UNPKG

@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
#!/usr/bin/env node "use strict"; 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)]); }