@naturalcycles/nodejs-lib
Version:
Standard library for Node.js
288 lines (287 loc) • 10.2 kB
JavaScript
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' });
}
}