UNPKG

affiance

Version:

A configurable and extendable Git hook manager for node projects

352 lines (292 loc) 10.6 kB
'use strict'; const _ = require('lodash'); const crypto = require('crypto'); const path = require('path'); const fse = require('fs-extra'); const AffianceError = require('../error'); const gitRepo = require('../gitRepo'); const configValidator = require('./validator'); const utils = require('../utils'); /** * @class Config * @classdesc Encapsulate functionality related to configuration like lookups, * signature verification, and parsing of dynamic fields like `concurrency`. */ module.exports = class Config { /** * Creates a Config instance from a json object and options. * * @param {object} json - the config object * @param {object} options - options for the constructor * @param {boolean} options.validate - if false, will not validate config. */ constructor(json, options) { this.options = _.merge({}, options || {}); this.json = _.merge({}, json); if (this.options.validate !== false) { this.json = configValidator.validate(this, this.json, this.options); } } /** * Get a key out of the config. * @param {string} key - the key of the config field * @returns {*} */ get(key) { return this.json[key]; } /** * Checks if the signature of the config file has changed. * * @returns {boolean} true if the signature has changed. */ hasSignatureChanged() { return (this.signature() !== this.storedSignature()); } /** * Computes the signature of the config file's contents. * * @returns {string} - the digest of the sha256 signature of the file contents */ signature() { let hash = crypto.createHash('sha256'); hash.update(JSON.stringify(this.json)); return hash.digest('hex'); } /** * Checks if the signature should be verified * Respects the 'verifySignatures' config setting. * * @returns {boolean} */ shouldVerifySignatures() { if (process.env.AFFIANCE_NO_VERIFY) { return false; } if (this.json['verifySignatures'] !== false) { return true; } let result = utils.spawnSync('git', ['config', '--local', '--get', this.verifySignatureConfigKey()]); // Key does not exist if (result.status === 1) { return true; } else if(result.status !== 0) { throw AffianceError.error( AffianceError.GitConfigError, 'Unable to read from local repo git config: ' + result.stderr.toString() ); } return (result.stdout.toString().trim() !== '0'); } /** * Update the signature stored in the local repo config. */ updateSignature() { let result = utils.spawnSync('git', ['config', '--local', this.signatureConfigKey(), this.signature()]); if(result.status !== 0) { throw AffianceError.error( AffianceError.GitConfigError, 'Unable to read from local repo git config: ' + result.stderr.toString() ); } let verifySignatureValue = this.json['verifySignatures'] ? 1 : 0; let verifyResult = utils.spawnSync('git', ['config', '--local', this.verifySignatureConfigKey(), verifySignatureValue]); if(verifyResult.status !== 0) { throw AffianceError.error( AffianceError.GitConfigError, 'Unable to read from local repo git config: ' + verifyResult.stderr.toString() ); } } storedSignature() { let result = utils.spawnSync('git', ['config', '--local', '--get', this.signatureConfigKey()]); // Key does not exist if (result.status === 1) { return ''; } else if(result.status !== 0) { throw AffianceError.error( AffianceError.GitConfigError, 'Unable to read from local repo git config: ' + result.stderr.toString() ); } return result.stdout.toString().trim(); } signatureConfigKey() { return 'affiance.configuration.signature'; } verifySignatureConfigKey() { return 'affiance.configuration.verifysignatures'; } applyEnvironment(hookContext, env) { let skippedHooks = gatherSkippedHooks(env); let onlyHooks = gatherOnlyHooks(env); let hookType = hookContext.hookConfigName; if (onlyHooks.length || skippedHooks.indexOf('all') > -1 || skippedHooks.indexOf('ALL') > -1) { this.json[hookType]['ALL']['skip'] = true; } for (let onlyHookIndex in onlyHooks) { let onlyHook = onlyHooks[onlyHookIndex]; onlyHook = utils.camelCase(onlyHook); if(!this.hookExists(hookContext, onlyHook)) { continue; } this.json[hookType][onlyHook] = this.json[hookType][onlyHook] || {}; this.json[hookType][onlyHook]['skip'] = false; } for (let skipHookIndex in skippedHooks) { let skipHook = skippedHooks[skipHookIndex]; skipHook = utils.camelCase(skipHook); if(!this.hookExists(hookContext, skipHook)) { continue; } this.json[hookType][skipHook] = this.json[hookType][skipHook] || {}; this.json[hookType][skipHook]['skip'] = true; } } enabledBuiltInHooks(hookContext) { return Object.keys(this.json[hookContext.hookConfigName]) .filter((hookName) => { return (hookName !== 'ALL'); }) .filter((hookName) => { return this.isBuiltInHook(hookContext, hookName); }) .filter((hookName) => { return this.isHookEnabled(hookContext, hookName); }); } enabledAdHocHooks(hookContext) { return Object.keys(this.json[hookContext.hookConfigName]) .filter((hookName) => { return (hookName !== 'ALL'); }) .filter((hookName) => { return this.isAdHocHook(hookContext, hookName); }) .filter((hookName) => { return this.isHookEnabled(hookContext, hookName); }); } forHook(hookName, hookType) { let allConfig = _.merge({}, this.json[hookType]['ALL'] || {}); let hookConfig = this.constructor.smartMerge(allConfig, this.json[hookType][hookName] || {}); hookConfig.enabled = this.isHookEnabled(hookType, hookName); return hookConfig; } isHookEnabled(hookContextOrType, hookName) { let hookType = typeof hookContextOrType === 'string' ? hookContextOrType : hookContextOrType.hookConfigName; let specificHookConfig = this.json[hookType][hookName] || {}; if (typeof specificHookConfig.enabled !== 'undefined') { return !!specificHookConfig.enabled; } let allHookConfig = this.json[hookType]['ALL'] || {}; if (typeof allHookConfig.enabled !== 'undefined') { return !!allHookConfig.enabled; } return false; } hookExists(hookContext, hookName) { return ( this.isBuiltInHook(hookContext, hookName) || this.isPluginHook(hookContext, hookName) || this.isAdHocHook(hookContext, hookName) ); } isBuiltInHook(hookContext, hookName) { hookName = utils.camelCase(hookName); let hookPath = path.join(__dirname, '..', 'hook', hookContext.hookScriptName, hookName + '.js'); return fse.existsSync(hookPath); } isPluginHook(hookContextOrType, hookName) { let hookType = typeof hookContextOrType === 'string' ? hookContextOrType : hookContextOrType.hookScriptName; let hookPath = path.join(this.pluginDirectory(), hookType, hookName + '.js'); return fse.existsSync(hookPath); } pluginDirectory() { let pluginDirectory = this.json['pluginDirectory'] || '.git-hooks'; return path.join(gitRepo.repoRoot(), pluginDirectory); } isAdHocHook(hookContext, hookName) { let hookConf = this.json[hookContext.hookConfigName] || {}; hookConf = hookConf[hookName]; if (!hookConf) { return false; } return ( !this.isBuiltInHook(hookContext, hookName) && !this.isPluginHook(hookContext, hookName) && (hookConf.command || hookConf.requiredExecutable) ); } nodeModuleMode() { if (!this._nodeModuleMode) { switch(this.json['nodeModuleMode']) { // TODO add ability to set up shared folder specifically for affiance case 'global': this._nodeModuleMode = 'global'; break; case 'local': default: this._nodeModuleMode = 'local'; break; } } return this._nodeModuleMode; } // TODO: Add tests for global mode useGlobalNodeModules() { return this.nodeModuleMode() === 'global'; } concurrency() { if (!this._concurrency) { let cores = utils.processorCount(); let content = this.json.concurrency || '%{processors}'; if (typeof content === 'string') { let concurrencyExpr = content.replace('%{processors}', cores); let matches = /(\d+)\s*([+\-*\/])\s*(\d+)/.exec(concurrencyExpr); if (matches) { let sideA = matches[1]; let op = matches[2]; let sideB = matches[3]; this._concurrency = Math.max(operatorFunction[op](parseInt(sideA), parseInt(sideB)), 1); } else { this._concurrency = Math.max(parseInt(concurrencyExpr), 1); } } else if (typeof content === 'number') { this._concurrency = parseInt(content, 10); } else { this._concurrency = 1; } } return this._concurrency; } /** * Merge two sets of configurations with special handling of * the `ALL` configuration. This will override all defaults for * the merged object. * @param parent * @param child * @returns {*} */ static smartMerge(parent, child) { let childAll = child.ALL; // Tread the ALL hook specially so that it overrides any configuration // specified by the default configuration. if (childAll) { let newParent = {}; for (let key in parent) { let value = parent[key]; let mergeResult = this.smartMerge(value, childAll); newParent[key] = mergeResult; } parent = newParent; } for (let key in child) { let oldValue = parent[key]; let newValue = child[key]; if (oldValue && !Array.isArray(oldValue) && typeof oldValue === 'object') { parent[key] = this.smartMerge(oldValue, newValue); } else { parent[key] = newValue; } } return parent; } }; const operatorFunction = { '+': (a, b) => { return a + b; }, '-': (a, b) => { return a - b; }, '*': (a, b) => { return a * b; }, '/': (a, b) => { return a / b; }, '\\': (a, b) => { return a / b; } }; function gatherSkippedHooks(env) { let skippedHookStr = ''; skippedHookStr += env.SKIP || ''; skippedHookStr += env.SKIP_CHECKS || ''; skippedHookStr += env.SKIP_HOOKS || ''; if (!skippedHookStr) { return []; } return skippedHookStr.split(/[:, ]/); } function gatherOnlyHooks(env) { if (!env.ONLY) { return []; } return env.ONLY.split(/[:, ]/); }