import { cac } from 'cac'; import pc from 'picocolors'; import { createContext, type CliContext } from './context.js'; const CLI_VERSION = '2.0.0'; const HELP_HEADER = ` ${pc.bold('apophis')} — Contract-driven API testing for Fastify ${pc.dim('Usage:')} apophis [options] ${pc.dim('Commands:')} init Scaffold config, scripts, and example usage verify Run deterministic contract verification observe Validate runtime observe configuration and reporting setup qualify Run scenario, stateful, protocol, or chaos-driven qualification replay Replay a failure using seed and stored trace doctor Validate config, environment safety, docs/example correctness migrate Check and rewrite deprecated config or API usage ${pc.dim('Global Options:')} --config Config file path --profile Profile name from config --generation-profile Generation budget profile (built-in or config alias) --cwd Working directory override --format Output format: human | json | ndjson (default: human) --color Color mode: auto | always | never (default: auto) --quiet Suppress non-error output --verbose Enable verbose logging --artifact-dir Directory for artifact output --workspace Run command across all workspace packages ${pc.dim('Other:')} -v, --version Show version number -h, --help Show help ${pc.dim('Examples:')} apophis init --preset safe-ci apophis verify --profile quick --routes "POST /users" apophis observe --profile staging-observe --check-config apophis qualify --profile oauth-nightly --seed 42 apophis replay --artifact reports/apophis/failure-*.json apophis doctor apophis doctor --workspace apophis migrate --dry-run `; function getCommandHelp(command: string): string { const helps: Record = { init: ` ${pc.bold('apophis init')} — Scaffold config, scripts, and example usage ${pc.dim('Usage:')} apophis init [options] ${pc.dim('Options:')} --preset Preset name (e.g. safe-ci, full) --force Overwrite existing files --noninteractive Skip all prompts, require explicit flags ${pc.dim('Examples:')} apophis init --preset safe-ci apophis init --force --noninteractive `, verify: ` ${pc.bold('apophis verify')} — Run deterministic contract verification ${pc.dim('Usage:')} apophis verify [options] ${pc.dim('Options:')} --profile Profile name from config --generation-profile Generation budget profile (built-in or config alias) --routes Route filter pattern --seed Deterministic seed --changed Filter to git-modified routes ${pc.dim('Examples:')} apophis verify --profile quick apophis verify --routes "POST /users" --seed 42 apophis verify --changed `, observe: ` ${pc.bold('apophis observe')} — Validate runtime observe configuration and reporting setup ${pc.dim('Usage:')} apophis observe [options] ${pc.dim('Options:')} --profile Profile name from config --check-config Only validate, do not activate ${pc.dim('Examples:')} apophis observe --profile staging-observe apophis observe --check-config `, qualify: ` ${pc.bold('apophis qualify')} — Run scenario, stateful, protocol, or chaos-driven qualification ${pc.dim('Usage:')} apophis qualify [options] ${pc.dim('Options:')} --profile Profile name from config --generation-profile Generation budget profile (built-in or config alias) --seed Deterministic seed ${pc.dim('Examples:')} apophis qualify --profile oauth-nightly --seed 42 `, replay: ` ${pc.bold('apophis replay')} — Replay a failure using seed and stored trace ${pc.dim('Usage:')} apophis replay --artifact ${pc.dim('Options:')} --artifact Path to failure artifact ${pc.dim('Examples:')} apophis replay --artifact reports/apophis/failure-*.json `, doctor: ` ${pc.bold('apophis doctor')} — Validate config, environment safety, docs/example correctness ${pc.dim('Usage:')} apophis doctor [options] ${pc.dim('Options:')} --mode Focus checks on a mode: verify | observe | qualify --strict Treat warnings as failures ${pc.dim('Examples:')} apophis doctor apophis doctor --mode verify apophis doctor --strict `, migrate: ` ${pc.bold('apophis migrate')} — Check and rewrite deprecated config or API usage ${pc.dim('Usage:')} apophis migrate [options] ${pc.dim('Options:')} --check Detect legacy config without rewriting --dry-run Show exact rewrites without writing --write Perform rewrites ${pc.dim('Examples:')} apophis migrate --check apophis migrate --dry-run apophis migrate --write `, }; return helps[command] || ''; } function printInternalError(error: unknown): void { console.error(); console.error(pc.red(' ╔══════════════════════════════════════════════════════════════╗')); console.error(pc.red(' ║ INTERNAL APOPHIS ERROR ║')); console.error(pc.red(' ╠══════════════════════════════════════════════════════════════╣')); console.error(pc.red(` ║ ${String(error).slice(0, 56).padEnd(56)} ║`)); console.error(pc.red(' ╚══════════════════════════════════════════════════════════════╝')); console.error(); console.error(pc.dim(' This is a bug in APOPHIS. Please report it with the full error')); console.error(pc.dim(' message and the command you ran.')); console.error(); } function resolveRequestedFormat(argv: string[]): 'human' | 'json' | 'ndjson' { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg) continue; if (arg === '--format' && argv[i + 1]) { const value = argv[i + 1]; if (value === 'json' || value === 'ndjson') return value; return 'human'; } if (arg.startsWith('--format=')) { const value = arg.slice('--format='.length); if (value === 'json' || value === 'ndjson') return value; return 'human'; } } return 'human'; } function writeMachineRecord( format: 'json' | 'ndjson', payload: Record, ): void { if (format === 'json') { process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); return; } process.stdout.write(JSON.stringify(payload) + '\n'); } type CommandName = 'init' | 'verify' | 'observe' | 'qualify' | 'replay' | 'doctor' | 'migrate'; type CommandHandler = (args: string[], ctx: CliContext) => Promise; const commandLoaders: Record Promise> = { init: async () => (await import('../commands/init/index.js')).handleInit, verify: async () => (await import('../commands/verify/index.js')).handleVerify, observe: async () => (await import('../commands/observe/index.js')).handleObserve, qualify: async () => (await import('../commands/qualify/index.js')).handleQualify, replay: async () => (await import('../commands/replay/index.js')).handleReplay, doctor: async () => (await import('../commands/doctor/index.js')).handleDoctor, migrate: async () => (await import('../commands/migrate/index.js')).handleMigrate, }; async function loadHandler(command: string): Promise { const loader = commandLoaders[command as CommandName]; return loader ? loader() : undefined; } export async function main(argv: string[] = process.argv.slice(2)): Promise { const cli = cac('apophis'); const requestedFormat = resolveRequestedFormat(argv); const machineMode = requestedFormat === 'json' || requestedFormat === 'ndjson'; // Global flags cli.option('--config ', 'Config file path'); cli.option('--profile ', 'Profile name from config'); cli.option('--generation-profile ', 'Generation budget profile (built-in or config alias)'); cli.option('--cwd ', 'Working directory override'); cli.option('--format ', 'Output format: human | json | ndjson', { default: 'human' }); cli.option('--color ', 'Color mode: auto | always | never', { default: 'auto' }); cli.option('--quiet', 'Suppress non-error output'); cli.option('--verbose', 'Enable verbose logging'); cli.option('--artifact-dir ', 'Directory for artifact output'); cli.option('--workspace', 'Run command across all workspace packages'); // Version cli.version(CLI_VERSION); // Override help to use our custom format // Note: cac's help() returns the CAC instance for chaining, but we just want to print cli.help = () => { console.log(HELP_HEADER); return cli; }; // Prevent cac from handling --version (we handle it manually) // cac.version() registers --version but we intercept it before cac processes it // Commands const commands = [ 'init', 'verify', 'observe', 'qualify', 'replay', 'doctor', 'migrate', ]; for (const command of commands) { const cmd = cli.command(command, getCommandHelp(command).split('\n')[1]?.trim() || `${command} command`); // Add command-specific options switch (command) { case 'init': cmd.option('--preset ', 'Preset name (e.g. safe-ci, full)'); cmd.option('--force', 'Overwrite existing files'); cmd.option('--noninteractive', 'Skip all prompts, require explicit flags'); break; case 'verify': cmd.option('--profile ', 'Profile name from config'); cmd.option('--generation-profile ', 'Generation budget profile (built-in or config alias)'); cmd.option('--routes ', 'Route filter pattern'); cmd.option('--seed ', 'Deterministic seed'); cmd.option('--changed', 'Filter to git-modified routes'); break; case 'observe': cmd.option('--profile ', 'Profile name from config'); cmd.option('--check-config', 'Only validate, do not activate'); break; case 'qualify': cmd.option('--profile ', 'Profile name from config'); cmd.option('--generation-profile ', 'Generation budget profile (built-in or config alias)'); cmd.option('--seed ', 'Deterministic seed'); break; case 'replay': cmd.option('--artifact ', 'Path to failure artifact'); break; case 'doctor': cmd.option('--mode ', 'Focus checks on a specific mode: verify | observe | qualify'); cmd.option('--strict', 'Treat warnings as failures'); break; case 'migrate': cmd.option('--check', 'Detect legacy config without rewriting'); cmd.option('--dry-run', 'Show exact rewrites without writing'); cmd.option('--write', 'Perform rewrites'); break; } cmd.action(async (options) => { const ctx = createContext(options); const handler = await loadHandler(command); if (!handler) { console.error(pc.red(`Unknown command: ${command}`)); return 2; } // Pass raw argv so doctor/migrate can parse extra flags const result = await handler(argv, ctx); // Ensure we always return a number (cac may swallow undefined) return typeof result === 'number' ? result : 0; }); } try { // Handle --help globally (before parsing) if (argv.includes('-h') || argv.includes('--help')) { const commandArg = argv.find(arg => commands.includes(arg)); if (commandArg) { const helpText = getCommandHelp(commandArg); if (helpText) { if (machineMode) { writeMachineRecord(requestedFormat, { command: commandArg, help: helpText, }); } else { console.log(helpText); } return 0; } } if (machineMode) { writeMachineRecord(requestedFormat, { help: HELP_HEADER }); } else { cli.help(); } return 0; } // Handle --version (before parsing) if (argv.includes('-v') || argv.includes('--version')) { if (machineMode) { writeMachineRecord(requestedFormat, { version: CLI_VERSION }); } else { console.log(CLI_VERSION); } return 0; } // Check for unknown commands const firstArg = argv[0]; if (firstArg && !firstArg.startsWith('-') && !commands.includes(firstArg)) { if (machineMode) { writeMachineRecord(requestedFormat, { error: `Unknown command: ${firstArg}`, availableCommands: commands, next: 'Run "apophis --help" for usage information.', }); } else { console.error(pc.red(`Unknown command: ${firstArg}`)); console.error(); console.error(pc.dim('Available commands:')); for (const cmd of commands) { console.error(pc.dim(` ${cmd}`)); } console.error(); console.error(pc.dim('Run "apophis --help" for usage information.')); } return 2; } // Handle unknown flags const knownGlobalFlags = new Set([ '--config', '--profile', '--cwd', '--format', '--color', '--generation-profile', '--quiet', '--verbose', '--artifact-dir', '--workspace', '-v', '--version', '-h', '--help', ]); const commandSpecificFlags: Record> = { init: new Set(['--preset', '--force', '--noninteractive']), verify: new Set(['--profile', '--generation-profile', '--routes', '--seed', '--changed', '--workspace']), observe: new Set(['--profile', '--check-config', '--workspace']), qualify: new Set(['--profile', '--generation-profile', '--seed', '--workspace']), replay: new Set(['--artifact']), doctor: new Set(['--mode', '--strict', '--workspace']), migrate: new Set(['--check', '--dry-run', '--write']), }; const activeCommand = firstArg && commands.includes(firstArg) ? firstArg : undefined; const activeCmdFlags = activeCommand ? commandSpecificFlags[activeCommand] : undefined; const allowedFlags = activeCmdFlags ? new Set([...knownGlobalFlags, ...activeCmdFlags]) : knownGlobalFlags; const unknownFlags: string[] = []; for (const arg of argv) { if (arg.startsWith('--') || (arg.startsWith('-') && arg.length > 1)) { const flagName = arg.split('=')[0]!; if (!allowedFlags.has(flagName)) { unknownFlags.push(flagName); } } } if (unknownFlags.length > 0) { if (machineMode) { writeMachineRecord(requestedFormat, { error: `Unknown flag: ${unknownFlags[0]}`, next: 'Run "apophis --help" for available options.', }); } else { console.error(pc.red(`Unknown flag: ${unknownFlags[0]}`)); console.error(); console.error(pc.dim('Run "apophis --help" for available options.')); } return 2; } // If no command provided, show help if (!firstArg || firstArg.startsWith('-')) { if (machineMode) { writeMachineRecord(requestedFormat, { help: HELP_HEADER }); } else { cli.help(); } return 0; } // Parse options for the command const parsed = cli.parse(['node', 'apophis', ...argv], { run: false }); // Directly dispatch to handler (bypass cac's runMatchedCommand which has issues) const handler = await loadHandler(firstArg); if (!handler) { console.error(pc.red(`Unknown command: ${firstArg}`)); return 2; } const ctx = createContext(parsed.options); const result = await handler(argv, ctx); return typeof result === 'number' ? result : 0; } catch (error) { if (machineMode) { writeMachineRecord(requestedFormat, { error: 'Internal APOPHIS error', detail: String(error), }); } else { printInternalError(error); } return 3; } } // src/cli/core/index.ts is the CLI logic module. The direct entrypoint is src/cli/index.ts. // Do NOT add a direct main() call here — that belongs in the entrypoint file only.