UNPKG

@fastify/pre-commit

Version:

Automatically install pre-commit hooks for your npm modules.

343 lines (291 loc) 9.34 kB
'use strict' const spawn = require('cross-spawn') const which = require('which') const path = require('node:path') const util = require('node:util') const tty = require('node:tty') const fs = require('node:fs') /** * 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 = {} // pre-commit configuration from the `package.json`. this.configFile = null // Actual contents of `pre-commit.json` if the user created one. 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.status = '' // Contents of the `git status`. this.exit = fn // Exit function. 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 () { const pre = this.configFile || this.json['pre-commit'] || this.json.precommit const config = !Array.isArray(pre) && typeof pre === 'object' ? pre : {}; ['silent', 'colors', 'template'].forEach(function each (flag) { let value if (flag in config) value = config[flag] else if ('precommit.' + flag in this.json) value = this.json['precommit.' + flag] else if ('pre-commit.' + flag in this.json) value = this.json['pre-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 || pre if (typeof config.run === 'string') config.run = config.run.split(/[, ]+/) if ( !Array.isArray(config.run) && this.json.scripts && this.json.scripts.test && this.json.scripts.test !== 'echo "Error: no test specified" && exit 1' ) { config.run = ['test'] } 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 (typeof exit !== 'number') exit = 1 const prefix = this.colors ? '\u001b[38;5;166mpre-commit:\u001b[39;49m ' : 'pre-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 * pre-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.root = this.exec(this.git, ['rev-parse', '--show-toplevel']) this.status = this.exec(this.git, ['status', '--porcelain']) if (this.status.code) return this.log(Hook.log.status, 0) if (this.root.code) return this.log(Hook.log.root, 0) this.status = this.status.stdout.toString().trim() this.root = this.root.stdout.toString().trim() if (fs.existsSync(path.join(this.root, '.pre-commit.json'))) { let configFile try { const rawText = fs.readFileSync(path.join(this.root, '.pre-commit.json'), 'utf-8').toString() configFile = JSON.parse(rawText) } catch (e) { return this.log(this.format(Hook.log.preCommitConfig, e.message), 1) } if (typeof configFile === 'object' && !Array.isArray(configFile)) { this.configFile = configFile } } 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) } // // We can only check for changes after we've parsed the package.json as it // contains information if we need to suppress the empty message or not. // if (!this.status.length && !this.options.ignorestatus) { return this.log(Hook.log.empty, 0) } // // If we have a git template we should configure it before checking for // scripts so it will still be applied even if we don't have anything to // execute. // if (this.config.template) { this.exec(this.git, ['config', 'commit.template', this.config.template]) } if (!this.config.run) return this.log(Hook.log.run, 0) } /** * Run the specified hooks. * * @api public */ Hook.prototype.run = function runner () { const hooked = this; (function again (scripts) { if (!scripts.length) return hooked.exit(0) const 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'], { 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 pre-commit hook.' ].join('\n'), status: [ 'Failed to retrieve the `git status` from the project.', 'Skipping the pre-commit hook.' ].join('\n'), root: [ 'Failed to find the root of this git repository, cannot locate the `package.json`.', 'Skipping the pre-commit hook.' ].join('\n'), empty: [ 'No changes detected.', 'Skipping the pre-commit hook.' ].join('\n'), json: [ 'Received an error while parsing or locating the `package.json` file:', '', ' %s', '', 'Skipping the pre-commit hook.' ].join('\n'), preCommitConfig: [ 'Received an error while parsing or locating the `.pre-commit.json` file:', '', ' %s', '', 'Skipping the pre-commit hook.' ].join('\n'), run: [ 'We have nothing pre-commit hooks to run. Either you\'re missing the `scripts`', 'in your `package.json` or have configured pre-commit to run nothing.', 'Skipping the pre-commit hook.' ].join('\n'), failure: [ 'We\'ve failed to pass the specified git pre-commit hooks as the `%s`', 'hook returned an exit code (%d). If you\'re feeling adventurous you can', 'skip the git pre-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) { const hook = new Hook(function cli (code) { process.exit(code) }) hook.run() }