UNPKG

@naturalcycles/nodejs-lib

Version:
466 lines (429 loc) 12.5 kB
import { execSync, spawn, spawnSync } from 'node:child_process' import type { StdioOptions } from 'node:child_process' import { _since } from '@naturalcycles/js-lib/datetime/time.util.js' import { AppError } from '@naturalcycles/js-lib/error/error.util.js' import { _substringAfterLast } from '@naturalcycles/js-lib/string/string.util.js' import type { AnyObject, NumberOfMilliseconds, UnixTimestampMillis, } from '@naturalcycles/js-lib/types' import { dimGrey, dimRed, hasColors, white } from '../colors/colors.js' /** * Set of utility functions to work with Spawn / Exec. * * How to decide between Spawn and Exec? * * Long-running job that prints output, and no need to return the output - use Spawn. * * Short-running job, no need to print the output, might want to return the output - use Exec. * * Need to both print and return the output - use SpawnAsyncAndReturn. * * *** * * Spawn is good for long-running large-output processes, that continuously output data. * E.g running `jest`. * * Exec is the opposite - good for short-running processes that output small data. * Exec allows to return the output as a string. * Exec doesn't stream data during execution, so the output/error will only be printed * at the end. * Exec always uses the shell (there's no option to disable it). */ class Exec2 { /** * Reasons to use it: * - Sync * - Need to print output while running * * Limitations: * - Cannot return stdout/stderr (use exec, execAsync or spawnAsyncAndReturn for that) * * Defaults: * * shell: true * log: true */ spawn(cmd: string, opt: SpawnOptions = {}): void { const { shell = true, cwd, env, passProcessEnv = true, forceColor = hasColors, stdio = 'inherit', } = opt opt.log ??= true // by default log should be true, as we are printing the output opt.logStart ??= opt.log opt.logFinish ??= opt.log const started = Date.now() as UnixTimestampMillis this.logStart(cmd, opt) const r = spawnSync(cmd, opt.args, { encoding: 'utf8', stdio, shell, cwd, env: { ...(passProcessEnv ? process.env : {}), ...(forceColor ? { FORCE_COLOR: '1' } : {}), ...env, }, }) const isSuccessful = !r.error && !r.status this.logFinish(cmd, opt, started, isSuccessful) if (r.error) { throw r.error } if (r.status) { throw new Error(`spawn exited with code ${r.status}: ${cmd}`) } } /** * Reasons to use it: * * - Sync * - Need to return output * * Limitations: * - Cannot print while running (use spawn or spawnAsync for that) * * Defaults: * * shell: true * log: false */ exec(cmd: string, opt: ExecOptions = {}): string { const { cwd, env, passProcessEnv = true, timeout, stdio } = opt opt.logStart ??= opt.log ?? false opt.logFinish ??= opt.log ?? false const started = Date.now() as UnixTimestampMillis this.logStart(cmd, opt) try { const s = execSync(cmd, { encoding: 'utf8', stdio, // shell: undefined, cwd, timeout, env: { ...(passProcessEnv ? process.env : {}), ...env, }, }).trim() this.logFinish(cmd, opt, started, true) return s } catch (err) { // Not logging stderr, as it's printed by execSync by default (somehow) // stdout is not printed by execSync though, therefor we print it here // if ((err as any).stderr) { // process.stderr.write((err as any).stderr) // } if ((err as any).stdout) { process.stdout.write((err as any).stdout) } this.logFinish(cmd, opt, started, false) // oxlint-disable-next-line preserve-caught-error throw new Error(`exec exited with code ${(err as any).status}: ${cmd}`) } } /** * Reasons to use it: * - Async * - Need to print output while running * * Limitations: * - Cannot return stdout/stderr (use execAsync or spawnAsyncAndReturn for that) * * Defaults: * * shell: true * log: true */ async spawnAsync(cmd: string, opt: SpawnOptions = {}): Promise<void> { const { shell = true, cwd, env, passProcessEnv = true, forceColor = hasColors, stdio = 'inherit', } = opt opt.log ??= true // by default log should be true, as we are printing the output opt.logStart ??= opt.log opt.logFinish ??= opt.log const started = Date.now() as UnixTimestampMillis this.logStart(cmd, opt) await new Promise<void>((resolve, reject) => { const p = spawn(cmd, opt.args || [], { shell, cwd, stdio, env: { ...(passProcessEnv ? process.env : {}), ...(forceColor ? { FORCE_COLOR: '1' } : {}), ...env, }, }) p.on('close', (code, signal) => { const isSuccessful = code === 0 this.logFinish(cmd, opt, started, isSuccessful) if (signal) { return reject(new Error(`spawnAsync killed by signal ${signal}: ${cmd}`)) } if (!isSuccessful) { return reject(new Error(`spawnAsync exited with code ${code}: ${cmd}`)) } resolve() }) // Important to have this error listener. // Without it - the process hangs, and `close` is never emitted p.on('error', err => { console.error(err) }) }) } /** * Advanced/async version of Spawn. * Consider simpler `spawn` or `exec` first, which are also sync. * * spawnAsyncAndReturn features: * * 1. Async * 2. Allows to collect the output AND print it while running. * 3. Returns SpawnOutput with stdout, stderr and exitCode. * 4. Allows to not throw on error, but just return SpawnOutput for further inspection. * * Defaults: * * shell: true * printWhileRunning: true * collectOutputWhileRunning: true * throwOnNonZeroCode: true * log: true */ async spawnAsyncAndReturn(cmd: string, opt: SpawnAsyncOptions = {}): Promise<SpawnOutput> { const { shell = true, printWhileRunning = true, collectOutputWhileRunning = true, throwOnNonZeroCode = true, cwd, env, passProcessEnv = true, forceColor = hasColors, } = opt opt.log ??= printWhileRunning // by default log should be true, as we are printing the output opt.logStart ??= opt.log opt.logFinish ??= opt.log const started = Date.now() as UnixTimestampMillis this.logStart(cmd, opt) let stdout = '' let stderr = '' return await new Promise<SpawnOutput>((resolve, reject) => { const p = spawn(cmd, opt.args || [], { shell, cwd, env: { ...(passProcessEnv ? process.env : {}), ...(forceColor ? { FORCE_COLOR: '1' } : {}), ...env, }, }) p.stdout.on('data', data => { if (collectOutputWhileRunning) { stdout += data.toString() // console.log('stdout:', data.toString()) } if (printWhileRunning) { process.stdout.write(data) // console.log('stderr:', data.toString()) } }) p.stderr.on('data', data => { if (collectOutputWhileRunning) { stderr += data.toString() } if (printWhileRunning) { process.stderr.write(data) } }) p.on('close', (code, signal) => { const isSuccessful = code === 0 this.logFinish(cmd, opt, started, isSuccessful) const exitCode = code ?? -1 const o: SpawnOutput = { exitCode, stdout: stdout.trim(), stderr: stderr.trim(), } if (signal) { return reject(new SpawnError(`spawnAsyncAndReturn killed by signal ${signal}: ${cmd}`, o)) } if (throwOnNonZeroCode && !isSuccessful) { return reject(new SpawnError(`spawnAsyncAndReturn exited with code ${code}: ${cmd}`, o)) } resolve(o) }) // Important to have this error listener. // Without it - the process hangs, and `close` is never emitted p.on('error', err => { console.error(err) }) }) } private logStart(cmd: string, opt: SpawnOptions | ExecOptions): void { if (!opt.logStart) return const envString = Object.entries(opt.env || {}) .map(([k, v]) => [k, v].join('=')) .join(' ') if (opt.name) { console.log([' ', white(opt.name), dimGrey('started...')].filter(Boolean).join(' ')) } else { console.log( [ ' ', dimGrey(envString), // todo: only before first space white(_substringAfterLast(cmd, '/')), ...((opt as SpawnOptions).args || []), ] .filter(Boolean) .join(' '), ) } } private logFinish( cmd: string, opt: SpawnOptions | ExecOptions, started: UnixTimestampMillis, isSuccessful: boolean, ): void { if (isSuccessful && !opt.logFinish) return console.log( [ isSuccessful ? ' ✓' : ' ×', white(opt.name || _substringAfterLast(cmd, '/')), ...((!opt.name && (opt as SpawnOptions).args) || []), dimGrey('took ' + _since(started)), !isSuccessful && dimGrey('and ') + dimRed('failed'), ] .filter(Boolean) .join(' '), ) } } export const exec2 = new Exec2() export class SpawnError extends AppError<SpawnErrorData> { constructor(message: string, data: SpawnErrorData) { super(message, data, { name: 'SpawnError' }) } } export interface SpawnErrorData extends SpawnOutput {} export interface SpawnOutput { /** * Exit code of the spawned process. * 0 means success, anything else means failure. */ exitCode: number stdout: string stderr: string } export interface SpawnAsyncOptions extends SpawnOptions { /** * Defaults to true. * If true - prints both stdout and stderr to console while running, * otherwise runs "silently". * Returns SpawnOutput in the same way, regardless of `printWhileRunning` setting. */ printWhileRunning?: boolean /** * Defaults to true. * If true - collects stdout and stderr while running, and return it in the end. * stdout/stderr are collected and returned regardless if it returns with error or not. * On success - stdout/stderr are available from `SpawnOutput`. * On error - stdout/stderr are available from `SpawnError.data`. */ collectOutputWhileRunning?: boolean /** * Defaults to true. * If true - throws SpawnError if non-zero code is returned. * SpawnError conveniently contains .data.stdout and .data.strerr for inspection. * If false - will not throw, but return SpawnOutput with stdout, stderr and exitCode. */ throwOnNonZeroCode?: boolean } export interface SpawnOptions { args?: string[] /** * Defaults to true. */ logStart?: boolean /** * Defaults to true. */ logFinish?: boolean /** * Defaults to true. * Controls/overrides both logStart and logFinish simultaneously. */ log?: boolean /** * Defaults to true. */ shell?: boolean /** * If specified - will be used as "command name" for logging purposes, * instead of "cmd + args" */ name?: string cwd?: string env?: AnyObject /** * Defaults to true. * Set to false to NOT pass `process.env` to the spawned process. */ passProcessEnv?: boolean /** * Defaults to "auto detect colors". * Set to false or true to override. */ forceColor?: boolean /** * Defaults to "inherit" */ stdio?: StdioOptions } export interface ExecOptions { /** * Defaults to false. */ logStart?: boolean /** * Defaults to false. */ logFinish?: boolean /** * Defaults to false. * Controls/overrides both logStart and logFinish simultaneously. */ log?: boolean /** * If specified - will be used as "command name" for logging purposes, * instead of "cmd + args" */ name?: string cwd?: string timeout?: NumberOfMilliseconds env?: AnyObject /** * Defaults to false for security reasons. * Set to true to pass `process.env` to the spawned process. */ passProcessEnv?: boolean /** * Defaults to undefined. * beware that stdio: 'inherit', means we don't get the output returned. */ stdio?: StdioOptions }