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 }