forked from angular/dev-infra
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchild-process.ts
More file actions
246 lines (218 loc) · 9.63 KB
/
child-process.ts
File metadata and controls
246 lines (218 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
/**
* @license
* Copyright Google LLC
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import supportsColor from 'supports-color';
import {
spawn as _spawn,
SpawnOptions as _SpawnOptions,
spawnSync as _spawnSync,
SpawnSyncOptions as _SpawnSyncOptions,
ExecOptions as _ExecOptions,
exec as _exec,
ChildProcess as _ChildProcess,
} from 'child_process';
import {Log} from './logging.js';
import assert from 'assert';
export interface CommonCmdOpts {
// Stdin text to provide to the process. The raw text will be written to `stdin` and then
// the stream is closed. This is equivalent to the `input` option from `SpawnSyncOption`.
input?: string;
/** Console output mode. Defaults to "enabled". */
mode?: 'enabled' | 'silent' | 'on-error';
/** Whether to prevent exit codes being treated as failures. */
suppressErrorOnFailingExitCode?: boolean;
}
/** Interface describing the options for spawning a process synchronously. */
export interface SpawnSyncOptions
extends CommonCmdOpts, Omit<_SpawnSyncOptions, 'stdio' | 'input'> {}
/** Interface describing the options for spawning a process. */
export interface SpawnOptions extends CommonCmdOpts, Omit<_SpawnOptions, 'stdio'> {}
/** Interface describing the options for exec-ing a process. */
export interface ExecOptions extends CommonCmdOpts, Omit<_ExecOptions, 'stdio'> {}
/** Interface describing the options for spawning an interactive process. */
export interface SpawnInteractiveCommandOptions extends Omit<_SpawnOptions, 'stdio'> {}
/** Interface describing the result of a spawned process. */
export interface SpawnResult {
/** Captured stdout in string format. */
stdout: string;
/** Captured stderr in string format. */
stderr: string;
/** The exit code or signal of the process. */
status: number | NodeJS.Signals;
}
/** Interface describing the result of an exec process. */
export type ExecResult = SpawnResult;
/** Class holding utilities for spawning child processes. */
export abstract class ChildProcess {
/**
* Spawns a given command with the specified arguments inside an interactive shell. All process
* stdin, stdout and stderr output is printed to the current console.
*
* @returns a Promise resolving on success, and rejecting on command failure with the status code.
*/
static spawnInteractive(
command: string,
args: string[],
options: SpawnInteractiveCommandOptions = {},
) {
return new Promise<void>((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`;
Log.debug(`Executing command: ${commandText}`);
const childProcess = _spawn(command, args, {...options, stdio: 'inherit'});
// The `close` event is used because the process is guaranteed to have completed writing to
// stdout and stderr, using the `exit` event can cause inconsistent information in stdout and
// stderr due to a race condition around exiting.
childProcess.on('close', (status) => (status === 0 ? resolve() : reject(status)));
});
}
/**
* Spawns a given command with the specified arguments inside a shell synchronously.
*
* @returns The command's stdout and stderr.
*/
static spawnSync(command: string, args: string[], options: SpawnSyncOptions = {}): SpawnResult {
// Default shell to false to prevent OS command injection: with shell: true, Node.js
// internally joins command + args into a single string evaluated by /bin/sh, making
// shell metacharacters in args exploitable. Callers that genuinely require shell
// features (e.g. sourcing shell scripts) may explicitly pass shell: true.
const commandText = `${command} ${args.join(' ')}`;
const env = getEnvironmentForNonInteractiveCommand(options.env);
Log.debug(`Executing command: ${commandText}`);
const {
status: exitCode,
signal,
stdout,
stderr,
} = _spawnSync(command, args, {...options, env, encoding: 'utf8', stdio: 'pipe'});
/** The status of the spawn result. */
const status = statusFromExitCodeAndSignal(exitCode, signal);
if (status === 0 || options.suppressErrorOnFailingExitCode) {
return {status, stdout, stderr};
}
throw new Error(stderr);
}
/**
* Spawns a given command with the specified arguments inside a shell. All process stdout
* output is captured and returned as resolution on completion. Depending on the chosen
* output mode, stdout/stderr output is also printed to the console, or only on error.
*
* @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure.
*/
static spawn(command: string, args: string[], options: SpawnOptions = {}): Promise<SpawnResult> {
// Default shell to false to prevent OS command injection: with shell: true, Node.js
// internally joins command + args into a single string evaluated by /bin/sh, making
// shell metacharacters in args exploitable. Callers that genuinely require shell
// features (e.g. sourcing shell scripts) may explicitly pass shell: true.
const commandText = `${command} ${args.join(' ')}`;
const env = getEnvironmentForNonInteractiveCommand(options.env);
return processAsyncCmd(
commandText,
options,
_spawn(command, args, {...options, env, stdio: 'pipe'}),
);
}
/**
* Execs a given command with the specified arguments inside a shell. All process stdout
* output is captured and returned as resolution on completion. Depending on the chosen
* output mode, stdout/stderr output is also printed to the console, or only on error.
*
* @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure.
*/
static exec(command: string, options: ExecOptions = {}): Promise<SpawnResult> {
const env = getEnvironmentForNonInteractiveCommand(options.env);
return processAsyncCmd(command, options, _exec(command, {...options, env}));
}
}
/**
* Convert the provided exitCode and signal to a single status code.
*
* During `exit` node provides either a `code` or `signal`, one of which is guaranteed to be
* non-null.
*
* For more details see: https://nodejs.org/api/child_process.html#child_process_event_exit
*/
function statusFromExitCodeAndSignal(exitCode: number | null, signal: NodeJS.Signals | null) {
return exitCode ?? signal ?? -1;
}
/**
* Gets a process environment object with defaults that can be used for
* spawning non-interactive child processes.
*
* Currently we enable `FORCE_COLOR` since non-interactive spawn's with
* non-inherited `stdio` will not have colors enabled due to a missing TTY.
*/
function getEnvironmentForNonInteractiveCommand(
userProvidedEnv?: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
// Pass through the color level from the TTY/process performing the `spawn` call.
const forceColorValue =
supportsColor.stdout !== false ? supportsColor.stdout.level.toString() : undefined;
return {FORCE_COLOR: forceColorValue, ...(userProvidedEnv ?? process.env)};
}
/**
* Process the ChildProcess object created by an async command.
*/
function processAsyncCmd(
command: string,
options: CommonCmdOpts,
childProcess: _ChildProcess,
): Promise<SpawnResult> {
return new Promise((resolve, reject) => {
let logOutput = '';
let stdout = '';
let stderr = '';
Log.debug(`Executing command: ${command}`);
// If provided, write `input` text to the process `stdin`.
if (options.input !== undefined) {
assert(
childProcess.stdin,
'Cannot write process `input` if there is no pipe `stdin` channel.',
);
childProcess.stdin.write(options.input);
childProcess.stdin.end();
}
// Capture the stdout separately so that it can be passed as resolve value.
// This is useful if commands return parsable stdout.
childProcess.stderr?.on('data', (message) => {
stderr += message;
logOutput += message;
// If console output is enabled, print the message directly to the stderr. Note that
// we intentionally print all output to stderr as stdout should not be polluted.
if (options.mode === undefined || options.mode === 'enabled') {
process.stderr.write(message);
}
});
childProcess.stdout?.on('data', (message) => {
stdout += message;
logOutput += message;
// If console output is enabled, print the message directly to the stderr. Note that
// we intentionally print all output to stderr as stdout should not be polluted.
if (options.mode === undefined || options.mode === 'enabled') {
process.stderr.write(message);
}
});
// The `close` event is used because the process is guaranteed to have completed writing to
// stdout and stderr, using the `exit` event can cause inconsistent information in stdout and
// stderr due to a race condition around exiting.
childProcess.on('close', (exitCode, signal) => {
const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`;
const status = statusFromExitCodeAndSignal(exitCode, signal);
const printFn = status !== 0 && options.mode === 'on-error' ? Log.error : Log.debug;
printFn(`Command "${command}" completed with ${exitDescription}.`);
printFn(`Process output: \n${logOutput}`);
// On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`.
if (status === 0 || options.suppressErrorOnFailingExitCode) {
resolve({stdout, stderr, status});
} else {
reject(options.mode === 'silent' ? logOutput : undefined);
}
});
});
}