shared.ts
1 import { execFileSync, spawn } from 'child_process'; 2 import fs from 'fs'; 3 import os from 'os'; 4 import path from 'path'; 5 6 const cache = new Map<string, string>(); 7 const overrides = new Map<string, string>(); 8 9 function cacheAndReturn(name: string, resolved: string): string { 10 cache.set(name, resolved); 11 return resolved; 12 } 13 14 export function setBinaryOverride(name: string, overridePath: string): void { 15 if (overridePath) { 16 overrides.set(name, overridePath); 17 } else { 18 overrides.delete(name); 19 } 20 cache.delete(name); 21 } 22 23 /** 24 * Auto-detect the absolute path to a CLI binary, ignoring overrides and cache. 25 * On macOS/Linux the user's login shell is invoked so that profile-managed 26 * paths (Homebrew, Volta, nvm, etc.) are visible even when launched from Finder/Dock. 27 */ 28 export function detectBinaryPath(name: string, extraCandidates: string[] = []): string { 29 if (process.platform === 'win32') { 30 try { 31 const raw = execFileSync('where.exe', [name], { encoding: 'utf-8', timeout: 5000 }); 32 const result = raw.trim().split('\n')[0].trim(); 33 if (result) return result; 34 } catch { 35 /* fall through */ 36 } 37 } else { 38 for (const shell of ['/bin/zsh', '/bin/bash']) { 39 if (!fs.existsSync(shell)) continue; 40 try { 41 const result = execFileSync(shell, ['-lc', `which ${name}`], { encoding: 'utf-8', timeout: 5000 }).trim(); 42 if (result) return result; 43 } catch { 44 /* try next */ 45 } 46 } 47 } 48 49 const home = os.homedir(); 50 const candidates = [ 51 `/usr/local/bin/${name}`, 52 `/opt/homebrew/bin/${name}`, 53 `${home}/.local/bin/${name}`, 54 `${home}/.npm-global/bin/${name}`, 55 `${home}/.nvm/current/bin/${name}`, 56 ...extraCandidates, 57 ]; 58 59 // Scan nvm versioned directories (newest first) 60 const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); 61 if (fs.existsSync(nvmVersionsDir)) { 62 try { 63 const versions = fs 64 .readdirSync(nvmVersionsDir) 65 .filter((d) => d.startsWith('v')) 66 .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); 67 for (const v of versions) { 68 candidates.push(path.join(nvmVersionsDir, v, 'bin', name)); 69 } 70 } catch { 71 /* ignore */ 72 } 73 } 74 75 for (const p of candidates) { 76 if (fs.existsSync(p)) { 77 console.log(`[detect] ${name} → ${p}`); 78 return p; 79 } 80 } 81 82 console.log(`[detect] ${name} → fallback (not found)`); 83 return name; 84 } 85 86 /** 87 * Resolve the absolute path to a CLI binary. Checks user overrides first, 88 * then cache, then falls back to auto-detection. 89 */ 90 export function resolveBinaryPath(name: string, extraCandidates: string[] = []): string { 91 const override = overrides.get(name); 92 if (override) return override; 93 94 const cached = cache.get(name); 95 if (cached) return cached; 96 97 const detected = detectBinaryPath(name, extraCandidates); 98 return cacheAndReturn(name, detected); 99 } 100 101 // ── Shared CLI spawn helpers ──────────────────────────────────── 102 103 export interface StreamingCliOptions { 104 /** Absolute path to the CLI binary */ 105 binPath: string; 106 /** CLI name for log prefix and error messages (e.g. "claude", "gemini") */ 107 cliName: string; 108 /** Arguments to pass to the CLI */ 109 args: string[]; 110 /** Content piped to stdin */ 111 stdinContent: string; 112 /** Called for each streaming chunk */ 113 processLine: (line: string) => void; 114 /** Environment overrides (merged with process.env) */ 115 env?: NodeJS.ProcessEnv; 116 /** Install instructions shown when CLI is not found */ 117 installHint: string; 118 /** Custom error handler for non-zero exit codes. Return an Error or undefined to use default. */ 119 handleExitError?: (stderr: string) => Error | undefined; 120 /** When aborted, kills the child process and rejects with a cancellation error. */ 121 signal?: AbortSignal; 122 } 123 124 /** 125 * Spawn a CLI process with streaming stdout line-buffering, debug logging, 126 * and ENOENT handling. Used by both Claude and Gemini providers. 127 */ 128 function withBinDir(binPath: string, env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { 129 const binDir = path.dirname(binPath); 130 const existing = env?.PATH ?? process.env.PATH ?? ''; 131 return { ...env, PATH: `${binDir}${path.delimiter}${existing}` }; 132 } 133 134 export function spawnCliStreaming(opts: StreamingCliOptions): Promise<void> { 135 return new Promise((resolve, reject) => { 136 const proc = spawn(opts.binPath, opts.args, { env: withBinDir(opts.binPath, opts.env) }); 137 const tag = `[${opts.cliName}]`; 138 139 if (opts.signal) { 140 const onAbort = () => { 141 proc.kill(); 142 }; 143 opts.signal.addEventListener('abort', onAbort, { once: true }); 144 } 145 146 proc.on('error', (err: NodeJS.ErrnoException) => { 147 if (err.code === 'ENOENT') { 148 reject(new Error(`${opts.cliName} CLI not found at "${opts.binPath}". ${opts.installHint}`)); 149 } else { 150 reject(err); 151 } 152 }); 153 154 const debugPath = path.join(os.tmpdir(), `gnosis-${opts.cliName}-last-response.txt`); 155 const debugStream = fs.createWriteStream(debugPath, { flags: 'w' }); 156 const startMs = Date.now(); 157 158 let lineBuffer = ''; 159 let stderr = ''; 160 161 proc.stdout.on('data', (chunk: Buffer) => { 162 const str = chunk.toString(); 163 debugStream.write(str); 164 lineBuffer += str; 165 const newlineIdx = lineBuffer.lastIndexOf('\n'); 166 if (newlineIdx === -1) return; 167 const completeLines = lineBuffer.slice(0, newlineIdx); 168 lineBuffer = lineBuffer.slice(newlineIdx + 1); 169 for (const line of completeLines.split('\n')) opts.processLine(line); 170 const elapsed = ((Date.now() - startMs) / 1000).toFixed(1); 171 console.log(`${tag} +${elapsed}s streaming...`); 172 }); 173 proc.stderr.on('data', (chunk: Buffer) => { 174 stderr += chunk.toString(); 175 }); 176 177 proc.stdin.on('error', () => { 178 // Ignore EPIPE — the process may exit before stdin write completes 179 }); 180 proc.stdin.write(opts.stdinContent); 181 proc.stdin.end(); 182 183 proc.on('close', (code: number | null) => { 184 debugStream.end(); 185 if (lineBuffer.trim()) opts.processLine(lineBuffer); 186 if (opts.signal?.aborted) { 187 reject(new Error('GNOSIS_CANCELLED')); 188 return; 189 } 190 if (code === 0) { 191 const elapsed = ((Date.now() - startMs) / 1000).toFixed(1); 192 console.log(`${tag} CLI finished in ${elapsed}s -> ${debugPath}`); 193 resolve(); 194 } else { 195 console.error(`${tag} CLI exited with code ${String(code)}. stderr: ${stderr || '(empty)'}`); 196 console.error(`${tag} Raw stdout saved to: ${debugPath}`); 197 const custom = opts.handleExitError?.(stderr); 198 reject(custom ?? new Error(`${opts.cliName} CLI exited with code ${String(code)}: ${stderr.slice(0, 300)}`)); 199 } 200 }); 201 }); 202 } 203 204 export interface QuickCliOptions { 205 binPath: string; 206 cliName: string; 207 args: string[]; 208 stdinContent: string; 209 env?: NodeJS.ProcessEnv; 210 installHint: string; 211 handleExitError?: (stderr: string) => Error | undefined; 212 } 213 214 /** 215 * Spawn a CLI process collecting all stdout as text. Used for non-streaming 216 * calls like smart imports. 217 */ 218 export function spawnCliQuick(opts: QuickCliOptions): Promise<string> { 219 return new Promise((resolve, reject) => { 220 const proc = spawn(opts.binPath, opts.args, { env: withBinDir(opts.binPath, opts.env) }); 221 222 proc.on('error', (err: NodeJS.ErrnoException) => { 223 if (err.code === 'ENOENT') { 224 reject(new Error(`${opts.cliName} CLI not found at "${opts.binPath}". ${opts.installHint}`)); 225 } else { 226 reject(err); 227 } 228 }); 229 230 let stdout = ''; 231 let stderr = ''; 232 233 proc.stdout.on('data', (chunk: Buffer) => { 234 stdout += chunk.toString(); 235 }); 236 proc.stderr.on('data', (chunk: Buffer) => { 237 stderr += chunk.toString(); 238 }); 239 240 proc.stdin.on('error', () => { 241 // Ignore EPIPE — the process may exit before stdin write completes 242 }); 243 proc.stdin.write(opts.stdinContent); 244 proc.stdin.end(); 245 246 proc.on('close', (code: number | null) => { 247 if (code === 0) { 248 resolve(stdout.trim()); 249 } else { 250 const custom = opts.handleExitError?.(stderr); 251 reject(custom ?? new Error(`${opts.cliName} CLI exited with code ${String(code)}: ${stderr.slice(0, 300)}`)); 252 } 253 }); 254 }); 255 }