post-commit
Version:
Automatically install post-commit hooks for your npm modules.
310 lines (263 loc) • 8.58 kB
JavaScript
'use strict';
var spawn = require('cross-spawn')
, which = require('which')
, path = require('path')
, util = require('util')
, tty = require('tty');
/**
* Representation of a hook runner.
*
* @constructor
* @param {Function} fn Function to be called when we want to exit
* @param {Object} options Optional configuration, primarily used for testing.
* @api public
*/
function Hook(fn, options) {
if (!this) return new Hook(fn, options);
options = options || {};
this.options = options; // Used for testing only. Ignore this. Don't touch.
this.config = {}; // post-commit configuration from the `package.json`.
this.json = {}; // Actual content of the `package.json`.
this.npm = ''; // The location of the `npm` binary.
this.git = ''; // The location of the `git` binary.
this.root = ''; // The root location of the .git folder.
this.exit = fn; // Exit function.
this.commit = ''; // The commit get with `git log --oneline -1 head`
this.commitMessage = ''; // The commit msg get with `git log --oneline -1 head`
this.commitId = ''; // The commit md5 id get with `git log --oneline -1 head`
this.initialize();
}
/**
* Boolean indicating if we're allowed to output progress information into the
* terminal.
*
* @type {Boolean}
* @public
*/
Object.defineProperty(Hook.prototype, 'silent', {
get: function silent() {
return !!this.config.silent;
}
});
/**
* Boolean indicating if we're allowed and capable of outputting colors into the
* terminal.
*
* @type {Boolean}
* @public
*/
Object.defineProperty(Hook.prototype, 'colors', {
get: function colors() {
return this.config.colors !== false && tty.isatty(process.stdout.fd);
}
});
/**
* Execute a binary.
*
* @param {String} bin Binary that needs to be executed
* @param {Array} args Arguments for the binary
* @returns {Object}
* @api private
*/
Hook.prototype.exec = function exec(bin, args) {
return spawn.sync(bin, args, {
stdio: 'pipe'
});
};
/**
* Parse the package.json so we can create an normalize it's contents to
* a usable configuration structure.
*
* @api private
*/
Hook.prototype.parse = function parse() {
var post = this.json['post-commit'] || this.json.postcommit
, config = !Array.isArray(post) && 'object' === typeof post ? post : {};
['silent', 'colors'].forEach(function each(flag) {
var value;
if (flag in config) value = config[flag];
else if ('postcommit.' + flag in this.json) value = this.json['postcommit.' + flag];
else if ('post-commit.' + flag in this.json) value = this.json['post-commit.' + flag];
else return;
config[flag] = value;
}, this);
//
// The scripts we need to run can be set under the `run` property.
//
config.run = config.run || post;
if ('string' === typeof config.run) config.run = config.run.split(/[, ]+/);
this.config = config;
};
/**
* Write messages to the terminal, for feedback purposes.
*
* @param {Array} lines The messages that need to be written.
* @param {Number} exit Exit code for the process.exit.
* @api public
*/
Hook.prototype.log = function log(lines, exit) {
if (!Array.isArray(lines)) lines = lines.split('\n');
if ('number' !== typeof exit) exit = 1;
var prefix = this.colors
? '\u001b[38;5;166mpost-commit:\u001b[39;49m '
: 'post-commit: ';
lines.push(''); // Whitespace at the end of the log.
lines.unshift(''); // Whitespace at the beginning.
lines = lines.map(function map(line) {
return prefix + line;
});
if (!this.silent) lines.forEach(function output(line) {
if (exit) console.error(line);
else console.log(line);
});
this.exit(exit, lines);
return exit === 0;
};
/**
* Initialize all the values of the constructor to see if we can run as an
* pos-commit hook.
*
* @api private
*/
Hook.prototype.initialize = function initialize() {
['git', 'npm'].forEach(function each(binary) {
try {
this[binary] = which.sync(binary);
}
catch (e) {
}
}, this);
//
// in GUI clients node and npm are not in the PATH so get node binary PATH,
// add it to the PATH list and try again.
//
if (!this.npm) {
try {
process.env.PATH += path.delimiter + path.dirname(process.env._);
this.npm = which.sync('npm');
} catch (e) {
return this.log(this.format(Hook.log.binary, 'npm'), 0);
}
}
//
// Also bail out if we cannot find the git binary.
//
if (!this.git) return this.log(this.format(Hook.log.binary, 'git'), 0);
this.commit = this.exec(this.git, ['log', '--oneline', '-1', 'head']);
if (this.commit.code) return this.log(Hook.log.commit, 0);
this.commit = this.commit.stdout.toString().trim();
var commitTmp = this.commit.match(/^(.{7}) (.*)$/);
if (!commitTmp) {
this.commitMessage = this.commit;
} else {
this.commitId = commitTmp[1];
this.commitMessage = commitTmp[2];
}
this.root = this.exec(this.git, ['rev-parse', '--show-toplevel']);
if (this.root.code) return this.log(Hook.log.root, 0);
this.root = this.root.stdout.toString().trim();
try {
this.json = require(path.join(this.root, 'package.json'));
this.parse();
} catch (e) {
return this.log(this.format(Hook.log.json, e.message), 0);
}
if (!this.config.run) return this.log(Hook.log.run, 0);
};
/**
* Run the specified hooks.
*
* @api public
*/
Hook.prototype.run = function runner() {
var hooked = this;
(function again(scripts) {
if (!scripts.length) return hooked.exit(0);
var script = scripts.shift();
//
// There's a reason on why we're using an async `spawn` here instead of the
// `shelljs.exec`. The sync `exec` is a hack that writes writes a file to
// disk and they poll with sync fs calls to see for results. The problem is
// that the way they capture the output which us using input redirection and
// this doesn't have the required `isAtty` information that libraries use to
// output colors resulting in script output that doesn't have any color.
//
spawn(hooked.npm, ['run', script, '--silent', 'commit=' + hooked.commit, 'commit-message=' + hooked.commitMessage, 'commit-id' + hooked.commitId,], {
env: process.env,
cwd: hooked.root,
stdio: [0, 1, 2]
}).once('close', function closed(code) {
if (code) return hooked.log(hooked.format(Hook.log.failure, script, code));
again(scripts);
});
})(hooked.config.run.slice(0));
};
/**
* Expose some of our internal tools so plugins can also re-use them for their
* own processing.
*
* @type {Function}
* @public
*/
Hook.prototype.format = util.format;
/**
* The various of error and status messages that we can output.
*
* @type {Object}
* @private
*/
Hook.log = {
binary: [
'Failed to locate the `%s` binary, make sure it\'s installed in your $PATH.',
'Skipping the post-commit hook.'
].join('\n'),
commit: [
'Failed to get the latest commit with `git log --oneline -1 head`',
'Skipping the post-commit hook.'
].join('\n'),
status: [
'Failed to retrieve the `git status` from the project.',
'Skipping the post-commit hook.'
].join('\n'),
root: [
'Failed to find the root of this git repository, cannot locate the `package.json`.',
'Skipping the post-commit hook.'
].join('\n'),
empty: [
'No commit detected.',
'Skipping the post-commit hook.'
].join('\n'),
json: [
'Received an error while parsing or locating the `package.json` file:',
'',
' %s',
'',
'Skipping the post-commit hook.'
].join('\n'),
run: [
'We have nothing post-commit hooks to run. Either you\'re missing the `scripts`',
'in your `package.json` or have configured post-commit to run nothing.',
'Skipping the post-commit hook.'
].join('\n'),
failure: [
'We\'ve failed to pass the specified git post-commit hooks as the `%s`',
'hook returned an exit code (%d). If you\'re feeling adventurous you can',
'skip the git post-commit hooks by adding the following flags to your commit:',
'',
' git commit -n (or --no-verify)',
'',
'This is ill-advised since the commit is broken.'
].join('\n')
};
//
// Expose the Hook instance so we can use it for testing purposes.
//
module.exports = Hook;
//
// Run directly if we're required executed directly through the CLI
//
if (module !== require.main) return;
var hook = new Hook(function cli(code) {
process.exit(code);
});
hook.run();