/ src / registry.ts
registry.ts
  1  /**
  2   * Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration.
  3   */
  4  
  5  import type { IPage } from './types.js';
  6  
  7  export enum Strategy {
  8    PUBLIC = 'public',
  9    LOCAL = 'local',
 10    COOKIE = 'cookie',
 11    HEADER = 'header',
 12    INTERCEPT = 'intercept',
 13    UI = 'ui',
 14  }
 15  
 16  export interface Arg {
 17    name: string;
 18    type?: string;
 19    default?: unknown;
 20    required?: boolean;
 21    valueRequired?: boolean;
 22    positional?: boolean;
 23    help?: string;
 24    choices?: string[];
 25  }
 26  
 27  export interface RequiredEnv {
 28    name: string;
 29    help?: string;
 30  }
 31  
 32  export type CommandArgs = Record<string, any>;
 33  
 34  export interface CliCommand {
 35    site: string;
 36    name: string;
 37    aliases?: string[];
 38    description: string;
 39    domain?: string;
 40    strategy?: Strategy;
 41    browser?: boolean;
 42    args: Arg[];
 43    columns?: string[];
 44    func?: (page: IPage, kwargs: CommandArgs, debug?: boolean) => Promise<unknown>;
 45    pipeline?: Record<string, unknown>[];
 46    timeoutSeconds?: number;
 47    /** Origin of this command: 'yaml', 'ts', or plugin name. */
 48    source?: string;
 49    footerExtra?: (kwargs: CommandArgs) => string | undefined;
 50    requiredEnv?: RequiredEnv[];
 51    validateArgs?: (kwargs: CommandArgs) => void;
 52    /** Deprecation note shown in help / execution warnings. */
 53    deprecated?: boolean | string;
 54    /** Preferred replacement command, if any. */
 55    replacedBy?: string;
 56    /**
 57     * Control pre-navigation and browser-session requirement.
 58     *
 59     * After normalizeCommand() expands strategy, this field carries the
 60     * resolved runtime intent:
 61     *
 62     * - `undefined`: no pre-navigation, browser session decided by pipeline steps
 63     * - `false`: explicitly skip pre-navigation (adapter handles its own navigation)
 64     * - `true`: needs authenticated browser context but no specific pre-nav URL
 65     *   (e.g. INTERCEPT/UI adapters, or COOKIE without domain)
 66     * - `string`: pre-navigate to this URL before running the adapter
 67     *   (e.g. `'https://x.com'` for COOKIE strategy with domain)
 68     *
 69     * Adapter authors can set this explicitly to override the strategy-based default.
 70     */
 71    navigateBefore?: boolean | string;
 72    /** Override the default CLI output format when the user does not pass -f/--format. */
 73    defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv';
 74  }
 75  
 76  /** Internal extension for lazy-loaded TS modules (not exposed in public API) */
 77  export interface InternalCliCommand extends CliCommand {
 78    _lazy?: boolean;
 79    _modulePath?: string;
 80  }
 81  export interface CliOptions extends Partial<Omit<CliCommand, 'args' | 'description'>> {
 82    site: string;
 83    name: string;
 84    description?: string;
 85    args?: Arg[];
 86  }
 87  
 88  // Use globalThis to ensure a single shared registry across all module instances.
 89  // This is critical for TS plugins loaded via npm link / peerDependency — without
 90  // this, the plugin's import creates a separate module instance with its own Map.
 91  declare global { var __opencli_registry__: Map<string, CliCommand> | undefined; }
 92  const _registry: Map<string, CliCommand> =
 93    globalThis.__opencli_registry__ ??= new Map<string, CliCommand>();
 94  
 95  export function cli(opts: CliOptions): CliCommand {
 96    const cmd: CliCommand = {
 97      site: opts.site,
 98      name: opts.name,
 99      aliases: opts.aliases,
100      description: opts.description ?? '',
101      domain: opts.domain,
102      strategy: opts.strategy,
103      browser: opts.browser,
104      args: opts.args ?? [],
105      columns: opts.columns,
106      func: opts.func,
107      pipeline: opts.pipeline,
108      timeoutSeconds: opts.timeoutSeconds,
109      footerExtra: opts.footerExtra,
110      requiredEnv: opts.requiredEnv,
111      deprecated: opts.deprecated,
112      replacedBy: opts.replacedBy,
113      navigateBefore: opts.navigateBefore,
114      defaultFormat: opts.defaultFormat,
115    };
116  
117    registerCommand(cmd);
118    return _registry.get(fullName(cmd))!;
119  }
120  
121  export function getRegistry(): Map<string, CliCommand> {
122    return _registry;
123  }
124  
125  export function fullName(cmd: CliCommand): string {
126    return `${cmd.site}/${cmd.name}`;
127  }
128  
129  export function strategyLabel(cmd: CliCommand): string {
130    return cmd.strategy ?? Strategy.PUBLIC;
131  }
132  
133  /**
134   * Normalize a command's runtime fields. This is the single place where
135   * `strategy` is decoded into the concrete fields that the execution path
136   * reads (`browser`, `navigateBefore`). After normalization, execution code
137   * (resolvePreNav, shouldUseBrowserSession) never reads `cmd.strategy`.
138   *
139   * `strategy` itself is preserved as metadata for `opencli list`, cascade
140   * probe, adapter generation, and human documentation.
141   *
142   * Override priority (highest wins):
143   *   1. Explicit field on the command (`browser: false`, `navigateBefore: false`)
144   *   2. Derived from strategy + domain (the defaults below)
145   */
146  function normalizeCommand(cmd: CliCommand): CliCommand {
147    const strategy = cmd.strategy ?? (cmd.browser === false ? Strategy.PUBLIC : Strategy.COOKIE);
148    const browser = cmd.browser ?? (strategy !== Strategy.PUBLIC && strategy !== Strategy.LOCAL);
149  
150    let navigateBefore = cmd.navigateBefore;
151    if (navigateBefore === undefined) {
152      if ((strategy === Strategy.COOKIE || strategy === Strategy.HEADER) && cmd.domain) {
153        navigateBefore = `https://${cmd.domain}`;
154      } else if (strategy !== Strategy.PUBLIC && strategy !== Strategy.LOCAL) {
155        // Non-PUBLIC without domain: needs authenticated browser context
156        // but no specific pre-navigation URL. `true` signals this to
157        // shouldUseBrowserSession without triggering resolvePreNav.
158        navigateBefore = true;
159      }
160    }
161  
162    return { ...cmd, strategy, browser, navigateBefore };
163  }
164  
165  export function registerCommand(cmd: CliCommand): void {
166    const normalized = normalizeCommand(cmd);
167    const canonicalKey = fullName(normalized);
168    const existing = _registry.get(canonicalKey);
169    if (existing?.aliases) {
170      for (const alias of existing.aliases) {
171        _registry.delete(`${existing.site}/${alias}`);
172      }
173    }
174  
175    const aliases = normalizeAliases(normalized.aliases, normalized.name);
176    normalized.aliases = aliases.length > 0 ? aliases : undefined;
177    _registry.set(canonicalKey, normalized);
178    for (const alias of aliases) {
179      _registry.set(`${normalized.site}/${alias}`, normalized);
180    }
181  }
182  
183  function normalizeAliases(aliases: string[] | undefined, commandName: string): string[] {
184    if (!Array.isArray(aliases) || aliases.length === 0) return [];
185  
186    const seen = new Set<string>();
187    const normalized: string[] = [];
188    for (const alias of aliases) {
189      const value = typeof alias === 'string' ? alias.trim() : '';
190      if (!value || value === commandName || seen.has(value)) continue;
191      seen.add(value);
192      normalized.push(value);
193    }
194    return normalized;
195  }