/** * CLI Formatting Utilities * * Terminal formatting and styling for CLI output. * * @module cli/formatting */ import type { MigrationProgressDisplay, RollbackProgressDisplay } from './types.js' // ============================================================================ // ANSI Color Codes // ============================================================================ const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', // Foreground colors red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', } // Check if colors should be used const supportsColor = process.stdout.isTTY && !process.env['NO_COLOR'] function colorize(text: string, color: keyof typeof colors): string { if (!supportsColor) { return text } return `${colors[color]}${text}${colors.reset}` } // ============================================================================ // Status Formatting // ============================================================================ /** * Format a success message */ export function formatSuccess(message: string): string { return colorize(`✓ ${message}`, 'green') } /** * Format an error message */ export function formatError(message: string): string { return colorize(`✗ ${message}`, 'red') } /** * Format a warning message */ export function formatWarning(message: string): string { return colorize(`! ${message}`, 'yellow') } /** * Format an info message */ export function formatInfo(message: string): string { return colorize(`→ ${message}`, 'blue') } /** * Format a dim/secondary message */ export function formatDim(message: string): string { return colorize(message, 'gray') } /** * Format a progress/step message */ export function formatProgress(message: string): string { return colorize(` ${message}`, 'cyan') } // ============================================================================ // Progress Formatting // ============================================================================ /** * Format a migration progress event */ export function formatMigrationResult(event: MigrationProgressDisplay): string { const { migration, index, total, phase, durationMs, error } = event const progress = `[${index + 1}/${total}]` const version = `v${migration.version}` switch (phase) { case 'starting': return `${formatDim(progress)} ${formatInfo(`Starting migration ${version}: ${migration.name}`)}` case 'executing': return `${formatDim(progress)} ${formatDim(`Executing ${version}...`)}` case 'completed': const time = durationMs ? ` (${formatDuration(durationMs)})` : '' return `${formatDim(progress)} ${formatSuccess(`Applied ${version}: ${migration.name}${time}`)}` case 'failed': return `${formatDim(progress)} ${formatError(`Failed ${version}: ${migration.name}`)}${error ? `\n ${formatError(error)}` : ''}` case 'skipped': return `${formatDim(progress)} ${formatDim(`Skipped ${version}: ${migration.name} (already applied)`)}` default: return `${formatDim(progress)} ${migration.id}` } } /** * Format a rollback progress event */ export function formatRollbackResult(event: RollbackProgressDisplay): string { const { migration, index, total, phase, durationMs, error, isDryRun } = event const progress = `[${index + 1}/${total}]` const version = `v${migration.version}` const dryRunLabel = isDryRun ? ' (dry run)' : '' switch (phase) { case 'starting': return `${formatDim(progress)} ${formatInfo(`Rolling back ${version}: ${migration.name}${dryRunLabel}`)}` case 'executing': return `${formatDim(progress)} ${formatDim(`Executing rollback ${version}...`)}` case 'completed': const time = durationMs ? ` (${formatDuration(durationMs)})` : '' return `${formatDim(progress)} ${formatSuccess(`Rolled back ${version}: ${migration.name}${time}${dryRunLabel}`)}` case 'failed': return `${formatDim(progress)} ${formatError(`Rollback failed ${version}: ${migration.name}`)}${error ? `\n ${formatError(error)}` : ''}` case 'skipped': return `${formatDim(progress)} ${formatDim(`Skipped ${version}: ${migration.name} (not applied or non-reversible)`)}` default: return `${formatDim(progress)} ${migration.id}` } } // ============================================================================ // Duration Formatting // ============================================================================ /** * Format a duration in milliseconds to a human-readable string */ export function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms` } if (ms < 60000) { return `${(ms / 1000).toFixed(2)}s` } const minutes = Math.floor(ms / 60000) const seconds = ((ms % 60000) / 1000).toFixed(0) return `${minutes}m ${seconds}s` } // ============================================================================ // Table Formatting // ============================================================================ /** * Format data as a simple ASCII table */ export function formatTable(headers: string[], rows: string[][]): string { // Calculate column widths const widths = headers.map((h, i) => { const maxRowWidth = Math.max(...rows.map((r) => (r[i] || '').length)) return Math.max(h.length, maxRowWidth) }) // Format header const headerRow = headers.map((h, i) => h.padEnd(widths[i] || 0)).join(' | ') const separator = widths.map((w) => '-'.repeat(w)).join('-+-') // Format rows const dataRows = rows.map((row) => row.map((cell, i) => (cell || '').padEnd(widths[i] || 0)).join(' | ') ) return [headerRow, separator, ...dataRows].join('\n') } // ============================================================================ // Spinner (Simple) // ============================================================================ const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] /** * Create a simple spinner */ export function createSpinner(message: string) { let frame = 0 let intervalId: NodeJS.Timeout | null = null return { start() { if (!supportsColor) { console.log(message) return } intervalId = setInterval(() => { process.stdout.write(`\r${colorize(spinnerFrames[frame] || '⠋', 'cyan')} ${message}`) frame = (frame + 1) % spinnerFrames.length }, 80) }, stop(finalMessage?: string) { if (intervalId) { clearInterval(intervalId) process.stdout.write('\r' + ' '.repeat(message.length + 3) + '\r') if (finalMessage) { console.log(finalMessage) } } }, succeed(msg?: string) { this.stop(formatSuccess(msg || message)) }, fail(msg?: string) { this.stop(formatError(msg || message)) }, } } // ============================================================================ // Progress Bar // ============================================================================ /** * Create a simple progress bar */ export function formatProgressBar(current: number, total: number, width = 30): string { const percent = Math.round((current / total) * 100) const filled = Math.round((current / total) * width) const empty = width - filled const bar = `[${'█'.repeat(filled)}${'░'.repeat(empty)}]` return `${bar} ${percent}%` }