/ src / commanderAdapter.ts
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  }