/ src / utils / execFileNoThrow.ts
execFileNoThrow.ts
  1  // This file represents useful wrappers over node:child_process
  2  // These wrappers ease error handling and cross-platform compatbility
  3  // By using execa, Windows automatically gets shell escaping + BAT / CMD handling
  4  
  5  import { type ExecaError, execa } from 'execa'
  6  import { getCwd } from '../utils/cwd.js'
  7  import { logError } from './log.js'
  8  
  9  export { execSyncWithDefaults_DEPRECATED } from './execFileNoThrowPortable.js'
 10  
 11  const MS_IN_SECOND = 1000
 12  const SECONDS_IN_MINUTE = 60
 13  
 14  type ExecFileOptions = {
 15    abortSignal?: AbortSignal
 16    timeout?: number
 17    preserveOutputOnError?: boolean
 18    // Setting useCwd=false avoids circular dependencies during initialization
 19    // getCwd() -> PersistentShell -> logEvent() -> execFileNoThrow
 20    useCwd?: boolean
 21    env?: NodeJS.ProcessEnv
 22    stdin?: 'ignore' | 'inherit' | 'pipe'
 23    input?: string
 24  }
 25  
 26  export function execFileNoThrow(
 27    file: string,
 28    args: string[],
 29    options: ExecFileOptions = {
 30      timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
 31      preserveOutputOnError: true,
 32      useCwd: true,
 33    },
 34  ): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
 35    return execFileNoThrowWithCwd(file, args, {
 36      abortSignal: options.abortSignal,
 37      timeout: options.timeout,
 38      preserveOutputOnError: options.preserveOutputOnError,
 39      cwd: options.useCwd ? getCwd() : undefined,
 40      env: options.env,
 41      stdin: options.stdin,
 42      input: options.input,
 43    })
 44  }
 45  
 46  type ExecFileWithCwdOptions = {
 47    abortSignal?: AbortSignal
 48    timeout?: number
 49    preserveOutputOnError?: boolean
 50    maxBuffer?: number
 51    cwd?: string
 52    env?: NodeJS.ProcessEnv
 53    shell?: boolean | string | undefined
 54    stdin?: 'ignore' | 'inherit' | 'pipe'
 55    input?: string
 56  }
 57  
 58  type ExecaResultWithError = {
 59    shortMessage?: string
 60    signal?: string
 61  }
 62  
 63  /**
 64   * Extracts a human-readable error message from an execa result.
 65   *
 66   * Priority order:
 67   * 1. shortMessage - execa's human-readable error (e.g., "Command failed with exit code 1: ...")
 68   *    This is preferred because it already includes signal info when a process is killed,
 69   *    making it more informative than just the signal name.
 70   * 2. signal - the signal that killed the process (e.g., "SIGTERM")
 71   * 3. errorCode - fallback to just the numeric exit code
 72   */
 73  function getErrorMessage(
 74    result: ExecaResultWithError,
 75    errorCode: number,
 76  ): string {
 77    if (result.shortMessage) {
 78      return result.shortMessage
 79    }
 80    if (typeof result.signal === 'string') {
 81      return result.signal
 82    }
 83    return String(errorCode)
 84  }
 85  
 86  /**
 87   * execFile, but always resolves (never throws)
 88   */
 89  export function execFileNoThrowWithCwd(
 90    file: string,
 91    args: string[],
 92    {
 93      abortSignal,
 94      timeout: finalTimeout = 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
 95      preserveOutputOnError: finalPreserveOutput = true,
 96      cwd: finalCwd,
 97      env: finalEnv,
 98      maxBuffer,
 99      shell,
100      stdin: finalStdin,
101      input: finalInput,
102    }: ExecFileWithCwdOptions = {
103      timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
104      preserveOutputOnError: true,
105      maxBuffer: 1_000_000,
106    },
107  ): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
108    return new Promise(resolve => {
109      // Use execa for cross-platform .bat/.cmd compatibility on Windows
110      execa(file, args, {
111        maxBuffer,
112        signal: abortSignal,
113        timeout: finalTimeout,
114        cwd: finalCwd,
115        env: finalEnv,
116        shell,
117        stdin: finalStdin,
118        input: finalInput,
119        reject: false, // Don't throw on non-zero exit codes
120      })
121        .then(result => {
122          if (result.failed) {
123            if (finalPreserveOutput) {
124              const errorCode = result.exitCode ?? 1
125              void resolve({
126                stdout: result.stdout || '',
127                stderr: result.stderr || '',
128                code: errorCode,
129                error: getErrorMessage(
130                  result as unknown as ExecaResultWithError,
131                  errorCode,
132                ),
133              })
134            } else {
135              void resolve({ stdout: '', stderr: '', code: result.exitCode ?? 1 })
136            }
137          } else {
138            void resolve({
139              stdout: result.stdout,
140              stderr: result.stderr,
141              code: 0,
142            })
143          }
144        })
145        .catch((error: ExecaError) => {
146          logError(error)
147          void resolve({ stdout: '', stderr: '', code: 1 })
148        })
149    })
150  }