UNPKG

@naturalcycles/nodejs-lib

Version:
288 lines (287 loc) 10.2 kB
import { execSync, spawn, spawnSync } 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 { 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, opt = {}) { 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(); 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, opt = {}) { const { cwd, env, passProcessEnv = true, timeout, stdio } = opt; opt.logStart ??= opt.log ?? false; opt.logFinish ??= opt.log ?? false; const started = Date.now(); 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.stdout) { process.stdout.write(err.stdout); } this.logFinish(cmd, opt, started, false); // oxlint-disable-next-line preserve-caught-error throw new Error(`exec exited with code ${err.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, opt = {}) { 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(); this.logStart(cmd, opt); await new Promise((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, opt = {}) { 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(); this.logStart(cmd, opt); let stdout = ''; let stderr = ''; return await new Promise((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 = { 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); }); }); } logStart(cmd, opt) { 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.args || []), ] .filter(Boolean) .join(' ')); } } logFinish(cmd, opt, started, isSuccessful) { if (isSuccessful && !opt.logFinish) return; console.log([ isSuccessful ? ' ✓' : ' ×', white(opt.name || _substringAfterLast(cmd, '/')), ...((!opt.name && opt.args) || []), dimGrey('took ' + _since(started)), !isSuccessful && dimGrey('and ') + dimRed('failed'), ] .filter(Boolean) .join(' ')); } } export const exec2 = new Exec2(); export class SpawnError extends AppError { constructor(message, data) { super(message, data, { name: 'SpawnError' }); } }