run_cmd: Enhance with ability to return stdout as string

This commit is contained in:
Richard Hansen 2021-02-18 01:26:16 -05:00 committed by John McLear
parent d8bb5aa009
commit 1cfbf88f7c
2 changed files with 119 additions and 73 deletions

View File

@ -28,70 +28,130 @@ const logLines = (readable, logLineFn) => {
};
/**
* Similar to `util.promisify(child_rocess.exec)`, except:
* - `cwd` defaults to the Etherpad root directory.
* - PATH is prefixed with src/node_modules/.bin so that utilities from installed dependencies
* (e.g., npm) are preferred over system utilities.
* - Output is passed to logger callback functions by default. See below for details.
* Runs a command, logging its output to Etherpad's logs by default.
*
* Examples:
*
* Just run a command, logging stdout and stder to Etherpad's logs:
* await runCmd(['ls', '-l']);
*
* Capture just stdout as a string:
* const stdout = await runCmd(['ls', '-l'], {stdio: [null, 'string']});
*
* Capture both stdout and stderr as strings:
* const p = runCmd(['ls', '-l'], {stdio: 'string'});
* const stdout = await p; // Or: await p.stdout;
* const stderr = await p.stderr;
*
* Call a callback with each line of stdout:
* await runCmd(['ls', '-l'], {stdio: [null, (line) => console.log(line)]});
*
* @param args Array of command-line arguments, where `args[0]` is the command to run.
* @param opts Optional options that will be passed to `child_process.spawn()` with two extensions:
* - `stdoutLogger`: Callback that is called each time a line of text is written to stdout (utf8
* is assumed). The line (without trailing newline) is passed as the only argument. If null,
* stdout is not logged. If unset, defaults to logging to the Etherpad logs at log level INFO.
* Ignored if stdout is not a pipe.
* - `stderrLogger`: Like `stdoutLogger` but for stderr and log level ERROR.
* @param opts As with `child_process.spawn()`, except:
* - `cwd` defaults to the Etherpad root directory.
* - `env.PATH` is prefixed with `src/node_modules/.bin:node_modules/.bin` so that utilities from
* installed dependencies (e.g., npm) are preferred over system utilities.
* - By default stdout and stderr are logged to the Etherpad log at log levels INFO and ERROR.
* To pipe without logging you must explicitly use 'pipe' for opts.stdio.
* - opts.stdio[1] and opts.stdio[2] can be functions that will be called each time a line (utf8)
* is written to stdout or stderr. The line (without its trailing newline, if present) will be
* passed as the only argument, and the return value is ignored. opts.stdio = fn is equivalent
* to opts.stdio = [null, fn, fn].
* - opts.stdio[1] and opts.stdio[2] can be 'string', which will cause output to be collected,
* decoded as utf8, and returned (see below). opts.stdio = 'string' is equivalent to
* opts.stdio = [null, 'string', 'string'].
*
* @returns A Promise with `stdout`, `stderr`, and `child` properties containing the stdout stream,
* stderr stream, and ChildProcess objects, respectively.
* @returns A Promise that resolves when the command exits. The Promise resolves to the complete
* stdout if opts.stdio[1] is 'string', otherwise it resolves to undefined. The returned Promise is
* augmented with these additional properties:
* - `stdout`: If opts.stdio[1] is 'pipe', the stdout stream object. If opts.stdio[1] is 'string',
* a Promise that will resolve to the complete stdout (utf8 decoded) when the command exits.
* - `stderr`: Similar to `stdout` but for stderr.
* - `child`: The ChildProcess object.
*/
module.exports = exports = (args, opts = {}) => {
logger.debug(`Executing command: ${args.join(' ')}`);
const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`);
const {
stdoutLogger = (line) => cmdLogger.info(line),
stderrLogger = (line) => cmdLogger.error(line),
} = opts;
// Avoid confusing child_process.spawn() with our extensions.
opts = {...opts}; // Make a copy to avoid mutating the caller's copy.
delete opts.stdoutLogger;
delete opts.stderrLogger;
opts = {cwd: settings.root, ...opts};
logger.debug(`cwd: ${opts.cwd}`);
// Log stdout and stderr by default.
const stdio =
Array.isArray(opts.stdio) ? opts.stdio.slice() // Copy to avoid mutating the caller's array.
: typeof opts.stdio === 'function' ? [null, opts.stdio, opts.stdio]
: opts.stdio === 'string' ? [null, 'string', 'string']
: Array(3).fill(opts.stdio);
const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`);
if (stdio[1] == null) stdio[1] = (line) => cmdLogger.info(line);
if (stdio[2] == null) stdio[2] = (line) => cmdLogger.error(line);
const stdioLoggers = [];
const stdioSaveString = [];
for (const fd of [1, 2]) {
if (typeof stdio[fd] === 'function') {
stdioLoggers[fd] = stdio[fd];
stdio[fd] = 'pipe';
} else if (stdio[fd] === 'string') {
stdioSaveString[fd] = true;
stdio[fd] = 'pipe';
}
}
opts.stdio = stdio;
// On Windows the PATH environment var might be spelled "Path".
const pathVarName =
Object.keys(process.env).filter((k) => k.toUpperCase() === 'PATH')[0] || 'PATH';
// Set PATH so that utilities from installed dependencies (e.g., npm) are preferred over system
// (global) utilities.
let {env = process.env} = opts;
env = {...env}; // Copy to avoid modifying process.env.
// On Windows the PATH environment var might be spelled "Path".
const pathVarName = Object.keys(env).filter((k) => k.toUpperCase() === 'PATH')[0] || 'PATH';
env[pathVarName] = [
path.join(settings.root, 'src', 'node_modules', '.bin'),
path.join(settings.root, 'node_modules', '.bin'),
...(env[pathVarName] ? env[pathVarName].split(path.delimiter) : []),
].join(path.delimiter);
const {env = process.env} = opts;
const {[pathVarName]: PATH} = env;
opts.env = {
...env, // Copy env to avoid modifying process.env or the caller's supplied env.
[pathVarName]: [
path.join(settings.root, 'src', 'node_modules', '.bin'),
path.join(settings.root, 'node_modules', '.bin'),
...(PATH ? PATH.split(path.delimiter) : []),
].join(path.delimiter),
};
logger.debug(`${pathVarName}=${env[pathVarName]}`);
// Create an error object to use in case the process fails. This is done here rather than in the
// process's `exit` handler so that we get a useful stack trace.
const procFailedErr = new Error(`Command exited non-zero: ${args.join(' ')}`);
const procFailedErr = new Error();
const proc = spawn(args[0], args.slice(1), {cwd: settings.root, ...opts, env});
if (proc.stdout != null && stdoutLogger != null) logLines(proc.stdout, stdoutLogger);
if (proc.stderr != null && stderrLogger != null) logLines(proc.stderr, stderrLogger);
const p = new Promise((resolve, reject) => {
proc.on('exit', (code, signal) => {
if (code !== 0) {
logger.debug(procFailedErr.stack);
procFailedErr.code = code;
procFailedErr.signal = signal;
return reject(procFailedErr);
}
logger.debug(`Command returned successfully: ${args.join(' ')}`);
resolve();
});
});
p.stdout = proc.stdout;
p.stderr = proc.stderr;
const proc = spawn(args[0], args.slice(1), opts);
const streams = [undefined, proc.stdout, proc.stderr];
let px;
const p = new Promise((resolve, reject) => { px = {resolve, reject}; });
[, p.stdout, p.stderr] = streams;
p.child = proc;
const stdioStringPromises = [undefined, Promise.resolve(), Promise.resolve()];
for (const fd of [1, 2]) {
if (streams[fd] == null) continue;
if (stdioLoggers[fd] != null) {
logLines(streams[fd], stdioLoggers[fd]);
} else if (stdioSaveString[fd]) {
p[[null, 'stdout', 'stderr'][fd]] = stdioStringPromises[fd] = (async () => {
const chunks = [];
for await (const chunk of streams[fd]) chunks.push(chunk);
return Buffer.concat(chunks).toString().replace(/\n+$/g, '');
})();
}
}
proc.on('exit', async (code, signal) => {
const [, stdout] = await Promise.all(stdioStringPromises);
if (code !== 0) {
procFailedErr.message =
`Command exited ${code ? `with code ${code}` : `on signal ${signal}`}: ${args.join(' ')}`;
procFailedErr.code = code;
procFailedErr.signal = signal;
logger.debug(procFailedErr.stack);
return px.reject(procFailedErr);
}
logger.debug(`Command returned successfully: ${args.join(' ')}`);
px.resolve(stdout);
});
return p;
};

View File

@ -12,22 +12,15 @@ const defs = require('./plugin_defs');
const logger = log4js.getLogger('plugins');
// Log the version of npm at startup.
let loggedVersion = false;
(async () => {
if (loggedVersion) return;
loggedVersion = true;
const p = runCmd(['npm', '--version'], {stdoutLogger: null});
const chunks = [];
await Promise.all([
(async () => { for await (const chunk of p.stdout) chunks.push(chunk); })(),
p, // Await in parallel to avoid unhandled rejection if p rejects during chunk read.
]);
const version = Buffer.concat(chunks).toString().replace(/\n+$/g, '');
logger.info(`npm --version: ${version}`);
})().catch((err) => {
logger.error(`Failed to get npm version: ${err.stack || err}`);
// This isn't a fatal error so don't re-throw.
});
try {
const version = await runCmd(['npm', '--version'], {stdio: [null, 'string']});
logger.info(`npm --version: ${version}`);
} catch (err) {
logger.error(`Failed to get npm version: ${err.stack || err}`);
// This isn't a fatal error so don't re-throw.
}
})();
exports.prefix = 'ep_';
@ -96,15 +89,8 @@ exports.getPackages = async () => {
// * The `--no-production` flag is required (or the `NODE_ENV` environment variable must be
// unset or set to `development`) because otherwise `npm ls` will not mention any packages
// that are not included in `package.json` (which is expected to not exist).
const p = runCmd(['npm', 'ls', '--long', '--json', '--depth=0', '--no-production'], {
stdoutLogger: null, // We want to capture stdout, so don't attempt to log it.
});
const chunks = [];
await Promise.all([
(async () => { for await (const chunk of p.stdout) chunks.push(chunk); })(),
p, // Await in parallel to avoid unhandled rejection if p rejects during chunk read.
]);
const {dependencies = {}} = JSON.parse(Buffer.concat(chunks).toString());
const cmd = ['npm', 'ls', '--long', '--json', '--depth=0', '--no-production'];
const {dependencies = {}} = JSON.parse(await runCmd(cmd, {stdio: [null, 'string']}));
await Promise.all(Object.entries(dependencies).map(async ([pkg, info]) => {
if (!pkg.startsWith(exports.prefix)) {
delete dependencies[pkg];