/** * CLI utility functions for the Ion workflow engine. * * Provides formatting, table rendering, and JSON output helpers * used across all CLI commands. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CliOptions { /** Working directory override. */ cwd?: string; /** Output as JSON (suppresses all other output). */ json?: boolean; /** Path to the workflow store database. */ store?: string; /** Path to the database file. */ dbPath?: string; /** Output file path (for convert command). */ output?: string; } // --------------------------------------------------------------------------- // Duration formatting // --------------------------------------------------------------------------- /** * Format a duration in milliseconds into a human-readable string. * * @example * formatDuration(90500) // "1m 30s" * formatDuration(3661000) // "1h 1m" * formatDuration(500) // "0s" */ export function formatDuration(ms: number): string { if (ms < 0) ms = 0; const seconds = Math.floor(ms / 1000) % 60; const minutes = Math.floor(ms / 60000) % 60; const hours = Math.floor(ms / 3600000); if (hours > 0) { return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } if (minutes > 0) { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } return `${seconds}s`; } // --------------------------------------------------------------------------- // Timestamp formatting // --------------------------------------------------------------------------- /** * Format a Date into an ISO-like timestamp suitable for CLI display. * * @example * formatTimestamp(new Date('2025-06-07T14:30:00Z')) * // "2025-06-07 14:30:00" */ export function formatTimestamp(date: Date): string { const y = date.getFullYear(); const mo = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const h = String(date.getHours()).padStart(2, '0'); const mi = String(date.getMinutes()).padStart(2, '0'); const s = String(date.getSeconds()).padStart(2, '0'); return `${y}-${mo}-${d} ${h}:${mi}:${s}`; } // --------------------------------------------------------------------------- // String truncation // --------------------------------------------------------------------------- /** * Truncate a string to `max` characters, appending an ellipsis if truncated. * * @example * truncate('hello world', 8) // "hello..." * truncate('hi', 8) // "hi" */ export function truncate(str: string, max: number): string { if (str.length <= max) return str; if (max <= 3) return str.slice(0, max); return str.slice(0, max - 3) + '...'; } // --------------------------------------------------------------------------- // Table rendering // --------------------------------------------------------------------------- export interface TableColumn { /** Column header label. */ header: string; /** Minimum column width. */ minWidth?: number; /** Field name to extract from each row object. */ field: string; } /** * Print a formatted table to stdout. * * @param rows - Array of row objects. * @param columns - Column definitions with header labels and field names. * * @example * printTable( * [{ name: 'deploy', desc: 'Deploy app' }], * [{ header: 'Name', field: 'name' }, { header: 'Description', field: 'desc' }], * ) */ export function printTable( rows: Record[], columns: TableColumn[], ): void { if (rows.length === 0) { console.log('(no results)'); return; } // Compute column widths. const widths: number[] = columns.map((col) => { const headerLen = col.header.length; const dataLen = Math.max( ...rows.map((row) => { const val = row[col.field]; const str = val === undefined || val === null ? '' : String(val); return str.length; }), 0, ); const min = col.minWidth ?? 0; return Math.max(headerLen, dataLen, min); }); // Header row. const headerLine = columns .map((col, i) => col.header.padEnd(widths[i]!)) .join(' '); console.log(headerLine); // Separator. const sepLine = widths.map((w) => '-'.repeat(w)).join(' '); console.log(sepLine); // Data rows. for (const row of rows) { const line = columns .map((col, i) => { const val = row[col.field]; const str = val === undefined || val === null ? '' : String(val); return str.padEnd(widths[i]!); }) .join(' '); console.log(line); } } // --------------------------------------------------------------------------- // JSON output // --------------------------------------------------------------------------- /** * Print a data structure as formatted JSON to stdout. * Uses 2-space indentation. */ export function printJson(data: unknown): void { console.log(JSON.stringify(data, null, 2)); } // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- /** * Parse CLI arguments into positional args and named options. * * Supports `--flag` (boolean) and `--key value` (string) formats. * Everything after `--` is treated as positional. * * @example * parseArgs(['run', 'deploy', '--json', '--cwd', '/tmp']) * // { args: ['run', 'deploy'], options: { json: true, cwd: '/tmp' } } */ export function parseArgs(argv: string[]): { args: string[]; options: Record; } { const args: string[] = []; const options: Record = {}; let i = 0; while (i < argv.length) { const token = argv[i]!; if (token === '--') { args.push(...argv.slice(i + 1)); break; } if (token.startsWith('--')) { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { options[key] = next; i += 2; } else { options[key] = true; i += 1; } } else { args.push(token); i += 1; } } return { args, options }; } /** * Build a CliOptions object from parsed options. * Extracts known CLI flags into their typed fields. */ export function buildCliOptions( options: Record, ): CliOptions { return { cwd: typeof options.cwd === 'string' ? options.cwd : undefined, json: options.json === true, store: typeof options.store === 'string' ? options.store : undefined, dbPath: typeof options['db-path'] === 'string' ? options['db-path'] : undefined, output: typeof options.output === 'string' ? options.output : undefined, }; }