Files
apophis-fastify/src/cli/core/index.ts
T

459 lines
16 KiB
TypeScript

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 <command> [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 <path> Config file path
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--cwd <path> Working directory override
--format <mode> Output format: human | json | ndjson (default: human)
--color <mode> Color mode: auto | always | never (default: auto)
--quiet Suppress non-error output
--verbose Enable verbose logging
--artifact-dir <path> 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<string, string> = {
init: `
${pc.bold('apophis init')} — Scaffold config, scripts, and example usage
${pc.dim('Usage:')}
apophis init [options]
${pc.dim('Options:')}
--preset <name> 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 <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--routes <filter> Route filter pattern
--seed <number> 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 <name> 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 <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--seed <number> 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 <path>
${pc.dim('Options:')}
--artifact <path> 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 <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<string, unknown>,
): 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<number>;
const commandLoaders: Record<CommandName, () => Promise<CommandHandler>> = {
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<CommandHandler | undefined> {
const loader = commandLoaders[command as CommandName];
return loader ? loader() : undefined;
}
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
const cli = cac('apophis');
const requestedFormat = resolveRequestedFormat(argv);
const machineMode = requestedFormat === 'json' || requestedFormat === 'ndjson';
// Global flags
cli.option('--config <path>', 'Config file path');
cli.option('--profile <name>', 'Profile name from config');
cli.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cli.option('--cwd <path>', 'Working directory override');
cli.option('--format <mode>', 'Output format: human | json | ndjson', { default: 'human' });
cli.option('--color <mode>', 'Color mode: auto | always | never', { default: 'auto' });
cli.option('--quiet', 'Suppress non-error output');
cli.option('--verbose', 'Enable verbose logging');
cli.option('--artifact-dir <path>', '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 <name>', '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 <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--routes <filter>', 'Route filter pattern');
cmd.option('--seed <number>', 'Deterministic seed');
cmd.option('--changed', 'Filter to git-modified routes');
break;
case 'observe':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--check-config', 'Only validate, do not activate');
break;
case 'qualify':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--seed <number>', 'Deterministic seed');
break;
case 'replay':
cmd.option('--artifact <path>', 'Path to failure artifact');
break;
case 'doctor':
cmd.option('--mode <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<string, Set<string>> = {
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.