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}