/ lib / providers / shared.ts
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  }