Plato on Github
Report Home
lib/bagofcli.js
Maintainability
70.34
Lines of code
730
Difficulty
84.61
Estimated Errors
5.21
Function weight
By Complexity
By SLOC
"use strict"; import _ from "lodash"; import async from "async"; import chalk from "chalk"; import child from "child_process"; import colors from "colors"; import { program as commander } from "commander"; import fs from "fs"; import iz from "iz"; import p from "path"; import inquirer from "inquirer"; import util from "util"; import validators from "iz/lib/validators.js"; import wrench from "wrench-sui"; import yaml from "yaml-js"; /** * bagofcli module. * * @module bagofcli */ /** * Parse command line arguments and execute actions based on the specified commands. * Uses commander.js to provide -V / --version to be displayed version number, * and -h / --help to be displayed help info. * * @param {string} base Base directory where the client module is located. * Used as the root directory to read the command file and package.json file. * Ideally this is the client's `__dirname`. * @param {Object} actions Action functions for commands in the form * `{ command: { action: function () {} } }`. * The command name in `actions` is mapped to the command name specified in `commandFile`. * @param {Object} [opts] Optional settings. * @param {string} [opts.commandFile='conf/commands.json'] Relative path to the command file from `base`. * @returns {void} */ function command(base, actions, opts) { actions = actions || {}; opts = opts || {}; // commander keeps registered subcommands across invocations on program singleton, // so clear command list before registering command definitions for this invocation. commander.commands = []; const commands = JSON.parse( fs.readFileSync( p.join(base, opts.commandFile || "../conf/commands.json"), ), ), pkg = JSON.parse(fs.readFileSync(p.join(base, "../package.json"))); if (actions.commands && commands.commands) { _.each(actions.commands, function (command, name) { if (commands.commands[name]) { commands.commands[name].action = command.action; } }); } commander.version(pkg.version); if (commands.options) { _.each(commands.options, function (option) { commander.option(option.arg, option.desc, option.action); }); } _.each(commands.commands, function (command, name) { const program = commander .command(name) .description(command.desc) .allowExcessArguments(true); _.each(command.options, function (option) { program.option(option.arg, option.desc, option.action); }); if (command.action) { program.action(function (...args) { command.action(args[args.length - 1]); }); } }); _preCommand(commands.commands); commander.parse(process.argv); // NOTE: commander.args is populated by commander#parse, // hence _postCommand relies on commander#parse finishing without exiting or throwing error, // otherwise _postCommand won't be executed _postCommand(commander, commands.commands, commands.options); } // Pre-command tasks: // - if --help flag is specified, append examples after standard help output /** * Register a help handler that prints command examples after the default help output. * * @param {Object} commands Command configuration map. * @returns {void} * @private */ function _preCommand(commands) { commander.on("--help", function () { let hasExample = _.map(_.values(commands), "examples").filter((elem) => { return elem !== undefined; }); if (hasExample.length > 0) { hasExample = hasExample.reduce((a, b) => { return a.concat(b); }); } hasExample = hasExample.length > 0; if (hasExample) { console.log(" Examples:\n"); Object.keys(commands).forEach((command) => { if (!_.isEmpty(commands[command].examples)) { console.log(" %s:", command); commands[command].examples.forEach((example) => { console.log(" %s", example); }); } }); } }); } // Post-command tasks: // - if there's no command, display help then exit // - if command is unknown, display error message then exit // - if there's command, validate arguments and options then exit /** * Validate command arguments and options after commander parsing. * * @param {Object} commanderArg Commander instance after parsing. * @param {Object} commandsConfig Command configuration map. * @param {Array<Object>} [globalOptsConfig] Global option configuration list. * @returns {void} * @private */ function _postCommand(commanderArg, commandsConfig, globalOptsConfig) { const commanderArgs = commanderArg.args; // add custom validator required which checks if value is not empty validators.required = function (value) { return !validators.empty(value); }; function _validate(value, name, desc) { return function (rule) { iz({ value: value, validators: validators }); if (Object.keys(validators).indexOf(rule) !== -1) { const isValid = validators[rule](value); if (isValid === null || isValid === false) { exit( new Error( util.format("Invalid %s: <%s> must be %s", desc, name, rule), ), ); } } else { exit(new Error(util.format("Invalid %s rule: %s", desc, rule))); } }; } /** * Resolve an option value from a Commander command instance. * * Newer Commander versions expose parsed values via opts(), while older behavior * may expose values directly as command properties. This helper checks opts() * first, then falls back to direct property access for compatibility. * * @param {Object} commandArg Commander command instance. * @param {string} arg Option argument definition. * @returns {string|undefined} Parsed option value. * @private */ function _getOptionValue(commandArg, arg) { const argMatch = arg.match(/<.*?>|\[.*?\]/); if (!argMatch) { return undefined; } const name = argMatch[0].replace(/[<>\[\]]/g, ""); if (commandArg && typeof commandArg.opts === "function") { const commandArgOpts = commandArg.opts(); if (commandArgOpts && commandArgOpts[name] !== undefined) { return commandArgOpts[name]; } } return commandArg ? commandArg[name] : undefined; } if (!commanderArgs || commanderArgs.length === 0) { return; // } else if (commanderArgs.length === 1) { // // having a single arg // // which means that the command is executed without args, hence display help menu // // NOTE: this check is needed because for some reason commander.args // // also returns empty array when one of the args is an opt (-- prefixed) // if (process.argv.length === 2) { // commander.help(); // } } else { const commandName = commanderArgs[0], commandArgs = commanderArgs.slice(1, commanderArgs.length), commandConfigArgs = commandsConfig[commandName] ? commandsConfig[commandName].args : undefined, commandConfigArgsMandatory = _.filter(commandConfigArgs, function (arg) { return !arg.optional; }), commandConfigOpts = commandsConfig[commandName] ? commandsConfig[commandName].options : undefined; // Unknown command error if the command name doesn't exist in the commands configuration if (Object.keys(commandsConfig).indexOf(commandName) === -1) { exit( new Error( util.format( "Unknown command: %s, use --help for more info", commandName, ), ), ); } else if (commandConfigArgs && commandConfigArgs.length > 0) { const programName = typeof commanderArg.name === "function" ? commanderArg.name() : commanderArg._name; // display usage info when mandatory arguments don't exist // due to arguments being positional, then comparing the lengths is good enough if (commandConfigArgsMandatory.length !== commandArgs.length) { exit( new Error( util.format( "Usage: %s %s %s", programName, commandName, commandConfigArgs .map((arg) => { return util.format(arg.optional ? "[%s]" : "<%s>", arg.name); }) .join(" "), ), ), ); } else { // validate arguments as configured in commands setup for (let i = 0, ln = commandConfigArgs.length; i < ln; i += 1) { if (!commandConfigArgs[i].optional) { // first arg from Commander is the command, the rest are that command's arguments commandConfigArgs[i].rules.forEach( _validate( commanderArgs[i + 1], commandConfigArgs[i].name, "argument", ), ); } } } } // validate command opts as configured in commands setup if (commandConfigOpts) { commandConfigOpts.forEach((commandOpt) => { if (commandOpt.rules) { const name = commandOpt.arg, value = _getOptionValue(commanderArg, name); commandOpt.rules.forEach(_validate(value, name, "option")); } }); } // validate global opts as configured in commands setup if (globalOptsConfig) { globalOptsConfig.forEach((globalOptConfig) => { if (globalOptConfig.rules) { const name = globalOptConfig.arg, value = _getOptionValue(commanderArg.parent, name); globalOptConfig.rules.forEach(_validate(value, name, "option")); } }); } } } /** * Execute a one-liner command. * * The output emitted on stderr and stdout of the child process will be written to process.stdout * and process.stderr of this process. * * Fallthrough is handy in situation where there are multiple execs running in sequence/parallel, * and they all have to be executed regardless of success/error on either one of them. * * @param {string} command Command to execute. * @param {boolean} fallthrough Allow error to be camouflaged as a non-error. * @param {Function} cb Standard `cb(err, result)` callback. * @returns {void} */ function exec(command, fallthrough, cb) { execute( command, fallthrough, false, function (err, stdOutOuput, stdErrOuput, result) { // drop stdOutOuput and stdErrOuput parameters to keep exec backwards compatible. cb(err, result); }, ); } /** * Execute a one-liner command and collect the output. * * The output emitted on stderr and stdout of the child process will be * collected and passed on to the given callback. * * Fallthrough is handy in situation where there are multiple execs running in sequence/parallel, * and they all have to be executed regardless of success/error on either one of them. * * @param {string} command Command to execute. * @param {boolean} fallthrough Allow error to be camouflaged as a non-error. * @param {Function} cb `(err, stdOutOutput, stdErrOutput, result)` callback. * @returns {void} */ function execAndCollect(command, fallthrough, cb) { execute(command, fallthrough, true, cb); } // not exported /** * Execute a one-liner command. * * The output emitted on stderr and stdout of the child process will either be written to * process.stdout and process.stderr of this process or collected and passed on to the * given callback, depending on collectOutput. * * Fallthrough is handy in situation where there are multiple execs running in sequence/parallel, * and they all have to be executed regardless of success/error on either one of them. * * @param {string} command Command to execute. * @param {boolean} fallthrough Allow error to be camouflaged as a non-error. * @param {boolean} collectOutput Pass child output to the callback instead of writing it. * @param {Function} cb `(err, stdOutOutput, stdErrOutput, result)` callback. * @returns {void} * @private */ function execute(command, fallthrough, collectOutput, cb) { let collectedStdOut = ""; let collectedStdErr = ""; const _exec = child.exec(command, function (err) { let result; if (err && fallthrough) { // camouflage error to allow other execs to keep running result = err; err = null; } cb(err, collectedStdOut, collectedStdErr, result); }); _exec.stdout.on("data", function (data) { if (collectOutput) { collectedStdOut += data.toString().trim(); } else { process.stdout.write(colors.green(data.toString())); } }); _exec.stderr.on("data", function (data) { if (collectOutput) { collectedStdErr += data.toString().trim(); } else { process.stderr.write(colors.red(data.toString())); } }); } /** * Handle process exit based on the existence of error. * This is handy for command-line tools to use as the final callback. * Exit status code 1 indicates an error, exit status code 0 indicates a success. * Error message will be logged to the console. Result object is only used for convenient debugging. * Exit is also called with a second method in the signature called 'result', but it's not declared * here due to not being used when there is no error and this function simply exits with code 0 * * @param {Error} err Error object existence indicates the occurrence of an error. * @returns {void} */ function exit(err) { if (err) { console.error(colors.red(err.message || JSON.stringify(err))); process.exit(1); } else { process.exit(0); } } /** * A higher order function that returns a process exit callback, * with error and success callbacks to handle error and result accordingly. * Exit status code 1 indicates an error, exit status code 0 indicates a success. * * @param {Function} [errorCb] Error callback, defaults to logging to console error. * @param {Function} [successCb] Success callback, defaults to logging to console log. * @returns {Function} Exit callback. */ function exitCb(errorCb, successCb) { if (!errorCb) { errorCb = function (err) { console.error(colors.red(err.message || JSON.stringify(err))); }; } if (!successCb) { successCb = function (result) { console.log(colors.green(result.toString())); }; } return function (err, result) { if (err) { errorCb(err); process.exit(1); } else { successCb(result); process.exit(0); } }; } /** * Get an array of files contained in specified items. * When a directory is specified, all files contained within that directory and its sub-directories will be included. * * @param {Array<string>} items Array of files and/or directories. * @param {Object} [opts] Optional settings. * @param {string} [opts.match] Regular expression to match against the file name. * @returns {Array<string>} All files. */ function files(items, opts) { opts = opts || {}; const data = []; function addMatch(item) { if ( opts.match === undefined || (opts.match && item.match(new RegExp(opts.match))) ) { data.push(item); } } items.forEach((item) => { const stat = fs.statSync(item); if (stat.isFile()) { addMatch(item); } else if (stat.isDirectory()) { const _items = wrench.readdirSyncRecursive(item); _items.forEach((_item) => { _item = p.join(item, _item); if (fs.statSync(_item).isFile()) { addMatch(_item); } }); } }); return data; } /** * Lookup config values for the specified keys in the following order: * - environment variable * - if optional file is specified, then check for the value inside the file * file type depends on extension file * - if optional enablePrompt is set to true, then prompt the user for config value * * @param {Array<string>|string} keys Configuration keys to look up. * @param {Object} opts Optional settings. * @param {string} [opts.file] File name to look up configuration values from. * @param {boolean} [opts.prompt=false] Prompt the user for missing configuration values. * @param {Function} cb Standard `cb(err, result)` callback. * @returns {void} */ function lookupConfig(keys, opts, cb) { if (!Array.isArray(keys)) { keys = [keys]; } opts.prompt = opts.prompt || false; // parse values from configuration file if supplied const file = {}; if (opts.file) { const content = this.lookupFile(opts.file); if (opts.file.match(/\.json$/)) { file.json = JSON.parse(content); } else if (opts.file.match(/\.ya?ml$/)) { file.yaml = yaml.load(content); } else { throw new Error("Configuration file extension is not supported"); } } function lookup(key, cb) { if (process.env[key]) { cb(process.env[key]); } else if (file.json) { cb(file.json[key]); } else if (file.yaml) { cb(file.yaml[key]); } else { cb(undefined); } } // lookup for values in environment variables and configuration file const tasks = {}; keys.forEach((key) => { tasks[key] = function (cb) { lookup(key, function (result) { cb(null, result); }); }; }); async.parallel(tasks, function (err, results) { if (opts.prompt) { // prompt users for any keys that don't yet have any value from // environment variables and configuration file const promptQuestions = []; keys.forEach((key) => { if (results[key] === undefined) { const promptQuestion = { name: key, message: key, default: false, }; if (key.toLowerCase().indexOf("password") >= 0) { promptQuestion.type = "input"; } else { promptQuestion.type = "password"; } promptQuestions.push(promptQuestion); } }); if (promptQuestions.length > 0) { inquirer.prompt(promptQuestions).then((promptAnswers) => { results = _.extend(results, promptAnswers); cb(err, results); }); } else { cb(err, results); } } else { cb(err, results); } }); } /** * Synchronously read file based on these rules: * - if path is absolute, then check file at absolute path first * - if path is relative, then check file at current working directory * - if none of the above exists, check file at user home directory * - if none exists, throw an error * This allows simple file lookup which allows various locations. * * @param {string} file File name to read. * @param {Object} [opts] Optional settings. * @param {string} [opts.platform] Platform override for tests. * @returns {string} File content. */ function lookupFile(file, opts) { opts = opts || {}; let data; const platform = opts.platform || process.platform, baseDir = file.match(/^\//) ? p.dirname(file) : process.cwd(), homeDir = process.env[platform === "win32" ? "USERPROFILE" : "HOME"], files = _.map([baseDir, homeDir], function (dir) { return p.join(dir, file.match(/^\//) ? p.basename(file) : file); }); for (let i = 0, ln = files.length; i < ln; i += 1) { try { data = fs.readFileSync(files[i]); break; } catch (err) { // do nothing when unable to read file } } if (data) { return data; } else { throw new Error("Unable to lookup file in " + files.join(", ")); } } /** * Execute a command with an array of arguments. * E.g. command: make, arguments: -f somemakefile target1 target2 target3 * will be executed as: make -f somemakefile target1 target2 target3 * NOTE: process.stdout.write and process.stderr.write are used because console.log adds a newline * * @param {string} command Command to execute. * @param {Array<string>} args Command arguments. * @param {Function} cb Standard `cb(err, result)` callback. * @returns {void} */ function spawn(command, args, cb) { const _spawn = child.spawn(command, args); _spawn.stdout.on("data", function (data) { process.stdout.write(colors.green(data.toString())); }); _spawn.stderr.on("data", function (data) { process.stderr.write(colors.red(data.toString())); }); _spawn.on("exit", function (code) { cb(code !== 0 ? new Error(code) : undefined, code); }); } /** * Displays log step heading message on the console. * * @param {string} message Heading message to be displayed. * @param {Object} [opts] Optional settings. * @param {Array<string>} [opts.labels] Labels to be displayed before the message. * @returns {void} */ function logStepHeading(message, opts) { opts = opts || {}; const messageText = chalk.bold.cyan(message); if (opts.labels && Array.isArray(opts.labels)) { const labelsText = chalk.bgMagenta(opts.labels.join(" | ")); console.log("%s %s", labelsText, messageText); } else { console.log("%s", messageText); } } /** * Displays log step item success message on the console. * * @param {string} message Success message to be displayed. * @param {Object} [opts] Optional settings. * @param {Array<string>} [opts.labels] Labels to be displayed before the message. * @returns {void} */ function logStepItemSuccess(message, opts) { opts = opts || {}; const messageText = chalk.green(message); if (opts.labels && Array.isArray(opts.labels)) { const labelsText = chalk.bgMagenta(opts.labels.join(" | ")); console.log(" * %s %s", labelsText, messageText); } else { console.log(" * %s", messageText); } } /** * Displays log step item warning message on the console. * * @param {string} message Warning message to be displayed. * @param {Object} [opts] Optional settings. * @param {Array<string>} [opts.labels] Labels to be displayed before the message. * @returns {void} */ function logStepItemWarning(message, opts) { opts = opts || {}; const messageText = chalk.yellow(message); if (opts.labels && Array.isArray(opts.labels)) { const labelsText = chalk.bgMagenta(opts.labels.join(" | ")); console.log(" * %s %s", labelsText, messageText); } else { console.log(" * %s", messageText); } } /** * Displays log step error item error message on the console. * * @param {string} message Error message to be displayed. * @param {Object} [opts] Optional settings. * @param {Array<string>} [opts.labels] Labels to be displayed before the message. * @returns {void} */ function logStepItemError(message, opts) { opts = opts || {}; const messageText = chalk.red(message); if (opts.labels && Array.isArray(opts.labels)) { const labelsText = chalk.bgMagenta(opts.labels.join(" | ")); console.error(" * %s %s", labelsText, messageText); } else { console.error(" * %s", messageText); } } const exports = { command: command, _preCommand: _preCommand, _postCommand: _postCommand, exec: exec, execAndCollect: execAndCollect, exit: exit, exitCb: exitCb, files: files, lookupConfig: lookupConfig, lookupFile: lookupFile, spawn: spawn, logStepHeading: logStepHeading, logStepItemSuccess: logStepItemSuccess, logStepItemWarning: logStepItemWarning, logStepItemError: logStepItemError, }; export { exports as default };