/ src / external.ts
external.ts
  1  import * as fs from 'node:fs';
  2  import * as path from 'node:path';
  3  import * as os from 'node:os';
  4  import { fileURLToPath } from 'node:url';
  5  import { spawnSync, execFileSync } from 'node:child_process';
  6  import yaml from 'js-yaml';
  7  import { log } from './logger.js';
  8  import { EXIT_CODES, getErrorMessage } from './errors.js';
  9  
 10  const __dirname = path.dirname(fileURLToPath(import.meta.url));
 11  
 12  export interface ExternalCliInstall {
 13    mac?: string;
 14    linux?: string;
 15    windows?: string;
 16    default?: string;
 17  }
 18  
 19  export interface ExternalCliConfig {
 20    name: string;
 21    binary: string;
 22    description?: string;
 23    homepage?: string;
 24    tags?: string[];
 25    install?: ExternalCliInstall;
 26  }
 27  
 28  function getUserRegistryPath(): string {
 29    const home = os.homedir();
 30    return path.join(home, '.opencli', 'external-clis.yaml');
 31  }
 32  
 33  let _cachedExternalClis: ExternalCliConfig[] | null = null;
 34  
 35  export function loadExternalClis(): ExternalCliConfig[] {
 36    if (_cachedExternalClis) return _cachedExternalClis;
 37    const configs = new Map<string, ExternalCliConfig>();
 38  
 39    // 1. Load built-in
 40    const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
 41    try {
 42      if (fs.existsSync(builtinPath)) {
 43        const raw = fs.readFileSync(builtinPath, 'utf8');
 44        const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
 45        for (const item of parsed) configs.set(item.name, item);
 46      }
 47    } catch (err) {
 48      log.warn(`Failed to parse built-in external-clis.yaml: ${getErrorMessage(err)}`);
 49    }
 50  
 51    // 2. Load user custom
 52    const userPath = getUserRegistryPath();
 53    try {
 54      if (fs.existsSync(userPath)) {
 55        const raw = fs.readFileSync(userPath, 'utf8');
 56        const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
 57        for (const item of parsed) {
 58          configs.set(item.name, item); // Overwrite built-in if duplicated
 59        }
 60      }
 61    } catch (err) {
 62      log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
 63    }
 64  
 65    _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
 66    return _cachedExternalClis;
 67  }
 68  
 69  export function isBinaryInstalled(binary: string): boolean {
 70    try {
 71      const isWindows = os.platform() === 'win32';
 72      execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' });
 73      return true;
 74    } catch {
 75      return false;
 76    }
 77  }
 78  
 79  export function getInstallCmd(installConfig?: ExternalCliInstall): string | null {
 80    if (!installConfig) return null;
 81    const platform = os.platform();
 82    if (platform === 'darwin' && installConfig.mac) return installConfig.mac;
 83    if (platform === 'linux' && installConfig.linux) return installConfig.linux;
 84    if (platform === 'win32' && installConfig.windows) return installConfig.windows;
 85    if (installConfig.default) return installConfig.default;
 86    return null;
 87  }
 88  
 89  /**
 90   * Safely parses a command string into a binary and argument list.
 91   * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
 92   * cannot be safely expressed as execFileSync arguments.
 93   *
 94   * Args:
 95   *   cmd: Raw command string from YAML config (e.g. "brew install gh")
 96   *
 97   * Returns:
 98   *   Object with `binary` and `args` fields, or throws on unsafe input.
 99   */
100  export function parseCommand(cmd: string): { binary: string; args: string[] } {
101    const shellOperators = /&&|\|\|?|;|[><`$#\n\r]|\$\(/;
102    if (shellOperators.test(cmd)) {
103      throw new Error(
104        `Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
105          `Please install the tool manually.`
106      );
107    }
108  
109    // Tokenise respecting single- and double-quoted segments (no variable expansion).
110    const tokens: string[] = [];
111    const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
112    let match: RegExpExecArray | null;
113    while ((match = re.exec(cmd)) !== null) {
114      tokens.push(match[1] ?? match[2] ?? match[3]);
115    }
116  
117    if (tokens.length === 0) {
118      throw new Error(`Install command is empty.`);
119    }
120  
121    const [binary, ...args] = tokens;
122    return { binary, args };
123  }
124  
125  function shouldRetryWithCmdShim(binary: string, err: unknown): boolean {
126    const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
127    return os.platform() === 'win32' && !path.extname(binary) && code === 'ENOENT';
128  }
129  
130  function runInstallCommand(cmd: string): void {
131    const { binary, args } = parseCommand(cmd);
132  
133    try {
134      execFileSync(binary, args, { stdio: 'inherit' });
135    } catch (err) {
136      if (shouldRetryWithCmdShim(binary, err)) {
137        execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
138        return;
139      }
140      throw err;
141    }
142  }
143  
144  export function installExternalCli(cli: ExternalCliConfig): boolean {
145    if (!cli.install) {
146      log.error(`No auto-install command configured for '${cli.name}'.`);
147      log.info(`Please install '${cli.binary}' manually.`);
148      return false;
149    }
150  
151    const cmd = getInstallCmd(cli.install);
152    if (!cmd) {
153      log.error(`No install command for your platform (${os.platform()}) for '${cli.name}'.`);
154      if (cli.homepage) log.info(`See: ${cli.homepage}`);
155      return false;
156    }
157  
158    log.info(`'${cli.name}' is not installed. Auto-installing...`);
159    log.verbose(`$ ${cmd}`);
160    try {
161      runInstallCommand(cmd);
162      log.success(`Installed '${cli.name}' successfully.`);
163      return true;
164    } catch (err) {
165      log.error(`Failed to install '${cli.name}': ${getErrorMessage(err)}`);
166      return false;
167    }
168  }
169  
170  export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
171    const configs = preloaded ?? loadExternalClis();
172    const cli = configs.find((c) => c.name === name);
173    if (!cli) {
174      throw new Error(`External CLI '${name}' not found in registry.`);
175    }
176  
177    // 1. Check if installed
178    if (!isBinaryInstalled(cli.binary)) {
179      // 2. Try to auto install
180      const success = installExternalCli(cli);
181      if (!success) {
182        process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
183        return;
184      }
185    }
186  
187    // 3. Passthrough execution with stdio inherited
188    const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
189    if (result.error) {
190      log.error(`Failed to execute '${cli.binary}': ${result.error.message}`);
191      process.exitCode = EXIT_CODES.GENERIC_ERROR;
192      return;
193    }
194    
195    if (result.status !== null) {
196      process.exitCode = result.status;
197    }
198  }
199  
200  export interface RegisterOptions {
201    binary?: string;
202    install?: string;
203    description?: string;
204  }
205  
206  export function registerExternalCli(name: string, opts?: RegisterOptions): void {
207    const userPath = getUserRegistryPath();
208    const configDir = path.dirname(userPath);
209  
210    if (!fs.existsSync(configDir)) {
211      fs.mkdirSync(configDir, { recursive: true });
212    }
213  
214    let items: ExternalCliConfig[] = [];
215    if (fs.existsSync(userPath)) {
216      try {
217        const raw = fs.readFileSync(userPath, 'utf8');
218        items = (yaml.load(raw) || []) as ExternalCliConfig[];
219      } catch {
220        // Ignore
221      }
222    }
223  
224    const existingIndex = items.findIndex((c) => c.name === name);
225    
226    const newItem: ExternalCliConfig = {
227      name,
228      binary: opts?.binary || name,
229    };
230    if (opts?.description) newItem.description = opts.description;
231    if (opts?.install) newItem.install = { default: opts.install };
232  
233    if (existingIndex >= 0) {
234      items[existingIndex] = { ...items[existingIndex], ...newItem };
235      log.success(`Updated '${name}' in user registry.`);
236    } else {
237      items.push(newItem);
238      log.success(`Registered '${name}' in user registry.`);
239    }
240  
241    const dump = yaml.dump(items, { indent: 2, sortKeys: true });
242    fs.writeFileSync(userPath, dump, 'utf8');
243    _cachedExternalClis = null; // Invalidate cache so next load reflects the change
244    log.verbose(userPath);
245  }