@whodunit/investigator
Version:
yeoman inspired (actually stolen) investigator system to troubleshoot your production environment
704 lines (612 loc) • 22.2 kB
JavaScript
'use strict';
const path = require('path');
const os = require('os');
const EventEmitter = require('events');
const assert = require('assert');
const _ = require('lodash');
const findUp = require('find-up');
const readPkgUp = require('read-pkg-up');
const chalk = require('chalk');
const minimist = require('minimist');
const runAsync = require('run-async');
const through = require('through2');
const FileEditor = require('mem-fs-editor');
const debug = require('debug')('whodunit:investigator');
const Conflicter = require('./util/conflicter');
const Storage = require('./util/storage');
const promptSuggestion = require('./util/prompt-suggestion');
const EMPTY = '@@_WHODUNIT_EMPTY_MARKER_@@';
/**
* The `Investigator` class provides the common API shared by all investigators.
* It define options, arguments, file, prompt, log, API, etc.
*
* It mixes into its prototype all the methods found in the `actions/` mixins.
*
* Every investigator should extend this base class.
*
* @constructor
* @mixes actions/help
* @mixes actions/install
* @mixes actions/spawn-command
* @mixes actions/user
* @mixes nodejs/EventEmitter
*
* @param {String|Array} args
* @param {Object} options
*
* @property {Object} env - the current Environment being run
* @property {Object} args - Provide arguments at initialization
* @property {String} resolved - the path to the current investigator
* @property {String} description - Used in `--help` output
* @property {String} appname - The application name
* @property {Storage} config - `.pi-rc` config file manager
* @property {Object} fs - An instance of {@link https://github.com/SBoudrias/mem-fs-editor Mem-fs-editor}
* @property {Function} log - Output content through Interface Adapter
*
* @example
* const { Investigator } = require('@whodunit/investigator');
* module.exports = class extends Investigator {
* async investigating() {
* this.fs.write(this.destinationPath('index.js'), 'const foo = 1;');
* }
* };
*/
class Investigator extends EventEmitter {
constructor(args, options) {
super();
if (!Array.isArray(args)) {
options = args;
args = [];
}
this.options = options || {};
this._initOptions = _.clone(options);
this._args = args || [];
this._options = {};
this._arguments = [];
this._composedWith = [];
this._transformStreams = [];
this.option('help', {
type: Boolean,
alias: 'h',
description: "Print the investigator's options and usage"
});
this.option('skip-cache', {
type: Boolean,
description: 'Do not remember prompt answers',
default: false
});
this.option('skip-install', {
type: Boolean,
description: 'Do not automatically install dependencies',
default: false
});
this.option('force-install', {
type: Boolean,
description: 'Fail on install dependencies error',
default: false
});
// Checks required parameters
assert(
this.options.env,
'You must provide the environment object. Use env#create() to create a new investigator.'
);
assert(
this.options.resolved,
'You must provide the resolved path value. Use env#create() to create a new investigator.'
);
this.env = this.options.env;
this.resolved = this.options.resolved;
// Ensure the environment support features this @whodunit/environment version require.
require('@whodunit/environment').enforceUpdate(this.env);
this.description = this.description || '';
this.async = () => () => {};
this.fs = FileEditor.create(this.env.sharedFs);
this.conflicter = new Conflicter(this.env.adapter, this.options.force);
// Mirror the adapter log method on the investigator.
//
// example:
// this.log('foo');
// this.log.error('bar');
this.log = this.env.adapter.log;
// Determine the app root
this.contextRoot = this.env.cwd;
let rootPath = findUp.sync('.pi-rc.json', {
cwd: this.env.cwd
});
rootPath = rootPath ? path.dirname(rootPath) : this.env.cwd;
if (rootPath !== this.env.cwd) {
this.log(
[
'',
'Just found a `.pi-rc.json` in a parent directory.',
'Setting the project root at: ' + rootPath
].join('\n')
);
this.destinationRoot(rootPath);
}
this._globalConfig = this._getGlobalStorage();
}
_getConclusions() {
const Conclusion = require("./conclusion");
const conclusions = {};
const dir = path.dirname(this.resolved);
const conclusionInfo = require(path.join(dir, 'conclusions'));
Object.keys(conclusionInfo)
.forEach(key => conclusions[key] = new Conclusion(conclusionInfo[key], this.props));
return conclusions;
}
_getInvestigations() {
const dir = path.dirname(this.resolved);
const investigationClasses = require(path.join(dir, 'investigations'));
const investigations = {};
Object.keys(investigationClasses)
.forEach(key => {
investigations[key] = new investigationClasses[key](key, this.props, this.env);
});
return investigations;
}
/*
* Prompt user to answer questions. The signature of this method is the same as {@link https://github.com/SBoudrias/Inquirer.js Inquirer.js}
*
* On top of the Inquirer.js API, you can provide a `{cache: true}` property for
* every question descriptor. When set to true, whodunit will store/fetch the
* user's answers as defaults.
*
* @param {array} questions Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation}
* @return {Promise}
*/
prompt(questions) {
questions = promptSuggestion.prefillQuestions(this._globalConfig, questions);
return this.env.adapter.prompt(questions).then(answers => {
if (!this.options['skip-cache'] && !this.options.skipCache) {
promptSuggestion.storeAnswers(this._globalConfig, questions, answers, false);
}
return answers;
});
}
/*
* Starts you initial investigation.
*
* Calls to your environment adapter to conduct
* your investigation. You initial investigation will
* be started and the environment will conduct
* the next investigations depending on the outcomes
* until it arrives at a conclusion. These are run
* synchronousley
*
* @param {Investigation} investigation to start
* @return {Promise}
*/
start(investigation) {
return this.env.adapter.start(investigation);
}
/**
* Adds an option to the set of investigator expected options, only used to
* generate investigator usage. By default, investigators get all the cli options
* parsed by nopt as a `this.options` hash object.
*
* ### Options:
*
* - `description` Description for the option
* - `type` Either Boolean, String or Number
* - `alias` Option name alias (example `-h` and --help`)
* - `default` Default value
* - `hide` Boolean whether to hide from help
*
* @param {String} name
* @param {Object} config
*/
option(name, config) {
config = config || {};
// Alias default to defaults for backward compatibility.
if ('defaults' in config) {
config.default = config.defaults;
}
config.description = config.description || config.desc;
_.defaults(config, {
name,
description: 'Description for ' + name,
type: Boolean,
hide: false
});
// Check whether boolean option is invalid (starts with no-)
const boolOptionRegex = /^no-/;
if (config.type === Boolean && name.match(boolOptionRegex)) {
const simpleName = name.replace(boolOptionRegex, '');
return this.emit(
'error',
new Error(
[
`Option name ${chalk.yellow(name)} cannot start with ${chalk.red('no-')}\n`,
`Option name prefixed by ${chalk.yellow('--no')} are parsed as implicit`,
` boolean. To use ${chalk.yellow('--' + name)} as an option, use\n`,
chalk.cyan(` this.option('${simpleName}', {type: Boolean})`)
].join('')
)
);
}
if (this._options[name] === null || this._options[name] === undefined) {
this._options[name] = config;
}
this.parseOptions();
return this;
}
/**
* Adds an argument to the class and creates an attribute getter for it.
*
* Arguments are different from options in several aspects. The first one
* is how they are parsed from the command line, arguments are retrieved
* based on their position.
*
* Besides, arguments are used inside your code as a property (`this.argument`),
* while options are all kept in a hash (`this.options`).
*
* ### Options:
*
* - `description` Description for the argument
* - `required` Boolean whether it is required
* - `optional` Boolean whether it is optional
* - `type` String, Number, Array, or Object
* - `default` Default value for this argument
*
* @param {String} name
* @param {Object} config
*/
argument(name, config) {
config = config || {};
// Alias default to defaults for backward compatibility.
if ('defaults' in config) {
config.default = config.defaults;
}
config.description = config.description || config.desc;
_.defaults(config, {
name,
required: config.default === null || config.default === undefined,
type: String
});
this._arguments.push(config);
this.parseOptions();
return this;
}
parseOptions() {
const minimistDef = {
string: [],
boolean: [],
alias: {},
default: {}
};
_.each(this._options, option => {
if (option.type === Boolean) {
minimistDef.boolean.push(option.name);
if (!('default' in option) && !option.required) {
minimistDef.default[option.name] = EMPTY;
}
} else {
minimistDef.string.push(option.name);
}
if (option.alias) {
minimistDef.alias[option.alias] = option.name;
}
// Only apply default values if we don't already have a value injected from
// the runner
if (option.name in this._initOptions) {
minimistDef.default[option.name] = this._initOptions[option.name];
} else if (option.alias && option.alias in this._initOptions) {
minimistDef.default[option.name] = this._initOptions[option.alias];
} else if ('default' in option) {
minimistDef.default[option.name] = option.default;
}
});
const parsedOpts = minimist(this._args, minimistDef);
// Parse options to the desired type
_.each(parsedOpts, (option, name) => {
// Manually set value as undefined if it should be.
if (option === EMPTY) {
parsedOpts[name] = undefined;
return;
}
if (this._options[name] && option !== undefined) {
parsedOpts[name] = this._options[name].type(option);
}
});
// Parse positional arguments to valid options
this._arguments.forEach((config, index) => {
let value;
if (index >= parsedOpts._.length) {
if (config.name in this._initOptions) {
value = this._initOptions[config.name];
} else if ('default' in config) {
value = config.default;
} else {
return;
}
} else if (config.type === Array) {
value = parsedOpts._.slice(index, parsedOpts._.length);
} else {
value = config.type(parsedOpts._[index]);
}
parsedOpts[config.name] = value;
});
// Make the parsed options available to the instance
Object.assign(this.options, parsedOpts);
this.args = parsedOpts._;
this.arguments = parsedOpts._;
// Make sure required args are all present
this.checkRequiredArgs();
}
checkRequiredArgs() {
// If the help option was provided, we don't want to check for required
// arguments, since we're only going to print the help message anyway.
if (this.options.help) {
return;
}
// Bail early if it's not possible to have a missing required arg
if (this.args.length > this._arguments.length) {
return;
}
this._arguments.forEach((config, position) => {
// If the help option was not provided, check whether the argument was
// required, and whether a value was provided.
if (config.required && position >= this.args.length) {
return this.emit(
'error',
new Error(`Did not provide required argument ${chalk.bold(config.name)}!`)
);
}
});
}
/**
* Runs the investigator, scheduling prototype methods on a run queue. Method names
* will determine the order each method is run. Methods without special names
* will run in the default queue.
*
* Any method named `constructor` and any methods prefixed by a `_` won't be scheduled.
*
* You can also supply the arguments for the method to be invoked. If none are
* provided, the same values used to initialize the invoker are used to
* initialize the invoked.
*
* @param {Function} [cb] Deprecated: prefer to use the promise interface
* @return {Promise} Resolved once the process finish
*/
run(cb) {
const promise = new Promise((resolve, reject) => {
const self = this;
this._running = true;
this.emit('run');
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
const validMethods = methods.filter(methodIsValid);
assert(
validMethods.length,
'This Investigator is empty. Add at least one method for it to run.'
);
this.env.runLoop.once('end', () => {
this.emit('end');
//resolve();
});
// Ensure a prototype method is a candidate run by default
function methodIsValid(name) {
return name.charAt(0) !== '_' && name !== 'constructor';
}
function addMethod(method, methodName, queueName) {
queueName = queueName || 'default';
debug(`Queueing ${methodName} in ${queueName}`);
self.env.runLoop.add(queueName, completed => {
debug(`Running ${methodName}`);
self.emit(`method:${methodName}`);
runAsync(function() {
if(methodName === "investigate") {
self.conclusions = self._getConclusions();
self.investigations = self._getInvestigations();
}
self.async = () => this.async();
return method.apply(self, self.args);
})()
.then(completed)
.catch(err => {
debug(`An error occured while running ${methodName}`, err);
// Ensure we emit the error event outside the promise context so it won't be
// swallowed when there's no listeners.
setImmediate(() => {
self.emit('error', err);
reject(err);
});
});
});
}
function addInQueue(name) {
const item = Object.getPrototypeOf(self)[name];
const queueName = self.env.runLoop.queueNames.indexOf(name) === -1 ? null : name;
// Name points to a function; run it!
if (typeof item === 'function') {
return addMethod(item, name, queueName);
}
// Not a queue hash; stop
if (!queueName) {
return;
}
// Run each queue items
_.each(item, (method, methodName) => {
if (!_.isFunction(method) || !methodIsValid(methodName)) {
return;
}
addMethod(method, methodName, queueName);
});
}
validMethods.forEach(addInQueue);
// // Add the default conflicts handling
// this.env.runLoop.add('conflicts', done => {
// this.conflicter.resolve(err => {
// if (err) {
// this.emit('error', err);
// }
// done();
// });
// });
_.invokeMap(this._composedWith, 'run');
});
// Maintain backward compatibility with the callback function
if (_.isFunction(cb)) {
promise.then(cb, cb);
}
return promise;
}
/**
* Compose this investigator with another one.
* @param {String|Object} investigator The path to the investigator module or an object (see examples)
* @param {Object} options The options passed to the Investigator
* @return {this} This investigator
*
* @example <caption>Using a peerDependency investigator</caption>
* this.composeWith('bootstrap', { sass: true });
*
* @example <caption>Using a direct dependency investigator</caption>
* this.composeWith(require.resolve('investigator-bootstrap/app/main.js'), { sass: true });
*
* @example <caption>Passing a Investigator class</caption>
* this.composeWith({ Investigator: MyInvestigator, path: '../investigator-bootstrap/app/main.js' }, { sass: true });
*/
composeWith(investigator, options) {
let instantiatedInvestigator;
const instantiate = (Investigator, path) => {
Investigator.resolved = require.resolve(path);
Investigator.namespace = this.env.namespace(path);
return this.env.instantiate(Investigator, {
options,
arguments: options.arguments
});
};
options = options || {};
// Pass down the default options so they're correctly mirrored down the chain.
options = _.extend(
{
skipInstall: this.options.skipInstall || this.options['skip-install'],
'skip-install': this.options.skipInstall || this.options['skip-install'],
skipCache: this.options.skipCache || this.options['skip-cache'],
'skip-cache': this.options.skipCache || this.options['skip-cache'],
forceInstall: this.options.forceInstall || this.options['force-install'],
'force-install': this.options.forceInstall || this.options['force-install']
},
options
);
if (typeof investigator === 'string') {
try {
const Investigator = require(investigator); // eslint-disable-line import/no-dynamic-require
instantiatedInvestigator = instantiate(Investigator, investigator);
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
instantiatedInvestigator = this.env.create(investigator, {
options,
arguments: options.arguments
});
} else {
throw err;
}
}
} else {
assert(
investigator.Investigator,
`${chalk.red('Missing Investigator property')}\n` +
`When passing an object to Investigator${chalk.cyan(
'#composeWith'
)} include the investigator class to run in the ${chalk.cyan(
'Investigator'
)} property\n\n` +
`this.composeWith({\n` +
` ${chalk.yellow('Investigator')}: MyInvestigator,\n` +
` ...\n` +
`});`
);
assert(
typeof investigator.path === 'string',
`${chalk.red('path property is not a string')}\n` +
`When passing an object to Investigator${chalk.cyan(
'#composeWith'
)} include the path to the investigators files in the ${chalk.cyan(
'path'
)} property\n\n` +
`this.composeWith({\n` +
` ${chalk.yellow('path')}: '../my-investigator',\n` +
` ...\n` +
`});`
);
instantiatedInvestigator = instantiate(investigator.Investigator, investigator.path);
}
if (this._running) {
instantiatedInvestigator.run();
} else {
this._composedWith.push(instantiatedInvestigator);
}
return this;
}
/**
* Determine the root investigator name (the one who's extending Investigator).
* @return {String} The name of the root investigator
*/
rootInvestigatorName() {
const pkg = readPkgUp.sync({ cwd: this.resolved }).pkg;
return pkg ? pkg.name : '*';
}
/**
* Determine the root investigator version (the one who's extending Investigator).
* @return {String} The version of the root investigator
*/
rootInvestigatorVersion() {
const pkg = readPkgUp.sync({ cwd: this.resolved }).pkg;
return pkg ? pkg.version : '0.0.0';
}
/**
* Setup a globalConfig storage instance.
* @return {Storage} Global config storage
* @private
*/
_getGlobalStorage() {
const storePath = path.join(os.homedir(), '.pi-rc-global.json');
const storeName = `${this.rootInvestigatorName()}:${this.rootInvestigatorVersion()}`;
return new Storage(storeName, this.fs, storePath);
}
/**
* Write memory fs file to disk and logging results
* @param {Function} done - callback once files are written
* @private
*/
_writeFiles(done) {
const self = this;
const conflictChecker = through.obj(function(file, enc, cb) {
const stream = this;
// If the file has no state requiring action, move on
if (file.state === null) {
return cb();
}
// Config file should not be processed by the conflicter. Just pass through
const filename = path.basename(file.path);
if (filename === '.pi-rc.json' || filename === '.pi-rc-global.json') {
this.push(file);
return cb();
}
self.conflicter.checkForCollision(file.path, file.contents, (err, status) => {
if (err) {
cb(err);
return;
}
if (status === 'skip') {
delete file.state;
} else {
stream.push(file);
}
cb();
});
self.conflicter.resolve();
});
const transformStreams = this._transformStreams.concat([conflictChecker]);
this.fs.commit(transformStreams, () => {
done();
});
}
}
// Mixin the actions modules
_.extend(Investigator.prototype, require('./actions/install'));
_.extend(Investigator.prototype, require('./actions/help'));
_.extend(Investigator.prototype, require('./actions/spawn-command'));
Investigator.prototype.user = require('./actions/user');
module.exports = Investigator;