v6
1// Copyright 2021 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import { ChalkInstance } from 'chalk'
16import {
17 ChildProcessByStdio,
18 SpawnOptionsWithStdioTuple,
19 StdioPipe,
20} from 'child_process'
21import { Readable, Writable } from 'node:stream'
22import { inspect, promisify } from 'node:util'
23import { spawn } from 'node:child_process'
24
25import { chalk, which } from './goods.js'
26import { runInCtx, getCtx, Context, Options, setRootCtx } from './context.js'
27import { printCmd, log } from './print.js'
28import { quote, substitute } from './guards.js'
29
30import psTreeModule from 'ps-tree'
31
32const psTree = promisify(psTreeModule)
33
34export interface Zx extends Options {
35 (pieces: TemplateStringsArray, ...args: any[]): ProcessPromise
36}
37
38export const $: Zx = function (pieces: TemplateStringsArray, ...args: any[]) {
39 let resolve, reject
40 let promise = new ProcessPromise((...args) => ([resolve, reject] = args))
41
42 let cmd = pieces[0],
43 i = 0
44 let quote = promise.ctx.quote
45 while (i < args.length) {
46 let s
47 if (Array.isArray(args[i])) {
48 s = args[i].map((x: any) => quote(substitute(x))).join(' ')
49 } else {
50 s = quote(substitute(args[i]))
51 }
52 cmd += s + pieces[++i]
53 }
54
55 Object.assign(promise.ctx, {
56 cmd,
57 __from: new Error().stack!.split(/^\s*at\s/m)[2].trim(),
58 resolve,
59 reject,
60 })
61
62 setImmediate(() => promise._run()) // Make sure all subprocesses are started, if not explicitly by await or then().
63
64 return promise
65}
66
67$.cwd = process.cwd()
68$.env = process.env
69$.quote = quote
70$.spawn = spawn
71$.verbose = 2
72$.maxBuffer = 200 * 1024 * 1024 /* 200 MiB*/
73$.prefix = '' // Bash not found, no prefix.
74$.shell = true
75try {
76 $.shell = which.sync('bash')
77 $.prefix = 'set -euo pipefail;'
78} catch (e) {}
79
80setRootCtx($)
81
82export class ProcessPromise extends Promise<ProcessOutput> {
83 child?: ChildProcessByStdio<Writable, Readable, Readable>
84 _resolved = false
85 _inheritStdin = true
86 _piped = false
87 _prerun: any = undefined
88 _postrun: any = undefined
89 readonly ctx: Context
90 constructor(cb: (resolve: Function, reject?: Function) => void) {
91 super(cb)
92 this.ctx = { ...getCtx() }
93 Object.defineProperty(this, 'ctx', {
94 value: this.ctx,
95 writable: false,
96 configurable: false,
97 })
98 }
99
100 get stdin() {
101 this._inheritStdin = false
102 this._run()
103 if (!this.child)
104 throw new Error('Access to stdin without creation a subprocess.')
105 return this.child.stdin
106 }
107
108 get stdout() {
109 this._inheritStdin = false
110 this._run()
111 if (!this.child)
112 throw new Error('Access to stdout without creation a subprocess.')
113 return this.child.stdout
114 }
115
116 get stderr() {
117 this._inheritStdin = false
118 this._run()
119 if (!this.child)
120 throw new Error('Access to stderr without creation a subprocess.')
121 return this.child.stderr
122 }
123
124 get exitCode() {
125 return this.then(
126 (p) => p.exitCode,
127 (p) => p.exitCode
128 )
129 }
130
131 pipe(dest: Writable | ProcessPromise | string) {
132 if (typeof dest === 'string') {
133 throw new Error('The pipe() method does not take strings. Forgot $?')
134 }
135 if (this._resolved) {
136 throw new Error(
137 "The pipe() method shouldn't be called after promise is already resolved!"
138 )
139 }
140 this._piped = true
141 if (dest instanceof ProcessPromise) {
142 dest._inheritStdin = false
143 dest._prerun = this._run.bind(this)
144 dest._postrun = () => {
145 if (!dest.child)
146 throw new Error(
147 'Access to stdin of pipe destination without creation a subprocess.'
148 )
149 this.stdout.pipe(dest.child.stdin)
150 }
151 return dest
152 } else {
153 this._postrun = () => this.stdout.pipe(dest)
154 return this
155 }
156 }
157
158 async kill(signal = 'SIGTERM') {
159 this.catch((_) => _)
160 if (!this.child)
161 throw new Error('Trying to kill child process without creating one.')
162 if (!this.child.pid) throw new Error('Child process pid is undefined.')
163 let children = await psTree(this.child.pid)
164 for (const p of children) {
165 try {
166 process.kill(+p.PID, signal)
167 } catch (e) {}
168 }
169 try {
170 process.kill(this.child.pid, signal)
171 } catch (e) {}
172 }
173
174 _run() {
175 if (this.child) return // The _run() called from two places: then() and setTimeout().
176 if (this._prerun) this._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run().
177
178 runInCtx(this.ctx, () => {
179 const {
180 nothrow,
181 cmd,
182 cwd,
183 env,
184 prefix,
185 shell,
186 maxBuffer,
187 __from,
188 resolve,
189 reject,
190 } = this.ctx
191
192 printCmd(cmd)
193
194 let options: SpawnOptionsWithStdioTuple<any, StdioPipe, StdioPipe> = {
195 cwd,
196 shell: typeof shell === 'string' ? shell : true,
197 stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
198 windowsHide: true,
199 // TODO: Surprise: maxBuffer have no effect for spawn.
200 // maxBuffer,
201 env,
202 }
203 let child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(
204 prefix + cmd,
205 options
206 )
207
208 child.on('close', (code, signal) => {
209 let message = `exit code: ${code}`
210 if (code != 0 || signal != null) {
211 message = `${stderr || '\n'} at ${__from}`
212 message += `\n exit code: ${code}${
213 exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : ''
214 }`
215 if (signal != null) {
216 message += `\n signal: ${signal}`
217 }
218 }
219 let output = new ProcessOutput({
220 code,
221 signal,
222 stdout,
223 stderr,
224 combined,
225 message,
226 })
227 ;(code === 0 || nothrow ? resolve : reject)(output)
228 this._resolved = true
229 })
230
231 let stdout = '',
232 stderr = '',
233 combined = ''
234 let onStdout = (data: any) => {
235 log({ scope: 'cmd', output: 'stdout', raw: true, verbose: 2 }, data)
236 stdout += data
237 combined += data
238 }
239 let onStderr = (data: any) => {
240 log({ scope: 'cmd', output: 'stderr', raw: true, verbose: 2 }, data)
241 stderr += data
242 combined += data
243 }
244 if (!this._piped) child.stdout.on('data', onStdout) // If process is piped, don't collect or print output.
245 child.stderr.on('data', onStderr) // Stderr should be printed regardless of piping.
246 this.child = child
247 if (this._postrun) this._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin.
248 })
249 }
250}
251
252export class ProcessOutput extends Error {
253 #code: number | null = null
254 #signal: NodeJS.Signals | null = null
255 #stdout = ''
256 #stderr = ''
257 #combined = ''
258
259 constructor({
260 code,
261 signal,
262 stdout,
263 stderr,
264 combined,
265 message,
266 }: {
267 code: number | null
268 signal: NodeJS.Signals | null
269 stdout: string
270 stderr: string
271 combined: string
272 message: string
273 }) {
274 super(message)
275 this.#code = code
276 this.#signal = signal
277 this.#stdout = stdout
278 this.#stderr = stderr
279 this.#combined = combined
280 }
281
282 toString() {
283 return this.#combined
284 }
285
286 get stdout() {
287 return this.#stdout
288 }
289
290 get stderr() {
291 return this.#stderr
292 }
293
294 get exitCode() {
295 return this.#code
296 }
297
298 get signal() {
299 return this.#signal
300 }
301
302 [inspect.custom]() {
303 let stringify = (s: string, c: ChalkInstance) =>
304 s.length === 0 ? "''" : c(inspect(s))
305 return `ProcessOutput {
306 stdout: ${stringify(this.stdout, chalk.green)},
307 stderr: ${stringify(this.stderr, chalk.red)},
308 signal: ${inspect(this.signal)},
309 exitCode: ${(this.exitCode === 0 ? chalk.green : chalk.red)(this.exitCode)}${
310 exitCodeInfo(this.exitCode)
311 ? chalk.grey(' (' + exitCodeInfo(this.exitCode) + ')')
312 : ''
313 }
314}`
315 }
316}
317
318function exitCodeInfo(exitCode: number | null): string | undefined {
319 return {
320 2: 'Misuse of shell builtins',
321 126: 'Invoked command cannot execute',
322 127: 'Command not found',
323 128: 'Invalid exit argument',
324 129: 'Hangup',
325 130: 'Interrupt',
326 131: 'Quit and dump core',
327 132: 'Illegal instruction',
328 133: 'Trace/breakpoint trap',
329 134: 'Process aborted',
330 135: 'Bus error: "access to undefined portion of memory object"',
331 136: 'Floating point exception: "erroneous arithmetic operation"',
332 137: 'Kill (terminate immediately)',
333 138: 'User-defined 1',
334 139: 'Segmentation violation',
335 140: 'User-defined 2',
336 141: 'Write to pipe with no one reading',
337 142: 'Signal raised by alarm',
338 143: 'Termination (request to terminate)',
339 145: 'Child process terminated, stopped (or continued*)',
340 146: 'Continue if stopped',
341 147: 'Stop executing temporarily',
342 148: 'Terminal stop signal',
343 149: 'Background process attempting to read from tty ("in")',
344 150: 'Background process attempting to write to tty ("out")',
345 151: 'Urgent data available on socket',
346 152: 'CPU time limit exceeded',
347 153: 'File size limit exceeded',
348 154: 'Signal raised by timer counting virtual time: "virtual timer expired"',
349 155: 'Profiling timer expired',
350 157: 'Pollable event',
351 159: 'Bad syscall',
352 }[exitCode || -1]
353}