execa
Version:
Process execution for humans
416 lines (342 loc) • 9.63 kB
JavaScript
;
const path = require('path');
const childProcess = require('child_process');
const crossSpawn = require('cross-spawn');
const stripFinalNewline = require('strip-final-newline');
const npmRunPath = require('npm-run-path');
const isStream = require('is-stream');
const getStream = require('get-stream');
const mergeStream = require('merge-stream');
const pFinally = require('p-finally');
const makeError = require('./lib/error');
const normalizeStdio = require('./lib/stdio');
const {spawnedKill, spawnedCancel, setupTimeout, setExitHandler, cleanup} = require('./lib/kill');
const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100;
const SPACES_REGEXP = / +/g;
const handleArgs = (file, args, options = {}) => {
const parsed = crossSpawn._parse(file, args, options);
file = parsed.command;
args = parsed.args;
options = parsed.options;
options = {
maxBuffer: DEFAULT_MAX_BUFFER,
buffer: true,
stripFinalNewline: true,
preferLocal: false,
localDir: options.cwd || process.cwd(),
encoding: 'utf8',
reject: true,
cleanup: true,
...options,
windowsHide: true
};
if (options.extendEnv !== false) {
options.env = {
...process.env,
...options.env
};
}
if (options.preferLocal) {
options.env = npmRunPath.env({
...options,
cwd: options.localDir
});
}
options.stdio = normalizeStdio(options);
if (process.platform === 'win32' && path.basename(file, '.exe') === 'cmd') {
// #116
args.unshift('/q');
}
return {file, args, options, parsed};
};
const handleInput = (spawned, input) => {
// Checking for stdin is workaround for https://github.com/nodejs/node/issues/26852
// TODO: Remove `|| spawned.stdin === undefined` once we drop support for Node.js <=12.2.0
if (input === undefined || spawned.stdin === undefined) {
return;
}
if (isStream(input)) {
input.pipe(spawned.stdin);
} else {
spawned.stdin.end(input);
}
};
const handleOutput = (options, value, error) => {
if (typeof value !== 'string' && !Buffer.isBuffer(value)) {
// When `execa.sync()` errors, we normalize it to '' to mimic `execa()`
return error === undefined ? undefined : '';
}
if (options.stripFinalNewline) {
return stripFinalNewline(value);
}
return value;
};
const makeAllStream = spawned => {
if (!spawned.stdout && !spawned.stderr) {
return;
}
const mixed = mergeStream();
if (spawned.stdout) {
mixed.add(spawned.stdout);
}
if (spawned.stderr) {
mixed.add(spawned.stderr);
}
return mixed;
};
const getBufferedData = async (stream, streamPromise) => {
if (!stream) {
return;
}
stream.destroy();
try {
return await streamPromise;
} catch (error) {
return error.bufferedData;
}
};
const getStreamPromise = (stream, {encoding, buffer, maxBuffer}) => {
if (!stream) {
return;
}
if (!buffer) {
// TODO: Use `ret = util.promisify(stream.finished)(stream);` when targeting Node.js 10
return new Promise((resolve, reject) => {
stream
.once('end', resolve)
.once('error', reject);
});
}
if (encoding) {
return getStream(stream, {encoding, maxBuffer});
}
return getStream.buffer(stream, {maxBuffer});
};
const getPromiseResult = async ({stdout, stderr, all}, {encoding, buffer, maxBuffer}, processDone) => {
const stdoutPromise = getStreamPromise(stdout, {encoding, buffer, maxBuffer});
const stderrPromise = getStreamPromise(stderr, {encoding, buffer, maxBuffer});
const allPromise = getStreamPromise(all, {encoding, buffer, maxBuffer: maxBuffer * 2});
try {
return await Promise.all([processDone, stdoutPromise, stderrPromise, allPromise]);
} catch (error) {
return Promise.all([
{error, code: error.code, signal: error.signal},
getBufferedData(stdout, stdoutPromise),
getBufferedData(stderr, stderrPromise),
getBufferedData(all, allPromise)
]);
}
};
const joinCommand = (file, args = []) => {
if (!Array.isArray(args)) {
return file;
}
return [file, ...args].join(' ');
};
const mergePromiseProperty = (spawned, getPromise, property) => {
Object.defineProperty(spawned, property, {
value(...args) {
return getPromise()[property](...args);
},
writable: true,
enumerable: false,
configurable: true
});
};
// The return value is a mixin of `childProcess` and `Promise`
const mergePromise = (spawned, getPromise) => {
mergePromiseProperty(spawned, getPromise, 'then');
mergePromiseProperty(spawned, getPromise, 'catch');
// TODO: Remove the `if`-guard when targeting Node.js 10
if (Promise.prototype.finally) {
mergePromiseProperty(spawned, getPromise, 'finally');
}
return spawned;
};
const handleSpawned = (spawned, context) => {
return new Promise((resolve, reject) => {
spawned.on('exit', (code, signal) => {
if (context.timedOut) {
reject(Object.assign(new Error('Timed out'), {code, signal}));
return;
}
resolve({code, signal});
});
spawned.on('error', error => {
reject(error);
});
if (spawned.stdin) {
spawned.stdin.on('error', error => {
reject(error);
});
}
});
};
const execa = (file, args, options) => {
const parsed = handleArgs(file, args, options);
const command = joinCommand(file, args);
let spawned;
try {
spawned = childProcess.spawn(parsed.file, parsed.args, parsed.options);
} catch (error) {
return mergePromise(new childProcess.ChildProcess(), () =>
Promise.reject(makeError({
error,
stdout: '',
stderr: '',
all: '',
command,
parsed,
timedOut: false,
isCanceled: false,
killed: false
}))
);
}
const context = {timedOut: false, isCanceled: false};
spawned.kill = spawnedKill.bind(null, spawned.kill.bind(spawned));
spawned.cancel = spawnedCancel.bind(null, spawned, context);
const timeoutId = setupTimeout(spawned, parsed.options, context);
const removeExitHandler = setExitHandler(spawned, parsed.options);
// TODO: Use native "finally" syntax when targeting Node.js 10
const processDone = pFinally(handleSpawned(spawned, context), () => {
cleanup(timeoutId, removeExitHandler);
});
const handlePromise = async () => {
const [result, stdout, stderr, all] = await getPromiseResult(spawned, parsed.options, processDone);
result.stdout = handleOutput(parsed.options, stdout);
result.stderr = handleOutput(parsed.options, stderr);
result.all = handleOutput(parsed.options, all);
if (result.error || result.code !== 0 || result.signal !== null) {
const error = makeError({
...result,
command,
parsed,
timedOut: context.timedOut,
isCanceled: context.isCanceled,
killed: spawned.killed
});
if (!parsed.options.reject) {
return error;
}
throw error;
}
return {
command,
exitCode: 0,
exitCodeName: 'SUCCESS',
stdout: result.stdout,
stderr: result.stderr,
all: result.all,
failed: false,
timedOut: false,
isCanceled: false,
killed: false
};
};
crossSpawn._enoent.hookChildProcess(spawned, parsed.parsed);
handleInput(spawned, parsed.options.input);
spawned.all = makeAllStream(spawned);
return mergePromise(spawned, handlePromise);
};
module.exports = execa;
module.exports.sync = (file, args, options) => {
const parsed = handleArgs(file, args, options);
const command = joinCommand(file, args);
if (isStream(parsed.options.input)) {
throw new TypeError('The `input` option cannot be a stream in sync mode');
}
let result;
try {
result = childProcess.spawnSync(parsed.file, parsed.args, parsed.options);
} catch (error) {
throw makeError({
error,
stdout: '',
stderr: '',
all: '',
command,
parsed,
timedOut: false,
isCanceled: false,
killed: false
});
}
result.stdout = handleOutput(parsed.options, result.stdout, result.error);
result.stderr = handleOutput(parsed.options, result.stderr, result.error);
if (result.error || result.status !== 0 || result.signal !== null) {
const error = makeError({
...result,
code: result.status,
command,
parsed,
timedOut: result.error && result.error.errno === 'ETIMEDOUT',
isCanceled: false,
killed: result.signal !== null
});
if (!parsed.options.reject) {
return error;
}
throw error;
}
return {
command,
exitCode: 0,
exitCodeName: 'SUCCESS',
stdout: result.stdout,
stderr: result.stderr,
failed: false,
timedOut: false,
isCanceled: false,
killed: false
};
};
// Allow spaces to be escaped by a backslash if not meant as a delimiter
const handleEscaping = (tokens, token, index) => {
if (index === 0) {
return [token];
}
const previousToken = tokens[tokens.length - 1];
if (previousToken.endsWith('\\')) {
return [...tokens.slice(0, -1), `${previousToken.slice(0, -1)} ${token}`];
}
return [...tokens, token];
};
const parseCommand = command => {
return command
.trim()
.split(SPACES_REGEXP)
.reduce(handleEscaping, []);
};
module.exports.command = (command, options) => {
const [file, ...args] = parseCommand(command);
return execa(file, args, options);
};
module.exports.commandSync = (command, options) => {
const [file, ...args] = parseCommand(command);
return execa.sync(file, args, options);
};
module.exports.node = (scriptPath, args, options = {}) => {
if (args && !Array.isArray(args) && typeof args === 'object') {
options = args;
args = [];
}
const stdioOption = normalizeStdio.node(options);
const {nodePath = process.execPath, nodeOptions = process.execArgv} = options;
return execa(
nodePath,
[
...nodeOptions,
scriptPath,
...(Array.isArray(args) ? args : [])
],
{
...options,
stdin: undefined,
stdout: undefined,
stderr: undefined,
stdio: stdioOption,
shell: false
}
);
};