cli-runner.ts
1 import { spawn, spawnSync } from 'node:child_process' 2 3 export interface CliProcessResult { 4 exitCode: number | null 5 signal: NodeJS.Signals | null 6 timedOut: boolean 7 stdout: string 8 stderr: string 9 } 10 11 interface AvailabilityCacheEntry { 12 bin: string 13 available: boolean 14 checkedAtMs: number 15 } 16 17 const COMMAND_AVAILABILITY_CACHE_TTL_MS = 15_000 18 const commandAvailabilityCache = new Map<string, AvailabilityCacheEntry>() 19 20 export function isCliCommandAvailable(bin: string): boolean { 21 const now = Date.now() 22 const cached = commandAvailabilityCache.get(bin) 23 if (cached && now - cached.checkedAtMs < COMMAND_AVAILABILITY_CACHE_TTL_MS) { 24 return cached.available 25 } 26 27 let available = false 28 try { 29 const result = spawnSync(bin, ['--version'], { encoding: 'utf8' }) 30 available = result.status === 0 31 } catch { 32 available = false 33 } 34 35 commandAvailabilityCache.set(bin, { 36 bin, 37 available, 38 checkedAtMs: now, 39 }) 40 41 return available 42 } 43 44 export async function runCliProcess(input: { 45 bin: string 46 args: string[] 47 timeoutMs: number 48 cwd?: string 49 env?: NodeJS.ProcessEnv 50 }): Promise<CliProcessResult> { 51 return new Promise((resolve, reject) => { 52 const child = spawn(input.bin, input.args, { 53 cwd: input.cwd, 54 env: input.env, 55 stdio: ['ignore', 'pipe', 'pipe'], 56 }) 57 58 const stdoutChunks: Buffer[] = [] 59 const stderrChunks: Buffer[] = [] 60 let settled = false 61 let timedOut = false 62 const timeoutId = setTimeout(() => { 63 if (settled) return 64 timedOut = true 65 child.kill('SIGTERM') 66 67 setTimeout(() => { 68 if (settled) return 69 child.kill('SIGKILL') 70 }, 2_000).unref() 71 }, input.timeoutMs) 72 73 timeoutId.unref() 74 75 child.stdout.on('data', (chunk: Buffer | string) => { 76 stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) 77 }) 78 79 child.stderr.on('data', (chunk: Buffer | string) => { 80 stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) 81 }) 82 83 child.on('error', (error) => { 84 clearTimeout(timeoutId) 85 if (settled) return 86 settled = true 87 reject(error) 88 }) 89 90 child.on('close', (exitCode, signal) => { 91 clearTimeout(timeoutId) 92 if (settled) return 93 settled = true 94 resolve({ 95 exitCode, 96 signal, 97 timedOut, 98 stdout: Buffer.concat(stdoutChunks).toString('utf8'), 99 stderr: Buffer.concat(stderrChunks).toString('utf8'), 100 }) 101 }) 102 103 }) 104 } 105 106 export function truncateCliOutput(value: string, maxChars: number): string { 107 const trimmed = value.trim() 108 if (trimmed.length <= maxChars) return trimmed 109 return `${trimmed.slice(0, maxChars)}…` 110 }