@latentsearch/timemachine-cli
Version:
CLI tool for TimeMachine API. Generates time entries, lists users/projects, and features an enhanced dry-run output for generation.
375 lines (374 loc) • 18.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const axios_1 = __importDefault(require("axios"));
const commander_1 = require("commander");
const dotenv_1 = __importDefault(require("dotenv"));
const luxon_1 = require("luxon");
const ora_1 = __importDefault(require("ora"));
const chalk_1 = __importDefault(require("chalk"));
// Load environment variables
dotenv_1.default.config();
const program = new commander_1.Command();
program
.name('time-entry-generator')
.description('A CLI tool for TimeMachine including time entry generation and listing users/projects.')
.version('1.3.0') // Enhanced dry run output
.option('-b, --base-url <url>', 'API base URL', 'https://www.codeclock.xyz/api')
.option('-v, --verbose', 'Show detailed output', false);
// Helper function to get an authenticated Axios client
function getApiClient(globalOpts) {
const apiKey = process.env.TIMEMACHINE_API_KEY;
if (!apiKey) {
console.error(chalk_1.default.red('Error: TIMEMACHINE_API_KEY environment variable is required.'));
console.error(chalk_1.default.yellow('Tip: Create a .env file with TIMEMACHINE_API_KEY=your_api_key or set it in your environment.'));
process.exit(1);
}
if (globalOpts.verbose) {
console.log(chalk_1.default.gray(`Using API base URL: ${globalOpts.baseUrl}`));
}
return axios_1.default.create({
baseURL: globalOpts.baseUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
timeout: 10000,
validateStatus: function (status) {
return status >= 200 && status < 300; // Default
}
});
}
// --- Helper function to fetch User Name by ID ---
async function fetchUserNameById(apiClient, userId, verbose) {
if (verbose)
console.log(chalk_1.default.gray(`Fetching details for User ID: ${userId}...`));
try {
const response = await apiClient.get('/users');
const user = response.data.find((u) => u.id === userId);
if (user) {
let displayName = '';
if (user.given_name && user.family_name) {
displayName = `${user.given_name} ${user.family_name}`;
}
else if (user.given_name) {
displayName = user.given_name;
}
else if (user.family_name) {
displayName = user.family_name;
}
else if (user.name) { // Existing fallback for 'name' field
displayName = user.name;
}
else if (user.email) {
displayName = user.email;
}
if (displayName) {
if (verbose)
console.log(chalk_1.default.gray(`Found User (ID: ${userId}): ${displayName}`));
return displayName;
}
}
if (verbose)
console.log(chalk_1.default.yellow(`User ID ${userId} not found or lacks identifiable name/email fields.`));
return null;
}
catch (error) {
if (verbose)
console.error(chalk_1.default.red(`Error fetching user details for ID ${userId}: ${error.message}`));
return null; // Gracefully return null on error
}
}
// --- Helper function to fetch Project Name by ID ---
async function fetchProjectNameById(apiClient, projectId, verbose) {
if (verbose)
console.log(chalk_1.default.gray(`Fetching details for Project ID: ${projectId}...`));
try {
const response = await apiClient.get('/projects');
// Assuming projects is an array of objects like { id: number, name: string, ... }
const project = response.data.find((p) => p.id === projectId);
if (project && project.name) {
if (verbose)
console.log(chalk_1.default.gray(`Found Project: ${project.name}`));
return project.name;
}
if (verbose)
console.log(chalk_1.default.yellow(`Project ID ${projectId} not found or lacks name.`));
return null;
}
catch (error) {
if (verbose)
console.error(chalk_1.default.red(`Error fetching project details for ID ${projectId}: ${error.message}`));
return null; // Gracefully return null on error
}
}
// --- Generate Command ---
program
.command('generate')
.description('Generate time entries based on weekday patterns (default command if no other is specified)')
.requiredOption('-u, --user-id <id>', 'User ID for generating entries')
.requiredOption('-p, --project-id <id>', 'Project ID for generating entries')
.option('-s, --start-date <date>', 'Start date (YYYY-MM-DD)', luxon_1.DateTime.now().minus({ days: 30 }).toISODate())
.option('-e, --end-date <date>', 'End date (YYYY-MM-DD)', luxon_1.DateTime.now().toISODate())
.option('-d, --days <days>', 'Days to log time (comma-separated: 1=Monday through 7=Sunday)', '1,2,3,4,5')
.option('-H, --hours <hours>', 'Hours per day', '8') // Changed short option from -h to -H
.option('--dry-run', 'Perform a dry run without making actual API calls', false)
.action(async (cmdOptions) => {
const globalOpts = program.opts(); // Cast to expected type
const { userId: userIdStr, projectId: projectIdStr, startDate: startDateStr, endDate: endDateStr, days: daysOfWeekStr, hours: hoursStr, dryRun: isDryRun } = cmdOptions;
const isVerbose = globalOpts.verbose;
const baseUrl = globalOpts.baseUrl;
// Validate and parse inputs for generate command
const userId = parseInt(userIdStr, 10);
const projectId = parseInt(projectIdStr, 10);
const startDate = luxon_1.DateTime.fromISO(startDateStr);
const endDate = luxon_1.DateTime.fromISO(endDateStr);
const hoursPerDay = parseFloat(hoursStr);
const daysOfWeek = daysOfWeekStr.split(',').map((day) => parseInt(day.trim(), 10));
// Input validations
if (isNaN(userId) || userId <= 0) {
console.error(chalk_1.default.red('Error: User ID must be a positive number'));
process.exit(1);
}
if (isNaN(projectId) || projectId <= 0) {
console.error(chalk_1.default.red('Error: Project ID must be a positive number'));
process.exit(1);
}
if (!startDate.isValid) {
console.error(chalk_1.default.red(`Error: Invalid start date "${startDateStr}". Use YYYY-MM-DD format.`));
process.exit(1);
}
if (!endDate.isValid) {
console.error(chalk_1.default.red(`Error: Invalid end date "${endDateStr}". Use YYYY-MM-DD format.`));
process.exit(1);
}
if (endDate < startDate) {
console.error(chalk_1.default.red('Error: End date must be after start date'));
process.exit(1);
}
if (isNaN(hoursPerDay) || hoursPerDay <= 0) {
console.error(chalk_1.default.red('Error: Hours must be a positive number'));
process.exit(1);
}
if (!daysOfWeek.every((day) => day >= 1 && day <= 7)) {
console.error(chalk_1.default.red('Error: Days must be between 1 (Monday) and 7 (Sunday)'));
process.exit(1);
}
let apiClient = null;
if (!isDryRun) {
apiClient = getApiClient(globalOpts);
}
// Helper function to check if a date matches our weekday pattern
function shouldLogTimeOnDate(date) {
return daysOfWeek.includes(date.weekday);
}
let userNameDisplay = `ID: ${userId}`;
let projectNameDisplay = `ID: ${projectId}`;
// Attempt to fetch names for User and Project IDs for all modes (dry and live)
// This apiClient is specifically for fetching names. The main apiClient for POSTing is handled later.
const apiClientForNames = getApiClient(globalOpts);
if (isVerbose)
console.log(chalk_1.default.gray('Attempting to fetch names for User and Project IDs for display...'));
try {
const fetchedUserName = await fetchUserNameById(apiClientForNames, userId, isVerbose);
if (fetchedUserName)
userNameDisplay = `${fetchedUserName} (ID: ${userId})`;
const fetchedProjectName = await fetchProjectNameById(apiClientForNames, projectId, isVerbose);
if (fetchedProjectName)
projectNameDisplay = `${fetchedProjectName} (ID: ${projectId})`;
}
catch (error) {
// This catch is more of a safeguard; fetchUserNameById/fetchProjectNameById should handle their own errors gracefully.
console.warn(chalk_1.default.yellow('Warning: Could not fetch user/project names. Displaying IDs.'));
if (isVerbose && error instanceof Error) {
console.error(chalk_1.default.gray(`Error details: ${error.message}`));
}
}
console.log(chalk_1.default.blue('TimeMachine Time Entry Generator'));
console.log('----------------------------------------');
console.log(chalk_1.default.cyan('Configuration:'));
console.log(`User: ${userNameDisplay}`);
console.log(`Project: ${projectNameDisplay}`);
console.log(`Date Range: ${startDate.toISODate()} to ${endDate.toISODate()}`);
console.log(`Days Pattern: ${daysOfWeek.map((d) => ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][d - 1]).join(', ')}`);
console.log(`Hours per Day: ${hoursPerDay}`);
console.log(`API URL: ${baseUrl}`);
console.log(`Mode: ${isDryRun ? chalk_1.default.yellow('DRY RUN') : chalk_1.default.green('LIVE')}`);
console.log('----------------------------------------');
const datesToProcess = [];
let currentDate = startDate;
while (currentDate <= endDate) {
if (shouldLogTimeOnDate(currentDate)) {
datesToProcess.push(currentDate.toISODate());
}
currentDate = currentDate.plus({ days: 1 });
}
if (datesToProcess.length === 0) {
console.log(chalk_1.default.yellow('No matching dates found in the specified range and weekday pattern.'));
return;
}
console.log(`Found ${datesToProcess.length} dates matching your criteria.`);
if (isDryRun) {
console.log(chalk_1.default.yellow('\nDRY RUN - No time entries will be created.\n'));
datesToProcess.forEach(date => {
console.log(`Would create: ${date} - ${hoursPerDay} hours for User ${userNameDisplay} on Project ${projectNameDisplay}`);
});
console.log(chalk_1.default.green('\nDry run completed successfully'));
return;
}
if (!apiClient) { // Should not happen if not dryRun, but as a safeguard
console.error(chalk_1.default.red('Error: API client not initialized for live run.'));
process.exit(1);
}
const spinner = (0, ora_1.default)('Creating time entries...').start();
let created = 0;
let failed = 0;
for (let i = 0; i < datesToProcess.length; i++) {
const date = datesToProcess[i];
spinner.text = `Creating time entry ${i + 1}/${datesToProcess.length} (${date})`;
try {
const timeEntry = {
user_id: userId,
project_id: projectId,
date: date,
hours_worked: hoursPerDay
};
const response = await apiClient.post('/time-entries', timeEntry);
if (isVerbose && response?.data) {
spinner.stop(); // Stop for verbose log
console.log(chalk_1.default.gray(`\nResponse for ${date}:`), response.data);
spinner.start(); // Restart for next iteration
}
// No need to check response.status !== 201 as validateStatus in axios handles it
created++;
if (isVerbose) {
// Verbose success already logged if data exists
}
}
catch (error) {
failed++;
spinner.stop();
const errorMessage = error.response?.data?.error || error.message || 'Unknown error';
console.error(chalk_1.default.red(`\n✗ Failed to create entry for ${date}: ${errorMessage}`));
if (isVerbose && error.response?.data) {
console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
}
spinner.start();
}
}
spinner.stop();
console.log('----------------------------------------');
if (created > 0) {
console.log(chalk_1.default.green(`Successfully created ${created} time entries.`));
}
if (failed > 0) {
console.log(chalk_1.default.red(`Failed to create ${failed} time entries.`));
}
if (created === 0 && failed === 0 && datesToProcess.length > 0 && !isDryRun) {
console.log(chalk_1.default.yellow('No time entries were created, though matching dates were found. Check logs.'));
}
});
// --- List Users Command ---
program
.command('list-users')
.description('List all users and their IDs from the TimeMachine API')
.action(async () => {
const globalOpts = program.opts(); // Correct type
const apiClient = getApiClient(globalOpts);
const isVerbose = globalOpts.verbose;
if (isVerbose)
console.log(chalk_1.default.blue('Fetching users...'));
const spinner = (0, ora_1.default)('Fetching users...').start();
try {
const response = await apiClient.get('/users');
spinner.succeed(chalk_1.default.green('Successfully fetched users.'));
const users = response.data;
if (users && users.length > 0) {
console.log(chalk_1.default.cyan('\nAvailable Users:'));
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
// Dynamically calculate padding for names
const maxGivenNameLength = Math.max(...users.map((u) => u.given_name?.length || 0), 'Given Name'.length);
const maxFamilyNameLength = Math.max(...users.map((u) => u.family_name?.length || 0), 'Family Name'.length);
console.log(chalk_1.default.bold('ID'.padEnd(5)) +
chalk_1.default.bold('Given Name'.padEnd(maxGivenNameLength + 2)) +
chalk_1.default.bold('Family Name'.padEnd(maxFamilyNameLength + 2)) +
chalk_1.default.bold('Email'));
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
users.forEach((user) => {
console.log(String(user.id).padEnd(5) +
(user.given_name || '').padEnd(maxGivenNameLength + 2) +
(user.family_name || '').padEnd(maxFamilyNameLength + 2) +
user.email);
});
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
}
else {
console.log(chalk_1.default.yellow('No users found.'));
}
}
catch (error) {
spinner.fail(chalk_1.default.red('Failed to fetch users.'));
const errorMessage = error.response?.data?.error || error.message || 'Unknown error';
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
if (isVerbose && error.response?.data) {
console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
}
}
});
// --- List Projects Command ---
program
.command('list-projects')
.description('List all projects and their IDs from the TimeMachine API')
.action(async () => {
const globalOpts = program.opts(); // Correct type
const apiClient = getApiClient(globalOpts);
const isVerbose = globalOpts.verbose;
if (isVerbose)
console.log(chalk_1.default.blue('Fetching projects...'));
const spinner = (0, ora_1.default)('Fetching projects...').start();
try {
const response = await apiClient.get('/projects');
spinner.succeed(chalk_1.default.green('Successfully fetched projects.'));
const projects = response.data;
if (projects && projects.length > 0) {
console.log(chalk_1.default.cyan('\nAvailable Projects:'));
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
const maxProjectNameLength = Math.max(...projects.map((p) => p.name?.length || 0), 'Project Name'.length);
const maxCompanyNameLength = Math.max(...projects.map((p) => p.company_name?.length || 0), 'Company Name'.length);
console.log(chalk_1.default.bold('ID'.padEnd(5)) +
chalk_1.default.bold('Project Name'.padEnd(maxProjectNameLength + 2)) +
chalk_1.default.bold('Company Name'.padEnd(maxCompanyNameLength + 2)));
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
projects.forEach((project) => {
console.log(String(project.id).padEnd(5) +
(project.name || '').padEnd(maxProjectNameLength + 2) +
(project.company_name || 'N/A').padEnd(maxCompanyNameLength + 2));
});
console.log(chalk_1.default.gray('------------------------------------------------------------------------------------'));
}
else {
console.log(chalk_1.default.yellow('No projects found.'));
}
}
catch (error) {
spinner.fail(chalk_1.default.red('Failed to fetch projects.'));
const errorMessage = error.response?.data?.error || error.message || 'Unknown error';
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
if (isVerbose && error.response?.data) {
console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
}
}
});
// Make 'generate' the default command if no other command is specified
// This requires a bit more finesse with commander, or just instruct users to type 'generate'
// For simplicity, we'll require explicit commands. If no command, help is shown.
program.parse(process.argv);
// If no command was specified, Commander.js by default shows help.
// If you want 'generate' to be the default, you might need to check program.args.
if (!process.argv.slice(2).length) {
// program.help(); // Or, to make generate default (more complex):
// program.parse([process.argv[0], process.argv[1], 'generate', ...process.argv.slice(2)]);
}