automaton
Version:
Task automation tool
673 lines (585 loc) • 19.1 kB
JavaScript
'use strict';
var mout = require('mout');
var fs = require('fs');
var assert = require('assert');
var async = require('async');
var glob = require('glob');
var path = require('path');
var inter = require('./lib/string/interpolate');
var castInter = require('./lib/string/castInterpolate');
var Logger = require('./lib/Logger');
var TaskBuilder = require('./lib/TaskBuilder');
var GruntRunner = require('./lib/grunt/Runner');
/**
* Automaton.
*
* Available options:
* - verbosity - 0 means no logging
* 1 means only 1 deep level tasks and so on..
* -1 means log every level
* - debug - true to log debug messages, false otherwise
* - color - true to keep colors in messages, false otherwise
*
* @param {Object} [options] The options
*/
function Automaton(options) {
var dirs;
var dirname = __dirname;
var rootDir = path.resolve('/');
this._tasks = [];
this._options = options;
// load core tasks
dirs = glob.sync(dirname + '/node_modules/autofile-*');
// find up autofile's directory because latest npm >=v4 have node_modules tree flattened
while (dirname !== rootDir && dirs.length === 0) {
dirname = path.resolve(dirname, '..');
dirs = glob.sync(dirname + '/autofile-*');
}
dirs.forEach(function (dir) {
var errors = this.loadTasks(dir, 'autofile.js');
if (errors.length) {
throw new Error(errors[0]);
}
}, this);
}
/**
* Add new task.
* If the task already exists, it will be replaced.
* The task will be validated only before run.
*
* @param {Object|Function} task The task definition
*
* @return {Automaton} Chainable!
*/
Automaton.prototype.addTask = function (task) {
task = Automaton.getTaskDefinition(task);
TaskBuilder.validate(task);
assert(task.id, 'Can only add tasks with an id');
this._tasks[task.id] = task;
return this;
};
/**
* Remove task.
*
* @param {String} id The task id
*
* @return {Automaton} Chainable!
*/
Automaton.prototype.removeTask = function (id) {
assert(mout.lang.isString(id), 'Invalid task id provided');
delete this._tasks[id];
return this;
};
/**
* Retrieve a task definition by its id.
*
* @param {String} taskId The task id
*
* @return {Object} The task definition or null if not loaded
*/
Automaton.prototype.getTask = function (taskId) {
var task = this._tasks[taskId];
return task ? task : null;
};
/**
* Retrieve the loaded tasks.
*
* @return {Object} The tasks
*/
Automaton.prototype.getTasks = function () {
return this._tasks;
};
/**
* Load tasks in a given folder.
* If the folder contain tasks that are not valid, it results in an error being pushed to
* an array. That array is returned by the function.
*
* @param {String} folder The folder to search for tasks
* @param {String} [pattern] The pattern to use when searching for files, defaults to '*.js'
*
* @return {Array} An array of errors
*/
Automaton.prototype.loadTasks = function (folder, pattern) {
assert(mout.lang.isString(folder), 'Expected folder to be a string');
pattern = pattern || '*.js';
folder = fs.realpathSync(folder) + '/';
var filenames = glob.sync(folder + '/' + pattern),
errors = [];
filenames.forEach(function (file) {
try {
this.addTask(require(file));
} catch (e) {
errors.push(new Error('Unable to add task "' + file + '": ' + e.message));
}
}, this);
return errors;
};
/**
* Run a task.
* The callback follows the node style.
*
* @param {String|Object} task The task id or definition
* @param {Object} [options] The task options
* @param {Function} [callback] A callback to be called when the task completes
*
* @return {Stream} A read stream where logging will be done
*/
Automaton.prototype.run = function (task, options, callback) {
var batch,
handle,
context,
stream;
// function to handle the completion of the task
handle = function (err) {
// kill the grunt worker
context.gruntRunner.kill();
if (err) {
// if error is not actually an error, attempt to fix it
if (!(err instanceof Error)) {
err = new Error(err + '');
}
// log the error
context.log.errorln(err.message);
}
// signal the end of the stream
stream.emit('end');
// call callback if any
if (callback) {
if (err) {
err.message = Logger.removeColors(err.message); // Remove any colors from the message
}
callback(err);
}
return this;
}.bind(this);
// setup an unique context for the task
context = {};
context.log = new Logger(this._options);
context.gruntRunner = new GruntRunner(context);
context.string = {};
context.string.interpolate = inter;
context.string.castInterpolate = castInter;
stream = context.log.getStream();
// catch any error while getting the batch
// and report it with node style callback
batch = this._batchTask(this._createTaskDefinition({
task: task,
options: options,
context: context
}));
// run the batch
process.nextTick(batch.bind(batch, handle));
return stream;
};
/**
* Create a batch for a task.
* The batch is a sequence of functions that form the task.
*
* @param {Object} task The task definition
*
* @return {Function} The batch
*/
Automaton.prototype._batchTask = function (def) {
var batch = [],
preTaskFunc,
posTaskFunc,
option
;
// if task is an id
if (mout.lang.isString(def.task)) {
// grab its real definition
this._assertTaskLoaded(def.task);
def.task = this.getTask(def.task);
} else {
// if task is a function then needs a builder
if (mout.lang.isFunction(def.task)) {
def.task = Automaton.getTaskDefinition(def.task);
}
// trigger validation if is the root task
if (def.depth === 1) {
TaskBuilder.validate(def.task);
}
}
// fill in the options with default values where the option was not provided
for (option in def.task.options) {
if (def.options[option] === undefined && def.task.options[option].default !== undefined) {
def.options[option] = def.task.options[option].default;
}
}
// pre-task
preTaskFunc = function (next) {
var prevNext = next;
// replace options
this._replaceOptions(def.options, def.parentOptions, { skipUnescape : true });
// skip task if disabled
if (!this._isTaskEnabled(def)) {
return process.nextTick(next.bind(next, null, true));
}
// report task
this._onBeforeTask(def);
// after running the setup, we need to replace options again
// because parent setup might have added options that are placeholders
// also we valid if the all mandatory task options are ok
next = function (err) {
if (err) {
return process.nextTick(prevNext.bind(prevNext, err));
}
this._replaceOptions(def.options, def.parentOptions);
process.nextTick(prevNext.bind(prevNext, this._validateTaskOptions(def.task, def.options)));
}.bind(this);
// run setup
if (def.task.setup) {
def.task.setup.call(def.context, def.options, def.context, next);
} else {
next();
}
}.bind(this);
// tasks
def.task.tasks.forEach(function (subtask) {
var subtaskDef = this._createTaskDefinition(subtask, def);
// if it's a grunt task
if (subtaskDef.grunt) {
batch.push(this._batchGruntTask(subtaskDef));
// if it's a inline function
} else if (mout.lang.isFunction(subtaskDef.task) && subtaskDef.task.length !== 1) {
batch.push(this._batchFunctionTask(subtaskDef));
// then it must be another task
} else {
batch.push(this._batchTask(subtaskDef));
}
}, this);
// post task
posTaskFunc = function (err, next) {
err = this._onAfterTask(def, err);
// handle teardown
if (def.task.teardown) {
def.task.teardown.call(def.context, def.options, def.context, function (teardownErr) {
teardownErr = this._onAfterTask(def, teardownErr);
process.nextTick(next.bind(next, err || teardownErr));
}.bind(this));
} else {
process.nextTick(next.bind(next, err));
}
}.bind(this);
// return a final function which calls everything in order
return function (next) {
// run pre-task
preTaskFunc(function (err, disabled) {
// if task is disabled, call next
if (disabled) {
return next();
}
// if there was an error in the pre-task, run pos-task immediately
if (err) {
return posTaskFunc(err, next);
}
// run each subtask
async.series(batch, function (err) {
// finally run the pos-task, even if there was an error
posTaskFunc(err, next);
});
});
};
};
/**
* Create a batch for single function task.
*
* @param {Object} task The task definition
*
* @return {Function} The batch
*/
Automaton.prototype._batchFunctionTask = function (def) {
return function (next) {
this._replaceOptions(def.options, def.parentOptions);
// skip task if disabled
if (!this._isTaskEnabled(def)) {
return process.nextTick(next);
}
// Report task
this._onBeforeTask(def);
// run the function
// note that the options are the parent options
def.task.call(def.context, def.parentOptions, def.context, function (err) {
err = this._onAfterTask(def, err);
process.nextTick(next.bind(next, err));
}.bind(this));
}.bind(this);
};
/**
* Create a batch for a grunt task.
*
* @param {Object} task The task definition
*
* @return {Function} The batch
*/
Automaton.prototype._batchGruntTask = function (def) {
return function (next) {
// replace options
this._replaceOptions(def.options, def.parentOptions);
// skip task if disabled
if (!this._isTaskEnabled(def)) {
return process.nextTick(next);
}
// report task
this._onBeforeTask(def);
// run grunt task in the grunt runner
def.grunt = !mout.lang.isObject(def.grunt) ? {} : def.grunt;
def.context.gruntRunner
.run(def.task, def.options, def.grunt)
.on('data', def.context.log.write.bind(def.context.log))
.on('error', function (err) {
err = this._onAfterTask(def, err);
process.nextTick(next.bind(next, err));
}.bind(this))
.on('end', function () {
process.nextTick(next);
});
}.bind(this);
};
/**
* Creates a task definition for a task.
*
* @param {Object} task The task
* @param {Object} parentTaskDef The parent task definition
*
* @return {Object} The task definition
*/
Automaton.prototype._createTaskDefinition = function (task, parentTaskDef) {
var def = mout.object.mixIn({}, task);
def.options = def.options ? mout.lang.deepClone(def.options) : {};
def.depth = 1;
if (parentTaskDef) {
def.parentOptions = parentTaskDef.options;
def.context = parentTaskDef.context;
def.depth += parentTaskDef.depth;
} else {
def.parentOptions = def.options;
}
return def;
};
/**
* Function to run before each task.
* Reports the task that will run and sets up the logger.
*
* @param {Object} task The task definition
*/
Automaton.prototype._onBeforeTask = function (def) {
var desc,
logger = def.context.log,
inline = mout.lang.isFunction(def.task) || def.grunt;
// try out to extract the description, falling back to the name
desc = def.description !== undefined ? def.description : def.task.description || def.task.name;
if (mout.lang.isFunction(desc)) {
desc = desc(def.parentOptions) + '';
}
if (desc !== null) {
if (!desc) {
// if is an inline function that has no description, then simply do not report
if (inline) {
desc = null;
} else {
// otherwise assume '??'
desc = '??';
}
}
}
// set the logger depth
logger.setDepth(def.depth);
// log task that will run
// if desc is null, simply do not report it
if (desc != null) {
desc = this._replacePlaceholders(desc, def.parentOptions, { purge: true });
logger.infoln('> ' + desc);
}
// mute the logger if task is marked as muted and logger is unmuted
if (!logger.isMuted() && this._isTaskMuted(def)) {
logger.mute();
def.mutedLogger = true;
}
};
/**
* Function to run after each task.
* Finishes up the logger and handle fatal.
*
* @param {Object} task The task definition
* @param {Error} err The task error or null if none
*
* @return {Error} The error after processing
*/
Automaton.prototype._onAfterTask = function (def, err) {
var name;
// handle fatal
if (err && !this._isTaskFatal(def, err)) {
name = def.task.id || def.task.name || 'unknown';
def.context.log.debugln('Task "' + name + '" silently failed: ' + err.message);
err = null;
}
// unmute the logger if this task muted the logger
if (def.mutedLogger) {
def.context.log.unmute();
delete def.mutedLogger;
}
return err;
};
/**
* Check if a task is fatal.
* Disabled tasks should be skipped.
*
* @param {Object} def The task definition
* @param {Error} err The task error or null if none
*
* @return {Boolean} True if enabled, false otherwise
*/
Automaton.prototype._isTaskFatal = function (def, err) {
if (def.hasOwnProperty('fatal')) {
if (mout.lang.isString(def.fatal)) {
return !!this._replacePlaceholders(def.fatal, def.parentOptions, { purge: true });
} else if (mout.lang.isFunction(def.fatal)) {
return !!def.fatal.call(def.context, err, def.parentOptions, def.context);
} else {
return !!def.fatal;
}
}
return true;
};
/**
* Check if a task is enabled.
* Disabled tasks should be skipped.
*
* @param {Object} def The task definition
*
* @return {Boolean} True if enabled, false otherwise
*/
Automaton.prototype._isTaskEnabled = function (def) {
if (def.hasOwnProperty('on')) {
if (mout.lang.isString(def.on)) {
return !!this._replacePlaceholders(def.on, def.parentOptions, { purge: true });
} else if (mout.lang.isFunction(def.on)) {
return !!def.on.call(def.context, def.parentOptions, def.context);
} else {
return !!def.on;
}
}
return true;
};
/**
* Check if a task is muted.
* Muted tasks do not log messages.
*
* @param {Object} def The task definition
*
* @return {Boolean} True if muted, false otherwise
*/
Automaton.prototype._isTaskMuted = function (def) {
if (def.hasOwnProperty('mute')) {
if (mout.lang.isString(def.mute)) {
return !!this._replacePlaceholders(def.mute, def.parentOptions, { purge: true });
} else if (mout.lang.isFunction(def.mute)) {
return !!def.mute.call(def.context, def.parentOptions, def.context);
} else {
return !!def.mute;
}
}
return false;
};
/**
* Replace target placeholders with their correspondent options value.
* If the target is an array or an object, it will replace them
* recursively.
*
* @param {Mixed} target The target which will get its values replaced
* @param {Object} values The values
* @param {Object} [options] The interpolation options
*
* @return {Mixed} The passed target
*/
Automaton.prototype._replaceOptions = function (target, values, options) {
var k,
newK;
if (mout.lang.isObject(target)) {
for (k in target) {
newK = this._replacePlaceholders(k, values, options) + '';
target[newK] = this._replaceOptions(target[k], values, options);
if (newK !== k) {
delete target[k];
}
}
} else if (mout.lang.isArray(target)) {
for (k = target.length - 1; k >= 0; --k) {
target[k] = this._replaceOptions(target[k], values, options);
}
} else if (mout.lang.isString(target)) {
target = this._replacePlaceholders(target, values, options);
}
return target;
};
/**
* Replace placeholders in a string with their correspondent values
*
* @param {String} str The string
* @param {Object} values The values
* @param {Object} [options] The interpolate options
*
* @return {String} The replaced string
*/
Automaton.prototype._replacePlaceholders = function (str, values, options) {
return castInter(str, values, options);
};
/**
* Validate the task options.
* Detects if a task is missing required options.
*
* @param {Object} task The task definition
* @param {Object} options The task options
*
* @return {Error} The error if any
*/
Automaton.prototype._validateTaskOptions = function (task, options, verbose) {
var option;
for (option in task.options) {
// if option was not provided to the task, abort
if (options[option] === undefined) {
return new Error('Missing option \'' + option + '\' in \'' + task.id + '\' task', verbose);
}
}
};
/**
* Assert task is loaded.
*
* @param {String} taskId The task id
*/
Automaton.prototype._assertTaskLoaded = function (taskId) {
assert(this.getTask(taskId), 'Could not find any task handler suitable for \'' + taskId + '\'');
};
/**
* Get the task object in case the task is a function that will build the object.
*
* @param {Object|Function} task The task object or the builder
*
* @return {Object} The task
*/
Automaton.getTaskDefinition = function (task) {
var builder;
if (mout.lang.isFunction(task)) {
builder = new TaskBuilder();
try {
task(builder);
} catch (e) {
throw new Error('Unable to get task from builder: ' + e.message);
}
task = builder.toObject();
}
return task;
};
/**
* Creates a new automaton instance.
* Please see the constructor for more info about the available options.
*
* @param {Object} [options] The options
*
* @return {Automaton} A new automaton instance
*/
Automaton.create = function (options) {
return new Automaton(options);
};
module.exports = Automaton;