@xarc/run
Version:
concurrent or serial run npm scripts, javascript tasks, and more
803 lines (690 loc) • 21.6 kB
JavaScript
"use strict";
const assert = require("assert");
const defaults = require("./defaults");
const Insync = require("insync");
const XQItem = require("./xqitem");
const exec = require("xsh").exec;
const parseArray = require("./util/parse-array");
const childProc = require("child_process");
const unwrapNpmCmd = require("unwrap-npm-cmd");
const updateEnv = require("./util/update-env");
const { NixClap } = require("nix-clap");
const STAGE_FINALLY = "finally";
const isSerial = x => defaults.SERIAL_SIG.includes(x[0]);
const isConcurrent = x => defaults.CONCURRENT_SIG.includes(x[0]);
const isReadableStream = x => Boolean(x && x.pipe && x.on && x._readableState);
/*
* Task executor (XQtor) - After all the fuss, it comes down to this -- the core of xrun
* that handles the execution of tasks.
*
* Tasks are added to a stack using an array.
*
* - Each concurrent tasks array is executed with a new XQtor and Insync.parallel.
* - Serial tasks are all added to the top of the "stack" in reverse order
* - Some items are actions that cause more items to be created and pushed
* into the stack. Like looking up the task of a task name.
* - As each task is completed, the XQtor's next is invoked to pop items
* from the stack for processing.
* - A mark item is added to the stack before each task so when a mark
* is seen, error is checked and done event is emitted.
*
*/
class XQtor {
constructor(options) {
this._tasks = options.tasks;
this._done = options.done;
this._xrun = options.xrun;
this.xqItems = [];
}
next(err, xqId) {
process.nextTick(() => this._next(err, xqId), 0);
}
_next(err, xqId) {
if (err) {
const qItem = this._xrun.xqTree.item(xqId);
qItem.err = err;
this._xrun.fail(err);
}
if (this.xqItems.length > 0) {
this.execute();
} else {
this._done(this._xrun.failed);
}
}
execute() {
assert(this.xqItems.length > 0, "no next task");
const qItem = this.popItem();
if (qItem.mark) {
return this._processMark(qItem);
}
if (this._xrun._isStop || (this._xrun.failed && !qItem.isFinally && this._xrun.stopOnError)) {
return this.next(null, qItem.id);
}
const value = qItem.value();
if (value === defaults.STOP_SYM) {
this._xrun.actStop();
return this.next(null, qItem.id);
}
const vtype = value.constructor.name;
if (vtype === "XTaskSpec") {
if (value.type === "exec") {
return this._shellXer(qItem, value, true);
} else if (value.type === "env") {
return this._envXer(qItem, value);
}
this._xrun.fail(
new Error(`Unable to process XTaskSpec type ${value.type} for task ${qItem.name}`)
);
} else if (vtype === "String") {
if (this._isAnonShell(value)) {
return this._shellXer(qItem, this._parseAnonShell(value), true);
}
return this._processLookup(qItem);
} else if (vtype === "Function" || vtype === "AsyncFunction") {
return this._functionXer(qItem, value);
} else if (vtype === "Array") {
return this._processArray(qItem, value);
} else if (value.item) {
const dep = value.item.dep;
if (dep && !qItem.xqDep) {
return this._processDep(qItem, dep);
} else {
return this._processTaskObject(qItem);
}
} else {
this._xrun.fail(
new Error(`Unable to process task ${qItem.name} \
because value type ${vtype} is unknown and no value.item`)
);
}
return this.next(null, qItem.id);
}
pushMarkItem(qItem, callback) {
const x = this._makeXqMarkItem(qItem, callback);
this.pushItem(x);
return x;
}
pushItem(qItem) {
this.xqItems.push(qItem);
}
popItem() {
return this.xqItems.pop();
}
_isAnonShell(value) {
let sig;
if ((sig = defaults.ANON_SHELL_OPT_SIG.find(x => value.startsWith(x)))) {
return { sig, opt: true };
}
if ((sig = defaults.ANON_SHELL_SIG.find(x => value.startsWith(x)))) {
return { sig, opt: false };
}
return false;
}
_parseShellFlags(flags, value) {
const unknowns = [];
flags = flags.reduce((a, o) => {
const f = o.trim().toLowerCase();
if (defaults.SHELL_FLAGS.indexOf(f) < 0) {
unknowns.push(o);
} else {
a[f] = true;
}
return a;
}, {});
if (unknowns.length) {
throw new Error(`Unknown flag ${unknowns.join(",")} in shell task: ${value}`);
}
return flags;
}
_parseAnonShell(value) {
if (typeof value !== "string") return value;
let cmd = value;
let flags = {};
let error;
const anon = this._isAnonShell(value);
if (anon) {
const { sig, opt } = anon;
// support options like ~(tty,spawn,sync)$
if (opt) {
let ix;
const closeSig = defaults.ANON_SHELL_OPT_CLOSE_SIG.find(x => {
ix = value.indexOf(x, sig.length);
return ix > sig.length;
});
if (closeSig) {
const so = value.substring(sig.length, ix);
try {
flags = this._parseShellFlags(so.split(","), value);
} catch (err) {
error = err;
}
cmd = value.substr(ix + closeSig.length);
} else {
error = new Error(
`Missing ${defaults.ANON_SHELL_OPT_CLOSE_SIG[0]} in shell task: ${value}`
);
}
} else {
cmd = value.substr(sig.length);
}
}
return { flags, cmd, error };
}
_isStrArray(value) {
return value.startsWith(defaults.STR_ARRAY_SIG);
}
_parseStrArray(value) {
if (this._isStrArray(value)) {
return parseArray(value.substr(defaults.STR_ARRAY_SIG.length - 1));
}
return undefined;
}
_processLookup(qItem) {
this._emit("execute", {
type: "lookup",
qItem
});
// lookup is sync; should not insert mark for it
try {
const found = qItem.lookup(this._tasks);
this.pushItem(qItem);
this.next(null, qItem.id);
if (found.search) {
this._xrun.emit("search", { qItem, found });
qItem.setNamespace(found.ns);
}
} catch (error) {
if (error.optional) {
this._xrun.emit("not-found", error);
this.next();
} else {
this.next(error, qItem.id);
}
}
}
_processDep(qItem, dep) {
this._emit("dep", {
qItem
});
qItem.xqDep = true; // item's dep has been processed
this.pushItem(qItem);
const di = this._createQItem(
{
name: qItem.name,
value: { top: true, depDee: qItem, item: { task: dep } }
},
this._xrun.xqTree.parent(qItem)
);
this.pushItem(di);
this.next(null, qItem.id);
}
_processTaskObject(qItem) {
const value = qItem.value();
const stage = qItem.stage || "task";
const itemTask = value.item[stage] || value.item;
const type = itemTask.constructor.name;
if (type === "Array") {
return this._processArray(qItem, itemTask, value.top);
} else if (type === "Function" || type === "AsyncFunction") {
return this._functionXer(qItem, itemTask);
} else if (type === "String") {
const parsedArray = this._parseStrArray(itemTask);
if (parsedArray) return this._processArray(qItem, parsedArray, value.top);
return this._shellXer(qItem, itemTask);
} else if (type === "XTaskSpec") {
if (itemTask.type === "exec") {
return this._shellXer(qItem, itemTask);
} else if (itemTask.type === "env") {
return this._envXer(qItem, itemTask);
}
this._xrun.fail(
new Error(`Unable to process XTaskSpec type ${itemTask.type} for task ${qItem.name}`)
);
} else {
this._xrun.fail(new Error(`Task ${qItem.name} has unrecognize task value type ${type}`));
}
return this.next(null, qItem.id);
}
_processArray(qItem, tasks, top) {
// check first element for concurrent signature
// top level task arrays are automatically serial
// or check first element for serial signature
if (isConcurrent(tasks) || (!top && !isSerial(tasks))) {
this._concurrentArrayXer(qItem, tasks);
} else {
this._serialArrayXer(qItem, tasks);
}
}
_serialArrayXer(qItem, tasks) {
if (isSerial(tasks)) {
tasks = tasks.slice(1);
}
this._emit("execute", {
type: "serial-arr",
qItem,
array: tasks
});
this.pushMarkItem(qItem);
this.xqItems = this.xqItems.concat(
tasks
.map(value => {
return this._createQItem(
this._resolveValueToQItemOptions(qItem.ns, `${qItem.name}.S`, value, "serial_child"),
qItem
);
})
.reverse()
);
this.next(null, qItem.id);
}
_resolveValueToQItemOptions(ns, name, value, type) {
if (typeof value === "string" && !this._isAnonShell(value)) {
name = value;
value = undefined;
}
const anon = typeof value === "function";
return {
ns,
name,
value,
anon,
type
};
}
_concurrentArrayXer(qItem, tasks) {
if (isConcurrent(tasks)) {
tasks = tasks.slice(1);
}
this._emit("execute", {
type: "concurrent-arr",
qItem,
array: tasks
});
this.pushMarkItem(qItem);
Insync.parallel(
tasks.map(value => cb => {
this._emit("spawn-async", { name: qItem.name });
const xqtor = new XQtor({
tasks: this._tasks,
xrun: this._xrun,
done: () => {
this._emit("done-async");
process.nextTick(() => cb());
}
});
const options = this._resolveValueToQItemOptions(
qItem.ns,
`${qItem.name}.C`,
value,
"concurrent_child"
);
const item = this._createQItem(options, qItem);
if (options.value === undefined) {
xqtor._processLookup(item);
} else {
xqtor.pushItem(item);
xqtor.next();
}
}),
() => this.next()
);
}
_envXer(qItem, cmdVal) {
this._emit("execute", {
type: "env",
qItem,
cmdVal
});
updateEnv(cmdVal.options.env, process.env, cmdVal.options.override);
return this.next();
}
_shellXer(qItem, cmdVal, anon) {
cmdVal = this._parseAnonShell(cmdVal);
if (cmdVal.error) {
setTimeout(() => this.next(cmdVal.error, qItem.id), 0);
return;
}
const { cmd } = cmdVal;
const xrunOptions = Object.assign({ delayRunMs: 0 }, cmdVal.xrun || cmdVal.xclap);
const options = Object.assign({}, cmdVal.options);
let { flags } = cmdVal;
if (typeof flags === "string") {
flags = this._parseShellFlags(flags.split(","), `XTaskSpec "${flags}"`);
} else if (Array.isArray(flags)) {
flags = this._parseShellFlags(flags);
}
const item = qItem.value().item || {};
Object.assign(flags, item.flags);
// Access CLI context through runner
const cliContext = this._xrun.getCliContext();
const itemArgv = qItem.argv;
const cliArgv = cliContext.getTaskArgv(qItem.name);
const remainingArgv =
(cliContext.isLastTask(qItem.name) && cliContext.getRemainingArgs()) || [];
// Build command with task-specific argv
const cmd2 = []
.concat(itemArgv.slice(1), cliArgv.slice(1), remainingArgv)
.map(x => (x && x.includes(" ") ? `"${x}"` : x))
.join(" ");
setTimeout(() => {
const execData = {
type: "shell",
anon,
qItem,
cmd: cmd + ((cmd2 && " " + cmd2) || ""),
cmd2,
flags,
cmdVal,
options,
item
};
this._emit("execute", execData);
this._doShellXer(execData);
}, xrunOptions.delayRunMs);
}
_doShellXer({ /*anon,*/ qItem, cmd, flags, /*cmdVal,*/ options, item }) {
const itemOptions = Object.assign({}, item.options);
this.pushMarkItem(qItem);
const env = Object.assign(
flags.noenv ? {} : Object.assign({}, process.env),
itemOptions.env,
options.env
);
if (qItem.err) {
env.XCLAP_ERR = env.XRUN_ERR = qItem.err.message || "true";
}
if (this._xrun.failed || qItem.err) {
env.XCLAP_FAILED = env.XRUN_FAILED = "true";
}
let child;
const watch = { finish: false };
const done = err => {
// even if there's error, but if it's due to child being terminated with
// SIGTERM, then treat that as a normal exit.
if (this._isChildSigTerm(err, child) || this._isChildTerminated(child)) {
err = null;
}
if (watch.finish) return 0;
watch.finish = true;
return this.next(err, qItem.id);
};
let { tty, spawn, sync, npm } = flags;
if (npm) {
tty = spawn = true;
}
const cmd2 = unwrapNpmCmd(cmd, { path: env.PATH });
if (tty || spawn) {
const spawnOpts = { shell: true };
Object.assign(spawnOpts, itemOptions, options, { env });
if (tty) spawnOpts.stdio = "inherit";
if (sync) {
child = childProc.spawnSync(cmd2, spawnOpts);
if (child.error) {
done(child.error);
} else if (child.status === 0) {
done();
} else {
done(new Error(`cmd "${cmd}" exit code ${child.status}`));
}
} else {
child = childProc.spawn(cmd2, spawnOpts);
child.on("close", (code, signal) => {
if (code === 0 || signal === "SIGTERM") {
return done();
}
return done(new Error(`cmd "${cmd}" exit code ${code}`));
});
}
} else {
child = exec(Object.assign({ silent: false }, itemOptions, options, { env }), cmd2, done);
}
if (!watch.finish) {
this._handleChildTask(child, cmd);
watch.cancel = () => {
child.kill();
done();
};
this._watchFailure(watch);
}
}
_handleChildTask(child, cmd) {
if (child && child instanceof childProc.ChildProcess) {
const sym = Symbol(cmd);
this._xrun.addTaskChild(child, sym);
child.on("exit", () => {
this._xrun.removeTaskChild(child, sym);
});
return child;
}
}
_processMoreFromFn(qItem, value, callback) {
let tof = typeof value;
if (tof === "string") {
const parsedArray = this._parseStrArray(value);
if (parsedArray) value = parsedArray;
tof = typeof value;
}
if (
!value ||
(tof !== "string" &&
tof !== "function" &&
!Array.isArray(value) &&
value.constructor.name !== "XTaskSpec" &&
value !== defaults.STOP_SYM)
) {
return;
}
const qi = this._createQItem(
this._resolveValueToQItemOptions(qItem.ns, `${qItem.name}.fR`, value, "func_returned_child"),
qItem
);
if (callback) {
assert(
typeof callback === "function",
`${qItem.name} callback from function calling run is not a function`
);
this.pushMarkItem(qItem, callback);
}
this.pushItem(qi);
}
_isChildSigTerm(err, child) {
return err && err.code === null && child && child.signalCode === "SIGTERM";
}
_isChildTerminated(child) {
return child && child[defaults.STOP_SYM];
}
_handleStream(val) {
if (!isReadableStream(val)) {
return val;
}
return new Promise((resolve, reject) => {
const handle = err => {
val.removeListener("end", handle);
val.removeListener("error", handle);
return err ? reject(err) : resolve();
};
val.once("close", handle);
val.once("error", handle);
});
}
_functionXer(qItem, fn) {
this._emit("execute", {
type: "function",
anon: qItem.anon,
qItem
});
const watch = { finish: false, qItem };
const done = (err, value) => {
if (watch.finish) return;
watch.finish = true;
if (value) {
this._processMoreFromFn(qItem, value);
}
this.next(err, qItem.id);
};
watch.cancel = done;
try {
this.pushMarkItem(qItem);
const run = (value, cb) => this._processMoreFromFn(qItem, value, cb);
const task = qItem.value();
// Access CLI context through runner
const cliContext = this._xrun.getCliContext();
let cliCmd = cliContext.getTaskCommand(qItem.name);
const cliParser = task.item?.cliParser || task.cliParser || {};
const itemArgv = qItem.argv;
const cliArgv = cliCmd.argv || [];
if (itemArgv.length > 1 || (cliParser && cliArgv.length > 1)) {
const argv = [qItem.name].concat(itemArgv.slice(1), cliArgv.slice(1));
const config = {
name: qItem.name,
allowUnknownOption:
cliParser.allowUnknownOption !== undefined ? cliParser.allowUnknownOption : true,
allowUnknownCommand: false,
noDefaultHandlers: true
};
const argp = new NixClap(config)
.init(cliParser.options || {}, cliParser.commands || {})
.parse(argv, 1);
if (argp.errorNodes.length > 0) {
const unknownOptions = argp.errorNodes.reduce((acc, node) => {
acc.push(
...node.errors
.filter(e => e.message.includes("unknown CLI option"))
.map(e => e.data.name)
);
return acc;
}, []);
/* istanbul ignore else */
if (unknownOptions.length > 0) {
return done(
new Error(`Unknown options for task ${qItem.name}: ${unknownOptions.join(", ")}`)
);
}
}
cliCmd = argp.command;
}
const argv = cliCmd.argv || [];
const context = {
run,
argv,
cliCmd,
argp: cliCmd.jsonMeta,
args: cliCmd.argsList,
argOpts: cliCmd.opts,
err: qItem.err,
failed: this._xrun.failed
};
if (fn.length > 1) {
// takes two params, pass in context and done callback
this._watchFailure(watch);
return fn.call(context, context, done);
}
if (fn.constructor.name !== "AsyncFunction" && fn.length > 0) {
// non async function that takes 1 param, check if it wants the context
// or a callback.
const fnStr = fn.toString().replace(/\s/g, "");
const matchParam = x => {
return (
fnStr.startsWith(`${x}=>`) ||
fnStr.match(new RegExp(`\\(${x}[^\\)]*\\)=>`)) ||
fnStr.match(new RegExp(`function[^\\(]*\\(${x}[^\\)]*\\){`)) ||
fnStr.match(new RegExp(`[^(]*\\(${x}[^\\)]*\\){`))
);
};
const takeContext = ["ctx", "context"].find(matchParam);
if (!takeContext) {
// non async task function takes callback, expect async behavior
this._watchFailure(watch);
return fn.call(context, done);
}
// function takes context as first param
return done(null, fn.call(context, context));
}
const x = this._handleStream(fn.call(context, context));
// handling a exec/spawn child from a function
const cmd = `ChildProcess of function task ${qItem.name}`;
const child = this._handleChildTask(x && x.child, cmd) || this._handleChildTask(x, cmd);
if (!x || typeof x.then !== "function") {
// assume no async behavior in task function
return done(null, x);
}
// x.then is a function, assume task function returned a promise
this._watchFailure(watch);
return x.then(
v => done(null, v),
err => {
// treat child process that got SIGTERM as exited normally
if (this._isChildSigTerm(err, child)) {
done();
} else {
done(err);
}
}
);
} catch (err) {
return done(err);
}
}
_watchFailure(watch) {
if (this._xrun.stopOnError !== "full") return undefined;
return setTimeout(() => {
if (watch.finish) return 0;
if (this._xrun.failed) {
this._xrun.emit("fail-cancel", watch);
return watch.cancel();
}
return this._watchFailure(watch);
}, 50).unref();
}
_processMark(qItem) {
const value = qItem.value();
const xqItem = this._xrun.xqTree.item(value.xqId);
const endTime = Date.now();
const data = {
qItem,
xqItem,
elapse: endTime - value.startTime,
startTime: value.startTime,
endTime,
hrStartTime: value.hrStartTime,
hrElapse: process.hrtime(value.hrStartTime)
};
if (value.callback) {
value.callback(this._xrun.failed, data);
} else {
this._emit("done-item", data);
}
const xqValue = xqItem.value();
if (xqValue.item && xqValue.item[STAGE_FINALLY] && !xqItem.isFinally) {
xqItem.stage = STAGE_FINALLY;
xqItem.isFinally = true;
this.pushItem(xqItem);
}
this.next();
}
_makeXqMarkItem(qItem, callback) {
// do not add marking item to tree
const mark = new XQItem({
name: `mark_${qItem.name}`,
value: {
startTime: Date.now(),
hrStartTime: process.hrtime(),
callback,
xqId: qItem.id
}
});
mark.mark = true;
return mark;
}
_emit(event, data) {
try {
this._xrun.emit(event, data);
} catch (err) {
this._xrun.fail(err);
}
}
_createQItem(options, parent) {
return this._xrun.xqTree.create(options, parent);
}
}
module.exports = XQtor;