/ src / server / agents / cli-runner.ts
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  }