bagofcli.js

"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 };