Plato on Github
Report Home
lib/bagofcli.js
Maintainability
71.42
Lines of code
1129
Difficulty
86.70
Estimated Errors
7.71
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 { program as commander, Help, InvalidArgumentError, Option, } from "commander"; import fs from "fs"; import p from "path"; import inquirer from "inquirer"; import util from "util"; import v from "validator"; import yaml from "yaml-js"; /** * A bag of utility functions for building command-line tools: * command and option parsing backed by [commander](https://www.npmjs.com/package/commander), * child process execution, process exit handling, file and configuration lookup, * and coloured console logging. * * @module bagofcli * @example * import bag from "bagofcli"; * * const actions = { * commands: { * hello: { * action: function (command) { * console.log("Hello %s", command.args[0]); * }, * }, * }, * }; * bag.command(import.meta.dirname, actions); */ /** * @import { * Actions as ActionsType, * CommandArg as CommandArgType, * ExecAndCollectCallback, * ExecCallback, * ExitCallback, * ExitError, * ExitErrorCallback, * ExitSuccessCallback, * LookupConfigCallback, * SpawnCallback, * } from "./types.js" */ /** * Object passed to a command action handler, with these properties: * - `args` (`Array<string>`): positional arguments, in the order they were passed in * - `name` (`Function`): function that returns the name of the invoked command * * Besides those, every parsed option is available as a * direct property keyed by the option's long flag in camel case * (e.g. `--some-opt <value>` is available as `someOpt`). * This covers both the command's own options and the global options. * When a global option and a command option share the same name, * the global option value takes precedence. * * The TypeScript type is defined in types.d.ts. * * @typedef {CommandArgType} CommandArg */ /** * Action handlers for commands in the form * `{ commands: { <command>: { action: function (command) {} } } }`. * The command name is mapped to the command name specified in the command file. * Each action handler is called with a {@link module:bagofcli~CommandArg}. * * The TypeScript type is defined in types.d.ts. * * @typedef {ActionsType} Actions */ /** * Parse command line arguments and execute actions based on the specified commands. * Uses commander to provide `-V` / `--version` flags to display the version number, * and `-h` / `--help` flags to display the help info. * * Commands, their positional arguments, and the global and command options * are declared in the command file, while `actions` provides the action handler * of each command. * * A command without declared args accepts any positional arguments by default, * a command declared with `allowExcessArguments: false` rejects unexpected ones while parsing, * before the action handler is called, and the process exits with status code 1. * * An option declared with `choices` only accepts one of the listed values, * commander rejects any other value while parsing, before the action handler is called, * and the process exits with status code 1. * Likewise, an option declared with `min`, `max`, or `integer` only accepts a number * (or an integer when `integer` is true) within the inclusive range. * * Arguments and options are validated against the rules declared in the command file * before the action handler is called, the process exits with status code 1 * without calling the action handler when any of them is invalid. * * @alias module:bagofcli.command * @param {string} base Base directory where the client module is located. * Used as the base directory to locate the command file and the package.json file, * which is expected to be at `../package.json` relative to `base`. * Ideally this is the directory of the client's module, e.g. `lib`. * @param {Actions} [actions] Action handlers for commands, see {@link module:bagofcli~Actions}. * @param {Object} [opts] Optional settings. * @param {string} [opts.commandFile='../conf/commands.json'] Relative path to the command file from `base`. * @returns {void} * @example * // conf/commands.json * // { * // "options": [ * // { "arg": "-f, --file <file>", "desc": "A global option" } * // ], * // "commands": { * // "greet": { * // "desc": "Greet someone", * // "args": [{ "name": "name", "rules": ["required"] }], * // "options": [{ "arg": "-s, --shout", "desc": "A command option" }] * // } * // } * // } * bag.command(import.meta.dirname, { * commands: { * greet: { * action: function (command) { * // command.args[0] is the name positional argument * // command.shout is the command option * // command.file is the global option * }, * }, * }, * }); */ 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); // subcommands inherit the help configuration when they're created, // so this has to be configured before registering the commands commander.configureHelp({ optionDescription: _optionDescription }); if (commands.options) { _.each(commands.options, function (option) { _addOption(commander, option); }); } // whether _postCommand validation has already been run by an action handler let isValidated = false; _.each(commands.commands, function (command, name) { const allowExcessArguments = command.allowExcessArguments !== false, program = commander .command(name) .description(command.desc) .allowExcessArguments(allowExcessArguments); // commander counts excess arguments against its registered arguments, // so register the declared args, otherwise every positional argument would be excess. // They're registered as optional, so a missing mandatory argument // is still reported with the usage message by _postCommand. if (!allowExcessArguments) { _.each(command.args, function (arg) { program.argument(util.format("[%s]", arg.name)); }); } _.each(command.options, function (option) { _addOption(program, option); }); if (command.action) { program.action(function (...args) { const commandArg = this; // Validate arguments and options before calling the action handler, // so that the action handler isn't called when any of them is invalid. // commander.args and the subcommand's .args are already populated by // the time commander calls the action. _postCommand(commander, commands.commands, commands.options); isValidated = true; // Pass a plain object with parsed options as direct properties (so flags // aren't silently ignored, see issue #20), while still carrying over // .args (positional arguments) and .name() from the Command instance, // since bagofcli commands don't register positional args with commander - // args and name() are otherwise only reachable there. // Global options are included alongside the command's own options, so // that action handlers don't have to reach for the commander program. command.action( Object.assign(commandArg.optsWithGlobals(), { args: commandArg.args, name: () => commandArg.name(), }), ); }); } }); _preCommand(commands.commands); commander.parse(process.argv); // Validate when no action handler was called, e.g. no command was specified, // or the command doesn't have any action handler. // 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 if (!isValidated) { _postCommand(commander, commands.commands, commands.options); } } /** * Register an option, as configured in the command file, on a commander program. * * An option without `choices`, `min`, `max`, or `integer` is registered via commander's option(), * with `action` passed through as either the argument parser function or the default value. * Otherwise the option is registered via addOption() using commander's Option, * which rejects an invalid value while parsing (only when the option is supplied): * - `choices` rejects a value that isn't one of the choices, and lists the choices in the help output * - `min`, `max`, and `integer` reject a value that isn't a number (or an integer when `integer` is true), * or is outside the inclusive range, the value is passed on unchanged as a string * * @param {Object} program Commander program or command to register the option on. * @param {Object} option Option configuration with `arg`, `desc`, and optional `action`, * `choices`, `min`, `max`, and `integer`. * @returns {void} * @private */ function _addOption(program, option) { const hasRange = option.min !== undefined || option.max !== undefined || option.integer === true; if (!option.choices && !hasRange) { program.option(option.arg, option.desc, option.action); return; } const commanderOption = new Option(option.arg, option.desc); if (option.choices) { commanderOption.choices(option.choices); } if (typeof option.action !== "function") { commanderOption.default(option.action); } if (hasRange) { commanderOption.rangeDescription = _rangeDescription(option); } if (hasRange || typeof option.action === "function") { // choices() sets its own argument parser to check the value, // so check the value against the choices before the range, // then pass the value to action const choicesParser = commanderOption.parseArg; const name = commanderOption.long || commanderOption.short; commanderOption.argParser(function (value, previous) { let result = choicesParser ? choicesParser(value, previous) : value; if (hasRange) { _checkRange(value, name, option); } if (typeof option.action === "function") { result = option.action(value, previous); } return result; }); } program.addOption(commanderOption); } /** * Describe an option's numeric range for the help output, * e.g. `integer, range: 0 to 65535`, `min: 0`, or `max: 10`. * * @param {Object} option Option configuration with optional `min`, `max`, and `integer`. * @returns {string} Range description. * @private */ function _rangeDescription(option) { const info = []; if (option.integer === true) { info.push("integer"); } if (option.min !== undefined && option.max !== undefined) { info.push(util.format("range: %s to %s", option.min, option.max)); } else if (option.min !== undefined) { info.push(util.format("min: %s", option.min)); } else if (option.max !== undefined) { info.push(util.format("max: %s", option.max)); } return info.join(", "); } /** * Help configuration override for commander's option description, * which adds the option's range description (if any) to commander's * extra info, e.g. `Port number (integer, range: 0 to 65535, default: "8080")`. * * @param {Object} option Commander Option. * @returns {string} Option description for the help output. * @this {Help} * @private */ function _optionDescription(option) { if (!option.rangeDescription) { return Help.prototype.optionDescription.call(this, option); } // describe the option without its description to get only commander's // extra info, e.g. `(choices: "1", "5", default: "1")`, or an empty string const extraDescription = Help.prototype.optionDescription.call( this, Object.assign(Object.create(option), { description: "" }), ), extraInfo = [option.rangeDescription]; if (extraDescription) { extraInfo.push(extraDescription.slice(1, -1)); } const rangeDescription = util.format("(%s)", extraInfo.join(", ")); return option.description ? util.format("%s %s", option.description, rangeDescription) : rangeDescription; } /** * Check that an option value is a number (or an integer when `integer` is true) * within the inclusive range between `min` and `max`, either of which is optional. * * @param {string} value Option value to check. * @param {string} name Option flag, used in the error message. * @param {Object} option Option configuration with optional `min`, `max`, and `integer`. * @returns {void} * @throws {InvalidArgumentError} When the value is not a number, not an integer, or out of range. * @private */ function _checkRange(value, name, option) { const str = _toStr(value), isNumber = option.integer === true ? v.isInt(str) : v.isNumeric(str), number = Number(str); if ( !isNumber || (option.min !== undefined && number < option.min) || (option.max !== undefined && number > option.max) ) { let expected = option.integer === true ? "an integer" : "a number"; if (option.min !== undefined && option.max !== undefined) { expected += util.format(" between %s and %s", option.min, option.max); } else if (option.min !== undefined) { expected += util.format(" greater than or equal to %s", option.min); } else if (option.max !== undefined) { expected += util.format(" less than or equal to %s", option.max); } throw new InvalidArgumentError( util.format("%s must be %s.", name, expected), ); } } /** * Pre-command tasks, to be run before commander parses the command line arguments. * Register a help handler that prints command examples after the default help output * when `--help` flag is specified. * * @param {Object} commands Command configuration map, keyed by command name. * @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); }); } }); } }); } /** * Coerce a value to a string for validator (https://npmjs.com/package/validator), * which only operates on strings. Command line argument/option values are always * strings already, this just guards against undefined/null (e.g. a missing * optional argument). * * @param {*} value Value to coerce. * @returns {string} String form of value, or an empty string for undefined/null. * @private */ function _toStr(value) { return value === undefined || value === null ? "" : String(value); } /** * Argument/option validators, keyed by the rule name as referenced in a * commands.json `rules` array. Backed by validator rather than iz, which is * unmaintained - rule names are unchanged, so existing commands.json files * keep working as-is. * * Only rules callable with just the value being validated are supported here, * since commands.json's `rules` array has never had a way to pass extra * parameters (e.g. a length or pattern) through to a rule. * * @constant * @type {Object<string, Function>} * @private */ const validators = { required: (value) => !v.isEmpty(_toStr(value)), empty: (value) => v.isEmpty(_toStr(value)), blank: (value) => typeof value === "string" && v.isEmpty(value), string: (value) => typeof value === "string", number: (value) => v.isNumeric(_toStr(value)), int: (value) => v.isInt(_toStr(value)), // Unlike validator's own isDecimal (which also accepts whole numbers), // iz's decimal rule specifically required a fractional part - preserve that. decimal: (value) => v.isDecimal(_toStr(value)) && !v.isInt(_toStr(value)), boolean: (value) => typeof value === "boolean" || v.isBoolean(_toStr(value)), email: (value) => v.isEmail(_toStr(value)), alphaNumeric: (value) => v.isAlphanumeric(_toStr(value)), anArray: (value) => Array.isArray(value), date: (value) => v.isDate(_toStr(value)), ip: (value) => v.isIP(_toStr(value)), cc: (value) => v.isCreditCard(_toStr(value)), phone: (value) => v.isMobilePhone(_toStr(value), "en-US"), postal: (value) => v.isPostalCode(_toStr(value), "US"), ssn: (value) => { const digits = _toStr(value).replace(/[^0-9]/g, ""); return v.isInt(digits) && digits.length === 9; }, }; /** * Post-command tasks, to be run after commander parses the command line arguments, * either right before the command's action handler is called, * or after parsing when no action handler is called. * Validate command arguments and options: * - if there's no command, return without validating anything * - if command is unknown, log an error message then exit * - if the number of arguments doesn't match the mandatory arguments, log the usage then exit * - if any argument or option doesn't satisfy its rules, log an error message then exit * * @param {Object} commanderArg Top-level commander program after parsing. * @param {Object} commandsConfig Command configuration map, keyed by command name. * @param {Array<Object>} [globalOptsConfig] Global option configuration list. * @returns {void} * @private */ function _postCommand(commanderArg, commandsConfig, globalOptsConfig) { const commanderArgs = commanderArg.args; /** * Create a function that validates a value against a rule, * which exits the process with an error when the value is invalid * or when the rule is not supported. * * @param {*} value Value to validate. * @param {string} name Argument name or option definition, used in the error message. * @param {string} desc Either `argument` or `option`, used in the error message. * @returns {Function} Function that validates the value against the specified rule name. * @private */ function _validate(value, name, desc) { return function (rule) { 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. * * The option value is looked up using the name of the value placeholder * in the option definition (e.g. `someOpt` in `-s, --some-opt <someOpt>`), * an option definition without any value placeholder resolves to undefined. * * Newer commander versions expose parsed values via opts(), while older behaviour * 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 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 (typeof commandArg.opts === "function") { const commandArgOpts = commandArg.opts(); if (commandArgOpts && commandArgOpts[name] !== undefined) { return commandArgOpts[name]; } } return commandArg[name]; } if (!commanderArgs || commanderArgs.length === 0) { return; } else { const commandName = commanderArgs[0], // Prefer the actual invoked subcommand's own .args over slicing the // top-level commanderArgs: the top-level parse doesn't know about options // registered only on the subcommand, so unconsumed option tokens (and their // values) leak into commanderArgs and inflate the count, causing a false // "Usage: ..." error even though the subcommand parsed everything correctly. subCommand = Array.isArray(commanderArg.commands) && commanderArg.commands.find(function (cmd) { const name = typeof cmd.name === "function" ? cmd.name() : cmd._name; return name === commandName; }), commandArgs = subCommand ? subCommand.args : 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) { commandConfigArgs[i].rules.forEach( _validate(commandArgs[i], commandConfigArgs[i].name, "argument"), ); } } } } // validate command opts as configured in commands setup // command opts are parsed by the invoked subcommand, hence its values // have to be retrieved from the subcommand rather than the top-level commander if (commandConfigOpts) { commandConfigOpts.forEach((commandOpt) => { if (commandOpt.rules) { const name = commandOpt.arg, value = _getOptionValue(subCommand || commanderArg, name); commandOpt.rules.forEach(_validate(value, name, "option")); } }); } // validate global opts as configured in commands setup // global opts are parsed by the top-level commander, which has no parent if (globalOptsConfig) { globalOptsConfig.forEach((globalOptConfig) => { if (globalOptConfig.rules) { const name = globalOptConfig.arg, value = _getOptionValue(commanderArg, 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. * * @alias module:bagofcli.exec * @param {string} command Command to execute. * @param {boolean} fallthrough Allow error to be camouflaged as a non-error, * in which case the error is passed as the result. * @param {ExecCallback} 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. * * @alias module:bagofcli.execAndCollect * @param {string} command Command to execute. * @param {boolean} fallthrough Allow error to be camouflaged as a non-error, * in which case the error is passed as the result. * @param {ExecAndCollectCallback} cb `cb(err, stdOutOutput, stdErrOutput, result)` callback. * @returns {void} */ function execAndCollect(command, fallthrough, cb) { execute(command, fallthrough, true, cb); } /** * Execute a one-liner command. * This function is not exported, it backs {@link module:bagofcli.exec} * and {@link module:bagofcli.execAndCollect}. * * 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 {ExecAndCollectCallback} cb `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(chalk.green(data.toString())); } }); _exec.stderr.on("data", function (data) { if (collectOutput) { collectedStdErr += data.toString().trim(); } else { process.stderr.write(chalk.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. * * This function can be used as a standard `cb(err, result)` callback, * the result is not declared in the signature because it is not used, * the process simply exits with status code 0 when there is no error. * * @alias module:bagofcli.exit * @param {?ExitError} [err] Error object, its existence indicates the occurrence of an error, * a non-Error object is logged as JSON. * @returns {void} */ function exit(err) { if (err) { console.error(chalk.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. * * @alias module:bagofcli.exitCb * @template T * @param {ExitErrorCallback} [errorCb] Error callback in the form `errorCb(err)`, * defaults to logging the error message to console error. * @param {ExitSuccessCallback<T>} [successCb] Success callback in the form `successCb(result)`, * defaults to logging the result to console log. * @returns {ExitCallback<T>} Standard `cb(err, result)` exit callback. */ function exitCb(errorCb, successCb) { if (!errorCb) { errorCb = function (err) { console.error(chalk.red(err.message || JSON.stringify(err))); }; } if (!successCb) { successCb = function (result) { console.log(chalk.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. * * @alias module:bagofcli.files * @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 path, * only the matching files will be included. * @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 = fs.readdirSync(item, { recursive: true }); _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, * the file type is determined by the file extension (.json, .yaml, or .yml) * - if optional prompt is set to true, then prompt the user for the config values * that are not found in the environment variable and the file * the value typed in by the user is masked when the key contains the word password * (case insensitive) * * The file is located using {@link module:bagofcli.lookupFile}. * * @alias module:bagofcli.lookupConfig * @param {Array<string>|string} keys Configuration keys to look up. * @param {Object} opts 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 {LookupConfigCallback} cb Standard `cb(err, result)` callback, * the result is an object of configuration values keyed by configuration key. * @returns {void} * @throws {Error} When the configuration file extension is not supported. */ 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, }; // mask the value typed in by the user when the key is a password if (key.toLowerCase().indexOf("password") >= 0) { promptQuestion.type = "password"; } else { promptQuestion.type = "input"; } 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. * * @alias module:bagofcli.lookupFile * @param {string} file File name to read. * @param {Object} [opts] Optional settings. * @param {string} [opts.platform] Platform override, defaults to the current process platform. * @returns {Buffer} File content. * @throws {Error} When the file can not be read from any of the locations. */ 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. * * The output emitted on stdout and stderr of the child process will be written to * process.stdout and process.stderr of this process, without any additional newline. * * @alias module:bagofcli.spawn * @param {string} command Command to execute. * @param {Array<string>} args Command arguments. * @param {SpawnCallback} cb Standard `cb(err, result)` callback, * the result is the exit code, and the error exists when the exit code is not 0. * @returns {void} * @example * // will be executed as: make -f somemakefile target1 target2 target3 * bag.spawn("make", ["-f", "somemakefile", "target1", "target2", "target3"], cb); */ function spawn(command, args, cb) { const _spawn = child.spawn(command, args); // process.stdout.write and process.stderr.write are used because console.log adds a newline _spawn.stdout.on("data", function (data) { process.stdout.write(chalk.green(data.toString())); }); _spawn.stderr.on("data", function (data) { process.stderr.write(chalk.red(data.toString())); }); _spawn.on("exit", function (code) { cb(code !== 0 ? new Error(code) : undefined, code); }); } /** * Display log step heading message on the console. * * @alias module:bagofcli.logStepHeading * @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); } } /** * Display log step item success message on the console. * * @alias module:bagofcli.logStepItemSuccess * @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); } } /** * Display log step item warning message on the console. * * @alias module:bagofcli.logStepItemWarning * @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); } } /** * Display log step item error message on the console error. * * @alias module:bagofcli.logStepItemError * @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, _addOption: _addOption, _optionDescription: _optionDescription, _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 };