first commit

This commit is contained in:
2026-09-08 20:28:54 +08:00
commit 2ada8d3d5a
37380 changed files with 4886169 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable promise/prefer-await-to-callbacks */
import { spawn } from 'child_process';
import { quote } from 'shell-quote';
import B from 'bluebird';
import _ from 'lodash';
import { formatEnoent } from './helpers';
const MAX_BUFFER_SIZE = 100 * 1024 * 1024;
async function exec (cmd, args = [], opts = {}) {
// get a quoted representation of the command for error strings
const rep = quote([cmd, ...args]);
// extend default options; we're basically re-implementing exec's options
// for use here with spawn under the hood
opts = Object.assign({
timeout: null,
encoding: 'utf8',
killSignal: 'SIGTERM',
cwd: undefined,
env: process.env,
ignoreOutput: false,
stdio: 'inherit',
isBuffer: false,
shell: undefined,
logger: undefined,
maxStdoutBufferSize: MAX_BUFFER_SIZE,
maxStderrBufferSize: MAX_BUFFER_SIZE,
}, opts);
// this is an async function, so return a promise
return await new B((resolve, reject) => {
// spawn the child process with options; we don't currently expose any of
// the other 'spawn' options through the API
let proc = spawn(cmd, args, {cwd: opts.cwd, env: opts.env, shell: opts.shell});
let stdoutArr = [], stderrArr = [], timer = null;
// if the process errors out, reject the promise
proc.on('error', (err) => {
if (err.errno === 'ENOENT') {
err = formatEnoent(err, cmd, opts.cwd);
}
reject(err);
});
if (proc.stdin) {
proc.stdin.on('error', (err) => {
reject(new Error(`Standard input '${err.syscall}' error: ${err.stack}`));
});
}
const handleStream = (streamType, streamProps) => {
if (!proc[streamType]) {
return;
}
proc[streamType].on('error', (err) => {
reject(new Error(`${_.capitalize(streamType)} '${err.syscall}' error: ${err.stack}`));
});
if (opts.ignoreOutput) {
// https://github.com/nodejs/node/issues/4236
proc[streamType].on('data', () => {});
return;
}
// keep track of the stream if we don't want to ignore it
const {chunks, maxSize} = streamProps;
let size = 0;
proc[streamType].on('data', (chunk) => {
chunks.push(chunk);
size += chunk.length;
while (chunks.length > 1 && size >= maxSize) {
size -= chunks[0].length;
chunks.shift();
}
if (opts.logger && _.isFunction(opts.logger.debug)) {
opts.logger.debug(chunk.toString());
}
});
};
handleStream('stdout', {
maxSize: opts.maxStdoutBufferSize,
chunks: stdoutArr,
});
handleStream('stderr', {
maxSize: opts.maxStderrBufferSize,
chunks: stderrArr,
});
function getStdio (isBuffer) {
let stdout, stderr;
if (isBuffer) {
stdout = Buffer.concat(stdoutArr);
stderr = Buffer.concat(stderrArr);
} else {
stdout = Buffer.concat(stdoutArr).toString(opts.encoding);
stderr = Buffer.concat(stderrArr).toString(opts.encoding);
}
return {stdout, stderr};
}
// if the process ends, either resolve or reject the promise based on the
// exit code of the process. either way, attach stdout, stderr, and code.
// Also clean up the timer if it exists
proc.on('close', (code) => {
if (timer) {
clearTimeout(timer);
}
let {stdout, stderr} = getStdio(opts.isBuffer);
if (code === 0) {
resolve({stdout, stderr, code});
} else {
let err = new Error(`Command '${rep}' exited with code ${code}`);
err = Object.assign(err, {stdout, stderr, code});
reject(err);
}
});
// if we set a timeout on the child process, cut into the execution and
// reject if the timeout is reached. Attach the stdout/stderr we currently
// have in case it's helpful in debugging
if (opts.timeout) {
timer = setTimeout(() => {
let {stdout, stderr} = getStdio(opts.isBuffer);
let err = new Error(`Command '${rep}' timed out after ${opts.timeout}ms`);
err = Object.assign(err, {stdout, stderr, code: null});
reject(err);
// reject and THEN kill to avoid race conditions with the handlers
// above
proc.kill(opts.killSignal);
}, opts.timeout);
}
});
}
export { exec };
export default exec;
+32
View File
@@ -0,0 +1,32 @@
import which from 'which';
import fs from 'fs';
/**
* Decorates ENOENT error received from a spawn system call
* with a more descriptive message, so it could be properly handled by a user.
*
* @param {!Error} error Original error instance. !!! The instance is mutated after
* this helper function invocation
* @param {!string} cmd Original command to execute
* @param {?string} cwd Optional path to the current working dir
* @return {Error} Mutated error instance with an improved description or an
* unchanged error instance
*/
function formatEnoent (error, cmd, cwd = null) {
try {
which.sync(cmd);
if (cwd) {
try {
fs.accessSync(cwd, fs.R_OK);
} catch (ign) {
error.message = `The current working directory '${cwd}' for '${cmd}' command ` +
`either does not exist or is not accessible`;
}
}
} catch (ign) {
error.message = `Command '${cmd}' not found. Is it installed?`;
}
return error;
}
export { formatEnoent };
+273
View File
@@ -0,0 +1,273 @@
/* eslint-disable promise/prefer-await-to-callbacks */
import { spawn } from 'child_process';
import events from 'events';
const { EventEmitter } = events;
import B from 'bluebird';
import { quote } from 'shell-quote';
import _ from 'lodash';
import { formatEnoent } from './helpers';
// This is needed to avoid memory leaks
// when the process output is too long and contains
// no line breaks
const MAX_LINE_PORTION_LENGTH = 0xFFFF;
function cutSuffix (str, suffixLength) {
return str.length > suffixLength
// https://bugs.chromium.org/p/v8/issues/detail?id=2869
? ` ${str.substr(str.length - suffixLength)}`.substr(1)
: str;
}
class SubProcess extends EventEmitter {
constructor (cmd, args = [], opts = {}) {
super();
if (!cmd) throw new Error('Command is required'); // eslint-disable-line curly
if (!_.isString(cmd)) throw new Error('Command must be a string'); // eslint-disable-line curly
if (!_.isArray(args)) throw new Error('Args must be an array'); // eslint-disable-line curly
this.cmd = cmd;
this.args = args;
this.proc = null;
this.opts = opts;
this.expectingExit = false;
// get a quoted representation of the command for error strings
this.rep = quote([cmd, ...args]);
}
get isRunning () {
// presence of `proc` means we have connected and started
return !!this.proc;
}
emitLines (stream, lines) {
for (let line of lines) {
this.emit('stream-line', `[${stream.toUpperCase()}] ${line}`);
}
}
// spawn the subprocess and return control whenever we deem that it has fully
// "started"
async start (startDetector = null, timeoutMs = null, detach = false) {
let startDelay = 10;
const genericStartDetector = function genericStartDetector (stdout, stderr) {
return stdout || stderr;
};
// the default start detector simply returns true when we get any output
if (startDetector === null) {
startDetector = genericStartDetector;
}
// if the user passes a number, then we simply delay a certain amount of
// time before returning control, rather than waiting for a condition
if (_.isNumber(startDetector)) {
startDelay = startDetector;
startDetector = null;
}
// if the user passes in a boolean as one of the arguments, use it for `detach`
if (_.isBoolean(startDetector) && startDetector) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
startDetector = genericStartDetector;
} else if (_.isBoolean(timeoutMs) && timeoutMs) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
timeoutMs = null;
}
// return a promise so we can wrap the async behavior
return await new B((resolve, reject) => {
// actually spawn the subproc
this.proc = spawn(this.cmd, this.args, this.opts);
if (this.proc.stdout) {
this.proc.stdout.setEncoding(this.opts.encoding || 'utf8');
}
if (this.proc.stderr) {
this.proc.stderr.setEncoding(this.opts.encoding || 'utf8');
}
this.lastLinePortion = {stdout: '', stderr: ''};
// this function handles output that we collect from the subproc
const handleOutput = (streams) => {
const {stdout, stderr} = streams;
// if we have a startDetector, run it on the output so we can resolve/
// reject and move on from start
try {
if (startDetector && startDetector(stdout, stderr)) {
startDetector = null;
resolve();
}
} catch (e) {
reject(e);
}
// emit the actual output for whomever's listening
this.emit('output', stdout, stderr);
// we also want to emit lines, but it's more complex since output
// comes in chunks and a line could come in two different chunks, so
// we have logic to handle that case (using this.lastLinePortion to
// remember a line that started but did not finish in the last chunk)
for (const [streamName, streamData] of _.toPairs(streams)) {
if (!streamData) continue; // eslint-disable-line curly
const lines = streamData.split('\n')
// https://bugs.chromium.org/p/v8/issues/detail?id=2869
.map((x) => ` ${x}`.substr(1));
if (lines.length > 1) {
lines[0] = this.lastLinePortion[streamName] + lines[0];
this.lastLinePortion[streamName] = cutSuffix(_.last(lines), MAX_LINE_PORTION_LENGTH);
const resultLines = lines.slice(0, -1);
this.emit(`lines-${streamName}`, resultLines);
this.emitLines(streamName, resultLines);
} else {
const currentPortion = cutSuffix(lines[0], MAX_LINE_PORTION_LENGTH);
if (this.lastLinePortion[streamName].length + currentPortion.length > MAX_LINE_PORTION_LENGTH) {
this.lastLinePortion[streamName] = currentPortion;
} else {
this.lastLinePortion[streamName] += currentPortion;
}
}
}
};
// if we get an error spawning the proc, reject and clean up the proc
this.proc.on('error', (err) => {
this.proc.removeAllListeners('exit');
this.proc.kill('SIGINT');
if (err.errno === 'ENOENT') {
err = formatEnoent(err, this.cmd, this.opts?.cwd);
}
reject(err);
});
if (this.proc.stdout) {
this.proc.stdout.on('data', (chunk) => handleOutput({stdout: chunk.toString(), stderr: ''}));
}
if (this.proc.stderr) {
this.proc.stderr.on('data', (chunk) => handleOutput({stdout: '', stderr: chunk.toString()}));
}
// when the proc exits, we might still have a buffer of lines we were
// waiting on more chunks to complete. Go ahead and emit those, then
// re-emit the exit so a listener can handle the possibly-unexpected exit
this.proc.on('exit', (code, signal) => {
this.handleLastLines();
this.emit('exit', code, signal);
// in addition to the bare exit event, also emit one of three other
// events that contain more helpful information:
// 'stop': we stopped this
// 'die': the process ended out of our control with a non-zero exit
// 'end': the process ended out of our control with a zero exit
let event = this.expectingExit ? 'stop' : 'die';
if (!this.expectingExit && code === 0) {
event = 'end';
}
this.emit(event, code, signal);
// finally clean up the proc and make sure to reset our exit
// expectations
this.proc = null;
this.expectingExit = false;
});
// if the user hasn't given us a startDetector, instead just resolve
// when startDelay ms have passed
if (!startDetector) {
setTimeout(() => { resolve(); }, startDelay);
}
// if the user has given us a timeout, start the clock for rejecting
// the promise if we take too long to start
if (_.isNumber(timeoutMs)) {
setTimeout(() => {
reject(new Error(`The process did not start within ${timeoutMs}ms ` +
`(cmd: '${this.rep}')`));
}, timeoutMs);
}
}).finally(() => {
if (detach && this.proc) {
this.proc.unref();
}
});
}
handleLastLines () {
for (let stream of ['stdout', 'stderr']) {
if (this.lastLinePortion[stream]) {
const lastLines = [this.lastLinePortion[stream]];
this.emit(`lines-${stream}`, lastLines);
this.emitLines(stream, lastLines);
this.lastLinePortion[stream] = '';
}
}
}
async stop (signal = 'SIGTERM', timeout = 10000) {
if (!this.isRunning) {
throw new Error(`Can't stop process; it's not currently running (cmd: '${this.rep}')`);
}
// make sure to emit any data in our lines buffer whenever we're done with
// the proc
this.handleLastLines();
return await new B((resolve, reject) => {
this.proc.on('close', resolve);
this.expectingExit = true;
this.proc.kill(signal);
setTimeout(() => {
reject(new Error(`Process didn't end after ${timeout}ms (cmd: '${this.rep}')`));
}, timeout);
});
}
async join (allowedExitCodes = [0]) {
if (!this.isRunning) {
throw new Error(`Cannot join process; it is not currently running (cmd: '${this.rep}')`);
}
return await new B((resolve, reject) => {
this.proc.on('exit', (code) => {
if (allowedExitCodes.indexOf(code) === -1) {
reject(new Error(`Process ended with exitcode ${code} (cmd: '${this.rep}')`));
} else {
resolve(code);
}
});
});
}
/*
* This will only work if the process is created with the `detached` option
*/
detachProcess () {
if (!this.opts.detached) {
// this means that there is a misconfiguration in the calling code
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
if (this.proc) {
this.proc.unref();
}
}
get pid () {
return this.proc ? this.proc.pid : null;
}
}
export { SubProcess };
export default SubProcess;