just-build
Version:
A simple task runner that doesn't bloat your package
409 lines (383 loc) • 17.4 kB
JavaScript
const { Observable } = require ('../bundledExternals/bundle');
const path = require ('path');
const { tokenize, surroundWithQuotes } = require ('./tokenize');
const { clone } = require ('./extend');
const { extractConfig } = require ('./extract-config');
const { extend } = require('./extend');
const { ColorTransform } = require ('./color-transform');
const clr = require ('./console-colors');
const debug = require('./debug');
const COMMENT_COLOR = clr.GREEN;
const EMIT_COLOR = clr.GREEN + clr.BOLD;
const NOW_WATCHING_COLOR = clr.MAGENTA;
const SPECIAL_PROMPT_COLOR = clr.DIM;
const COMMAND_COLOR = clr.CYAN;
/**
* Execute the build tasks.
*
* @param cfg {{
dir: string,
taskSet: Object.<string, string[]>,
tasksToRun: string[],
watchMode: boolean,
spawn: Function,
env: Object,
log: Function,
packageRoot: string
}} Configuration to execute
@return Promise
*/
function executeAll (cfg) {
console.log(`${clr.DIM+clr.LIGHT_MAGENTA}Package: ${cfg.packageRoot}${clr.RESET}`);
return new Promise((resolve, reject) => {
createObservable(cfg).subscribe({
next ({command, exitCode}) {
if (exitCode == 0)
cfg.log(`${EMIT_COLOR}just-build ${cfg.tasksToRun.join(' ')}`+
` done.${clr.RESET}${cfg.watchMode ? NOW_WATCHING_COLOR+' Still watching...'+clr.RESET : ''}`);
else {
const errText = `just-build ${cfg.tasksToRun.join(' ')} failed. ${command} returned ${exitCode}`;
cfg.log(errText);
if (!cfg.watchMode) {
reject(new Error(errText));
}
}
},
error (err) {
reject(err);
},
complete () {
resolve();
}
});
});
}
/**
* Create a build-task executer as an Observable
*
* @param cfg {{
dir: string,
taskSet: Object.<string, string[]>,
tasksToRun: string[],
watchMode: boolean,
spawn: Function,
env: Object,
log: Function
}} Configuration to execute
@return Observable
*/
function createObservable (cfg) {
const {dir, taskSet, tasksToRun, watchMode, spawn, env, log} = cfg;
const tasks = tasksToRun.map(taskName => {
const commandList = taskSet[taskName];
if (!commandList)
throw new Error (`No such task name: ${taskName} was configured`);
return commandList;
});
return createParallellCommandsExecutor (
tasks,
dir,
env,
watchMode,
{spawn, log});
}
/**
* Create an Observable that will execute all tasks in parallell and emit exitCode
* whenever one of the tasks fails, or whenever all tasks emits successful exit code.
* If then a command is rerun and complete successfully, it will emit again.
* The final subscription will complete when (and if) all tasks completes.
*
* @param tasks {string[][]} Array of tasks (sequences of commands) to execute
* @param workingDir {string} Initial Working Directory
* @param envVars {Object} Initial environment variables
* @param watchMode {boolean} Whether to execute watchers or not
* @param host {{spawn: Function, log: Function}} Mockable host environment (mimicking child_process.spawn() and console.log())
@returns Observable
*/
function createParallellCommandsExecutor (tasks, workingDir, envVars, watchMode, host) {
return new Observable(observer => {
const exitCodes = tasks.map(()=>undefined);
let completeCount = 0;
tasks.forEach((commands, i) => {
const observable = createSequencialCommandExecutor(
commands, workingDir, envVars, watchMode, host);
observable.subscribe({
next ({command, exitCode}) {
exitCodes[i] = exitCode || 0;
if (!!exitCode) {
// Partial failure. Emit the error directly to output to console
// which command that failed. May be repaired by a watcher watching changed
// source.
observer.next({command, exitCode});
return;
}
if (exitCodes.every(code => code === 0)) {
observer.next({exitCode: 0});
}
},
error(err) {
observer.error(err);
},
complete() {
if (++completeCount === tasks.length) {
observer.complete();
}
}
});
});
});
}
/**
* Create an Observable that would execute a sequence of commands.
*
* @param commands {string[]} A sequence of commands to execute
* @param workingDir {string} Initial Working Directory
* @param envVars {Object} Initial environment variables
* @param watchMode {boolean} Whether to execute watchers or not
* @param host {{spawn: Function, log: Function}} Mockable host environment (mimicking child_process.spawn() and console.log())
@returns Observable
*/
function createSequencialCommandExecutor (commands, workingDir, envVars, watchMode, host) {
const source = Observable.from([{
cwd: workingDir,
env: envVars,
exitCode: 0
}]);
return commands.reduce((prev, command) =>
createCommandExecutor(command, prev, watchMode, host), source);
}
/**
* @param command {string} Command line to execute
* @param prevObservable {Observable} Observable source to get values from
* @param watchMode {boolean} Whether to invoke --watch argument
* @param host {{
spawn: Function,
log: Function
}} Host and Configuration.
*/
function createCommandExecutor (command, prevObservable, watchMode, host) {
return new Observable (observer => {
var prevComplete = false;
var childProcess = null;
var childSubscription = null; // Treat childSubscription exactly the same way as childProcess.
var prevSubscription = prevObservable.subscribe({
next (envProps) {
let [cmd, ...args] = tokenize (command, envProps.env);
if (envProps.exitCode) {
// Previous process exited with non-zero.
// Should not continue flow. Instead, forward the error all the
// way to the end listener. Note: This may happen several times and does not
// mean that observable.error() should be called. Reason: error means the end
// of the whole stream while this is not nescessarily so, as a source may be
// continously watching while a subsequent process failed to do it's job.
observer.next(envProps);
if (prevComplete) observer.complete();
return;
}
if (childProcess) {
// We've created a process as a response to a previous next().
// We are expected to re-execute the command.
try {
//console.log(`Killing ${command}`);
childProcess.kill('SIGTERM'); // Or should we use SIGINT ('CTRL-C')
} catch(err) {
console.error(`Failed to kill '${command}'. Error: ${err}`);
}
childProcess = null;
}
if (childSubscription) {
// We've created a child subscription
// We are expected to re-execute the subscription.
childSubscription.unsubscribe();
childSubscription = null;
}
try { // Don't know if we're required to do try..catch here or if the framework does that for us. Read/test es-observable contract!
if (!cmd) {
// Comment or empty line. ignore.
const text = command.split('#').map(s=>s.trim());
if (text.length > 1) {
host.log(
COMMENT_COLOR +
text.slice(1).join(' ') +
clr.RESET);
}
observer.next(clone(envProps, {
command: command,
exitCode: 0
}));
} else if (cmd === 'cd') {
// cd
const newDir = path.resolve(envProps.cwd, args[0]);
host.log(`${SPECIAL_PROMPT_COLOR}> ${COMMAND_COLOR}cd ${args[0]}${clr.RESET}`);
observer.next(clone(envProps, {
command: command,
exitCode: 0,
cwd: newDir,
}));
} else if (cmd.indexOf('=') !== -1 || args.length > 0 && args[0].indexOf('=') === 0) {
// ENV_VAR = value, ENV_VAR=value, ENV_VAR= value or ENV_VAR =value.
const statement = args.length > 0 ?
args[0] === '=' ?
cmd + args[0] + args[1] :
cmd + args[0] :
cmd;
const [variable, value] = statement.split('=');
const newEnv = clone(envProps.env);
newEnv[variable] = value;
host.log(`${SPECIAL_PROMPT_COLOR}> ${COMMAND_COLOR}${variable}=${surroundWithQuotes(value)}${clr.RESET}`);
observer.next(clone(envProps, {
command: command,
exitCode: 0,
env: newEnv
}));
} else if (cmd === 'just-build') {
// Shortcutting "just-build" commands to:
// 1. Not spawn a new process for it.
// 2. Not having to use [--watch] for it.
let {refinedArgs, grepString, useWatch} = refineArguments(args, true, command);
if (useWatch) {
host.log(`${SPECIAL_PROMPT_COLOR}> ${COMMAND_COLOR}${command}${clr.RESET}`);
throw new Error(`[--watch] is redundant for 'just-build'. It will invoke it automatically. http://tinyurl.com/z6ylnb7`);
}
const subCfg = extractConfig (["node", "just-build"].concat(args), {
cwd: envProps.cwd,
env: envProps.env
});
extend (subCfg, {
log: host.log,
spawn: host.spawn,
watchMode}); // Override watchmode given to root just-build as we ignore [--watch] argument here.
// Treat childSubscription exactly the same way as childProcess.
// it is conceptually the same thing. Use unsubscribe() istead of kill().
childSubscription = createObservable(subCfg).subscribe ({
next (result) {
observer.next(clone(envProps, {
command: result.command || command, // If succesful exitCode, result.command will be undefined.
exitCode: result.exitCode
}));
},
complete () {
childSubscription = null;
if (prevComplete) observer.complete();
},
error (err) {
childSubscription = null;
observer.error(err);
}
});
} else {
// Ordinary command
debug.log(`ordinary command: ${JSON.stringify([args, watchMode, command])}`);
let {refinedArgs, grepString, useWatch} = refineArguments(args, watchMode, command);
debug.log(`[refinedArgs, grepString, useWatch] = ${JSON.stringify([refinedArgs, grepString, useWatch])}`);
debug.log(`cmd = ${JSON.stringify(cmd)}`);
debug.log(`cwd = ${envProps.cwd}, env = ${envProps.env}`);
childProcess = (host.spawn)(
cmd,
refinedArgs, {
cwd: envProps.cwd,
env: envProps.env,
shell: true
});
childProcess.stdout.pipe(new ColorTransform()).pipe(process.stdout);
childProcess.stderr.pipe(new ColorTransform(true)).pipe(process.stderr);
childProcess.on('error', err => observer.error(err)); // Correct? Or just
if (useWatch) {
childProcess.stdout.on('data', data => {
if (data.indexOf(grepString) !== -1) {
observer.next(clone(envProps, {
command: command,
exitCode: undefined // No real exit code yet. Handled as exitCode 0.
}));
}
});
}
childProcess.on('exit', code => {
childProcess = null;
observer.next(clone(envProps, {
command: command,
exitCode: code
}));
if (prevComplete) observer.complete();
});
}
} catch (err) {
observer.error(err);
}
},
error(err) {
observer.error(err);
},
complete() {
prevComplete = true;
if (!childProcess && !childSubscription) observer.complete();
}
})
return {
unsubscribe () {
if (childProcess) {
try {
//console.log(`Killing ${command}`);
childProcess.kill('SIGTERM'); // Or should we use 'SIGINT' (CTRL-C) ?
}
catch (err) {
console.error(`Failed to kill '${command}'. Error: ${err}`);
};
/* Should we remove "exit", "error" and "data" listeners?
if (process.removeListener) {
if (process.stdout.removeListener) {
}
}
*/
childProcess = null;
}
if (childSubscription) {
childSubscription.unsubscribe();
childSubscription = null;
}
prevSubscription.unsubscribe();
},
get closed() {
return !childProcess && !childSubscription && prevSubscription.closed;
}
}
});
}
/**
* Takes an array of arguments and removes "[--watch ...]" if not watchMode. Otherwise,
* includes "--watch".
*
* @returns {{
* refinedArgs: string,
* grepString: string,
* useWatch: boolean
* }} Returns the refined arguments together with the grepString to watch for in case useWatch is true.
*/
function refineArguments(args, watchMode, commandSource) {
const refinedArgs = [];
let hasOptionalWatchArg = false;
let grepString = null;
for (let i=0; i<args.length; ++i) {
let arg = args[i];
if (arg === '[--watch') {
hasOptionalWatchArg = true;
if (watchMode) {
refinedArgs.push('--watch');
if (i + 1 >= args.length)
throw new Error (`Missing grepString in the following command: "${commandSource}"`);
grepString = args[i + 1];
}
if (i + 2 >= args.length || args[i + 2] !== ']')
throw new Error (`Missing ']' in the following command: ${commandSource}`);
i += 2;
} else {
refinedArgs.push(arg);
}
};
return {
refinedArgs,
grepString: grepString,
useWatch: hasOptionalWatchArg && watchMode
};
}
module.exports = {executeAll, createObservable, refineArguments};