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 }