1- import { exec , ExecOptions } from 'child_process'
1+ import { exec , ExecOptions } from 'node: child_process'
22import { traceInfo } from './common/log'
33
44export interface ExecResult {
@@ -7,29 +7,51 @@ export interface ExecResult {
77 stderr : string
88}
99
10- export interface CancellableExecOptions extends ExecOptions {
11- /** When `abort()` is called on this signal the child process is killed. */
12- signal ?: AbortSignal
10+ export async function execAsync (
11+ command : string ,
12+ args : string [ ] = [ ] ,
13+ options : ExecOptions & { signal ?: AbortSignal } = { } ,
14+ ) : Promise < ExecResult > {
15+ const fullCmd = `${ command } ${ args . join ( ' ' ) } `
16+ traceInfo ( `Executing command: ${ fullCmd } ` )
17+
18+ try {
19+ const result = await execAsyncCore ( command , args , options )
20+ traceInfo (
21+ `Command ${ fullCmd } exited with code ${ result . exitCode } ; stdout: ${ result . stdout } ; stderr: ${ result . stderr } ` ,
22+ )
23+ return result
24+ } catch ( err ) {
25+ if ( ( err as any ) ?. name === 'AbortError' ) {
26+ traceInfo ( `Command ${ fullCmd } was cancelled by AbortController` )
27+ } else {
28+ traceInfo ( `Command ${ fullCmd } failed: ${ ( err as Error ) . message } ` )
29+ }
30+ throw err // keep original error semantics
31+ }
1332}
1433
15- export function execAsync (
34+ function execAsyncCore (
1635 command : string ,
1736 args : string [ ] ,
18- options : CancellableExecOptions = { } ,
37+ options : ExecOptions & { signal ?: AbortSignal } = { } ,
1938) : Promise < ExecResult > {
20- return new Promise ( ( resolve , reject ) => {
21- // Pass the signal straight through to `exec`
22- traceInfo ( `Executing command: ${ command } ${ args . join ( ' ' ) } ` )
39+ return new Promise < ExecResult > ( ( resolve , reject ) => {
2340 const child = exec (
2441 `${ command } ${ args . join ( ' ' ) } ` ,
2542 options ,
2643 ( error , stdout , stderr ) => {
2744 if ( error ) {
28- resolve ( {
29- exitCode : typeof error . code === 'number' ? error . code : 1 ,
30- stdout,
31- stderr,
32- } )
45+ // Forward AbortError unchanged so callers can detect cancellation
46+ if ( ( error as NodeJS . ErrnoException ) . name === 'AbortError' ) {
47+ reject ( error )
48+ } else {
49+ resolve ( {
50+ exitCode : typeof error . code === 'number' ? error . code : 1 ,
51+ stdout,
52+ stderr,
53+ } )
54+ }
3355 return
3456 }
3557
@@ -41,28 +63,7 @@ export function execAsync(
4163 } ,
4264 )
4365
44- /* ---------- Tie the Promise life‑cycle to the AbortSignal ---------- */
45-
46- if ( options . signal ) {
47- // If the caller aborts: kill the child and reject the promise
48- const onAbort = ( ) => {
49- // `SIGTERM` is the default; use `SIGKILL` if you need something stronger
50- child . kill ( )
51- reject ( new Error ( 'Process cancelled' ) )
52- }
53-
54- if ( options . signal . aborted ) {
55- onAbort ( )
56- return
57- }
58- options . signal . addEventListener ( 'abort' , onAbort , { once : true } )
59-
60- // Clean‑up the event listener when the promise settles
61- const cleanup = ( ) => {
62- options . signal ! . removeEventListener ( 'abort' , onAbort )
63- }
64- child . once ( 'exit' , cleanup )
65- child . once ( 'error' , cleanup )
66- }
66+ // surface “spawn failed” errors that occur before the callback
67+ child . once ( 'error' , reject )
6768 } )
6869}
0 commit comments