execFileNoThrow.ts 4.2 KB

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