commanderAdapter.ts
1 /** 2 * Commander adapter: bridges Registry commands to Commander subcommands. 3 * 4 * This is a THIN adapter — it only handles: 5 * 1. Commander arg/option registration 6 * 2. Collecting kwargs from Commander's action args 7 * 3. Calling executeCommand (which handles browser sessions, validation, etc.) 8 * 4. Rendering output and errors 9 * 10 * All execution logic lives in execution.ts. 11 */ 12 13 import { Command } from 'commander'; 14 import { log } from './logger.js'; 15 import yaml from 'js-yaml'; 16 import { type CliCommand, fullName, getRegistry } from './registry.js'; 17 import { formatRegistryHelpText } from './serialization.js'; 18 import { render as renderOutput } from './output.js'; 19 import { executeCommand, prepareCommandArgs } from './execution.js'; 20 import { 21 CliError, 22 EXIT_CODES, 23 toEnvelope, 24 } from './errors.js'; 25 import { isDiagnosticEnabled } from './diagnostic.js'; 26 27 /** 28 * Register a single CliCommand as a Commander subcommand. 29 */ 30 export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): void { 31 if (siteCmd.commands.some((c: Command) => c.name() === cmd.name)) return; 32 33 const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : ''; 34 const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`); 35 if (cmd.aliases?.length) subCmd.aliases(cmd.aliases); 36 37 // Register positional args first, then named options 38 const positionalArgs: typeof cmd.args = []; 39 for (const arg of cmd.args) { 40 if (arg.positional) { 41 const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`; 42 subCmd.argument(bracket, arg.help ?? ''); 43 positionalArgs.push(arg); 44 } else { 45 const expectsValue = arg.required || arg.valueRequired; 46 const flag = expectsValue ? `--${arg.name} <value>` : `--${arg.name} [value]`; 47 if (arg.required) subCmd.requiredOption(flag, arg.help ?? ''); 48 else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default)); 49 else subCmd.option(flag, arg.help ?? ''); 50 } 51 } 52 subCmd 53 .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table') 54 .option('-v, --verbose', 'Debug output', false); 55 56 subCmd.addHelpText('after', formatRegistryHelpText(cmd)); 57 58 subCmd.action(async (...actionArgs: unknown[]) => { 59 const actionOpts = actionArgs[positionalArgs.length] ?? {}; 60 const optionsRecord = typeof actionOpts === 'object' && actionOpts !== null ? actionOpts as Record<string, unknown> : {}; 61 const startTime = Date.now(); 62 63 // ── Execute + render ──────────────────────────────────────────────── 64 try { 65 // ── Collect kwargs ──────────────────────────────────────────────── 66 const rawKwargs: Record<string, unknown> = {}; 67 for (let i = 0; i < positionalArgs.length; i++) { 68 const v = actionArgs[i]; 69 if (v !== undefined) rawKwargs[positionalArgs[i].name] = v; 70 } 71 for (const arg of cmd.args) { 72 if (arg.positional) continue; 73 const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase()); 74 const v = optionsRecord[arg.name] ?? optionsRecord[camelName]; 75 if (v !== undefined) rawKwargs[arg.name] = v; 76 } 77 const optionSources: Record<string, string> = {}; 78 for (const arg of cmd.args) { 79 if (arg.positional) continue; 80 const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase()); 81 const source = subCmd.getOptionValueSource(camelName) ?? subCmd.getOptionValueSource(arg.name); 82 if (source === 'cli') optionSources[arg.name] = source; 83 } 84 if (Object.keys(optionSources).length > 0) { 85 rawKwargs.__opencliOptionSources = optionSources; 86 } 87 const kwargs = prepareCommandArgs(cmd, rawKwargs); 88 89 const verbose = optionsRecord.verbose === true; 90 let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; 91 const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; 92 if (verbose) process.env.OPENCLI_VERBOSE = '1'; 93 if (cmd.deprecated) { 94 const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`; 95 const replacement = cmd.replacedBy ? ` Use ${cmd.replacedBy} instead.` : ''; 96 log.warn(`Deprecated: ${message}${replacement}`); 97 } 98 99 const result = await executeCommand(cmd, kwargs, verbose, { prepared: true }); 100 if (result === null || result === undefined) { 101 return; 102 } 103 104 const resolved = getRegistry().get(fullName(cmd)) ?? cmd; 105 if (!formatExplicit && format === 'table' && resolved.defaultFormat) { 106 format = resolved.defaultFormat; 107 } 108 109 if (verbose && (!result || (Array.isArray(result) && result.length === 0))) { 110 log.warn('Command returned an empty result.'); 111 } 112 renderOutput(result, { 113 fmt: format, 114 fmtExplicit: formatExplicit, 115 columns: resolved.columns, 116 title: `${resolved.site}/${resolved.name}`, 117 elapsed: (Date.now() - startTime) / 1000, 118 source: fullName(resolved), 119 footerExtra: resolved.footerExtra?.(kwargs), 120 }); 121 } catch (err) { 122 renderError(err, fullName(cmd), optionsRecord.verbose === true); 123 process.exitCode = resolveExitCode(err); 124 } 125 }); 126 } 127 128 // ── Exit code resolution ───────────────────────────────────────────────────── 129 130 function resolveExitCode(err: unknown): number { 131 if (err instanceof CliError) return err.exitCode; 132 return EXIT_CODES.GENERIC_ERROR; 133 } 134 135 // ── Error rendering ───────────────────────────────────────────────────────── 136 137 /** Emit AutoFix hint for repairable adapter errors (skipped if already in diagnostic mode). */ 138 function emitAutoFixHint(envelope: string, cmdName: string): string { 139 if (isDiagnosticEnabled()) return envelope; 140 return envelope + `# AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context\n# OPENCLI_DIAGNOSTIC=1 ${cmdName}\n`; 141 } 142 143 function renderError(err: unknown, cmdName: string, verbose: boolean): void { 144 const envelope = toEnvelope(err); 145 146 // In verbose mode, include stack trace for debugging 147 if (verbose && err instanceof Error && err.stack) { 148 envelope.error.stack = err.stack; 149 } 150 151 let output = yaml.dump(envelope, { sortKeys: false, lineWidth: 120, noRefs: true }); 152 153 // Append AutoFix hint for repairable errors 154 const code = envelope.error.code; 155 if (code === 'SELECTOR' || code === 'EMPTY_RESULT' || code === 'ADAPTER_LOAD' || code === 'UNKNOWN') { 156 output = emitAutoFixHint(output, cmdName); 157 } 158 159 process.stderr.write(output); 160 } 161 162 /** 163 * Register all commands from the registry onto a Commander program. 164 */ 165 export function registerAllCommands( 166 program: Command, 167 siteGroups: Map<string, Command>, 168 ): void { 169 const seen = new Set<CliCommand>(); 170 for (const [, cmd] of getRegistry()) { 171 if (seen.has(cmd)) continue; 172 seen.add(cmd); 173 let siteCmd = siteGroups.get(cmd.site); 174 if (!siteCmd) { 175 siteCmd = program.command(cmd.site).description(`${cmd.site} commands`); 176 siteGroups.set(cmd.site, siteCmd); 177 } 178 registerCommandToProgram(siteCmd, cmd); 179 } 180 }