particle-cli
Version:
Simple Node commandline application for working with your Particle devices and using the Particle Cloud
748 lines (645 loc) • 20.2 kB
JavaScript
/**
* Builds the command line parser based on yargs.
*
* The commands are arranged as a hierarchy, with the root representing invocation with no commands, children
* under the root as the first level of commands, their children are 2nd level commands and so on.
*
* The immediate child commands of the root are built via the `setup()` callback on the root command configuration,
* after `run()` is called on the root command. At this point, the command line has not yet been parsed.
*
* Once the first set of commands have been built, the parser is ran. If the command line matches one of the commands,
* the `run()` method for the command is invoked. This registers options, help text, examples and subcommands under
* the first level and the command string parsed again, causing subsequent nested commands to be registered and parsed.
*
* The first parser handles the top-level command, the next level handles sub-commands
* of the top level and so on.
* With each level, the yargs parser is augmented with new commands, options and parameters.
*
*/
const _ = require('lodash');
const path = require('path');
const util = require('util');
const chalk = require('chalk');
const VError = require('verror');
const yargsFactory = require('yargs/yargs');
const { JSONErrorResult } = require('../lib/json-result');
// It's important to run yargs in the directory of the script so it picks up options from package.json
const Yargs = yargsFactory(process.argv.slice(2), path.resolve(__dirname, '../..'));
Yargs.$0 = 'particle';
class CLICommandItem {
/**
* Constructor.
* @param {string} name The name of the command. This is the text the command is recognized by.
* @param {string} description A description of the command. Used to print help text / error messages.
* @param {object} options - options for yargs processing. it has these defined attributes:
* - options: the options to pass to yargs
* - setup: a function called with yargs to allow additional setup of the command line parsing
* - examples: an object of examples to add to yargs (key is the command, value is the description)
* - version: the version function to pass to yargs
*/
constructor(name, description = '', options = {}){
if (!name){
throw Error('name must be defined');
}
this.commands = {};
this.aliases = [];
description = description!==undefined ? description : '';
Object.assign(this, { name, description, options, inherited: options.inherited });
}
/**
* Retrieves the sequence of words used to reach this command.
* In cases where a command has an alias, this returns the canonical form of the command.
* @returns {Array<String>} The command path as an array of simple names.
*/
get path(){
return this.parent
? this.parent.path.concat([this.name])
: [this.name];
}
/**
* Fetches a subitem from this item.
* @param {string} name The name of the sub item to retrieve.
* @returns {CLICommandItem} A command item matching the name, or `undefined` if the named item doesn't exist.
*/
item(name){
return this.commands[name];
}
/**
* Finds the command at the given path.
* @param {Array<string>} path The command path to find from this command
* @returns {CliCommandItem} the item found at the path or undefined
*/
find(path){
if (!path || !path.length){
return this;
}
const name = path[0];
const remain = path.slice(1);
const cmd = this.item(name);
return cmd && cmd.find(remain);
}
/**
* Adds a command item at this point in the command tree.
* @param {CLICommandItem} item the command to add.
* @returns {CLICommandItem} this
*/
addItem(item){
this.commands[item.name] = item;
item.parent = this;
return this;
}
/**
* Configures the yargs parser for this command.
* @private
* @param {yargs} yargs the yargs instance to configure
* @param {object} options yargs options
* @param {function(yargs)} setup a function to call after setting the options
* @param {Array} examples yargs examples
* @param {function} version A function to retrieve the version
* @param {string} epilogue Printed at the end of the command block.
*/
configure(yargs, { options, setup, examples, version, epilogue } = this.buildOptions()){
if (options){
this.hideOption(options);
this.fetchAliases(options);
this.configureOptions(options);
// avoid converting positional arguments to numbers by default
const optionsWithDefaults = Object.assign({ '_': { string: true } }, options);
yargs.options(optionsWithDefaults);
}
if (setup){
setup(yargs, this);
}
if (examples){
const command = this.path.join(' ');
Object.keys(examples).forEach(cmd => {
yargs.example(cmd.replace(/\$command/, command), examples[cmd]);
});
}
if (version){
this.version = version;
}
if (epilogue){
yargs.epilogue(epilogue);
}
yargs.exitProcess(false);
}
/**
* Hides options that are marked as hidden. (by removing the description)
* See here -> https://github.com/yargs/yargs/issues/851
* @param options
*/
hideOption(options){
const optionKeys = Object.keys(options);
optionKeys.forEach((key) => {
const option = options[key];
if (option.hidden){
option.description = undefined;
}
});
}
fetchAliases(options){
Object.keys(options).forEach((key) => {
const option = options[key];
const alias = option.alias;
if (alias){
this.aliases[alias] = key;
}
});
}
configureOptions(options){
Object.keys(options).forEach((key) => {
const option = options[key];
if (!Object.prototype.hasOwnProperty.call(option, 'nargs') &&
!option.boolean &&
!option.count &&
!option.array){
option.nargs = 1;
}
if (!Object.prototype.hasOwnProperty.call(option, 'number') &&
!Object.prototype.hasOwnProperty.call(option, 'boolean') &&
!Object.prototype.hasOwnProperty.call(option, 'string') &&
!Object.prototype.hasOwnProperty.call(option, 'count') &&
!Object.prototype.hasOwnProperty.call(option, 'array')){
option.string = true;
}
});
}
/**
* Finds the original name of an option given a possible alias.
* @param {string} name The option name to unalias.
* @returns {string} The original option name
*/
unaliasOption(name){
return this.aliases[name] || (this.parent ? this.parent.unaliasOption(name) : undefined);
}
/**
* @param {Array<string>} args The command line arguments to parse
* @param {object} yargs The yargs instance to use for parsing.
* @return {object} the results of parsing. The object has these properties:
* clicommand: the CLICommandItem instance that matched
* clierror: any errors produces (mutually exclusive with clicommand)
* *: properties corresponding to any options specified on the command line.
*/
parse(args, yargs){
if (yargs === undefined){
yargs = Yargs;
}
let error = undefined;
yargs.fail((msg, err) => {
error = err || msg;
});
const argv = this.configureAndParse(args, yargs);
if (!error){
if (this.options.parsed){
this.options.parsed(argv);
}
if (this.matches(argv)){
argv.clicommand = this;
}
} else {
argv.clierror = error;
}
return argv;
}
configureAndParse(args, yargs){
this.configureParser(args, yargs);
return yargs.parse(args);
}
matches(argv){
return this.matchesArgs(argv._);
}
matchesArgs(args){
if (!this.path.length){
return !args.length;
}
// walk the argv matching each command in sequence
const last = args[args.length - 1];
return this.matchesName(last) && (!this.parent || this.parent.matchesArgs(args.slice(0, args.length - 1)));
}
matchesName(name){
if (this.name === name){
return true;
}
return this.options.alias && this.options.alias === name;
}
/**
* Executes the given command, optionally consuming the result if an error handler is provided.
* @param {object} argv The parsed arguments for the cli command.
* @returns {Promise} to run the comand
*/
exec(argv){
if (this.options.handler){
return Promise.resolve().then(() => this.options.handler(argv));
}
if (argv.version && this.version){
return Promise.resolve(this.version(argv));
}
return this.showHelp();
}
showHelp(){
Yargs.showHelp();
}
addInheritedOptions(target){
const parent = this.parent;
if (parent){
parent.addInheritedOptions(target);
}
this.assign(target, this.inherited);
}
buildOptions(){
const target = {};
this.addInheritedOptions(target);
this.assign(target, this.options);
return target;
}
assign(target, value){
if (value){
// TODO (mirande): this is a dirty hack! for now, only merge the options
const options = target.options || {};
Object.assign(target, value);
target.options = Object.assign(options, value.options);
}
}
}
/**
* Describes a container of commands, and whose path has a common prefix.
* Uses the container pattern where child items can be further nested categories or
* CLICommand instance.
*/
class CLICommandCategory extends CLICommandItem {
constructor(name, description, options){
super(name, description, options);
this.parent = null;
}
/**
* Checks that the given command exists.
* @callback
* @param {object} yargs The yargs parser instance for this command.
* @param {object} argv the parsed yargs arguments
* @returns {boolean} the validity of the check.
*/
check(yargs, argv){
// ensure common prefix
if (!this.matches(argv)){
throw unknownCommandError(argv._, this);
}
// We can't use `yargs.strict()` because it is possible that
// `options.setup` changes the options during execution and this
// seems to interfere with the timing for strict mode.
// Additionally, `yargs.strict()` does not seem to handle pre-
// negated params like `--no-run`.
checkForUnknownArguments(yargs, argv, this);
return true;
}
get commandNames(){
return Object.keys(this.commands);
}
configureParser(args, yargs){
this.configure(yargs);
// add the subcommands of this category
this.commandNames.forEach((commandName) => {
const command = this.commands[commandName];
const builder = (yargs) => {
return { argv: command.parse(args, yargs) };
};
yargs.command(command.name, command.description, builder);
if (command.options && command.options.alias){
// hidden command
yargs.command(command.options.alias, false, builder);
}
});
yargs
.usage((this.description ? this.description + '\n' : '')
+ ['Usage: $0', ...this.path, '<command>'].join(' ') + '\n'
+ ['Help: $0 help', ...this.path, '<command>'].join(' '))
.check((argv) => this.check(yargs, argv));
return yargs;
}
}
class CLIRootCategory extends CLICommandCategory {
constructor(options){
super('$0', options && options.description, options);
}
get path(){
return [];
}
get commandNames(){
return super.commandNames.sort();
}
}
class CLICommand extends CLICommandItem {
/**
* @param {string} name The invocation name of the command on the command line
* @param {string} description Description of the command. Used to produce help text.
* @param {object} options In addition to attributes defined by the base class:
* - params: the positional arguments in this format: <required> [optional] <rest...>
* - handler: the function that is invoked with `this` and the parsed commandline.
*/
constructor(name, description, options){
super(name, description, _.defaultsDeep(options || {}, { params: '' }));
this.name = name || '$0';
this.parent = null;
}
configureParser(args, yargs){
this.configure(yargs);
yargs
.check((argv) => {
// We can't use `yargs.strict()` because it is possible that
// `options.setup` changes the options during execution and this
// seems to interfere with the timing for strict mode.
// Additionally, `yargs.strict()` does not seem to handle pre-
// negated params like `--no-run`.
checkForUnknownArguments(yargs, argv, this);
parseParams(yargs, argv, this.path, this.options.params);
return true;
})
.usage((this.description ? this.description + '\n' : '')
+ 'Usage: $0 ' + this.path.join(' ') + ' [options]'
+ (this.options.params ? ' ' + this.options.params : ''));
return yargs;
}
}
/**
* Creates an error handler. The handler displays the error message if there is one,
* or displays the help if there ie no error or it is a usage error.
* @param {object} yargs
* @returns {function(err)} the error handler function
*/
function createErrorHandler(yargs){
if (!yargs){
yargs = Yargs;
}
return consoleErrorLogger.bind(undefined, console, yargs, true);
}
/**
* Logs an error to the console given and optionally calls yargs.showHelp() if the
* error is a usage error.
* @param {object} console the console to log to.
* @param {Yargs} yargs the yargs instance
* @param {boolean} exit if true, process.exit() is called.
* @param {object} error the error to log. If it has a `message` property, that is logged, otherwise
* the error is converted to a string by calling `stringify(err)`.
*/
function consoleErrorLogger(console, yargs, exit, error){
const usage = (!error || error.isUsageError);
const verbose = (global.verboseLevel || 0) > 1;
if (usage){
yargs.showHelp();
}
if (error){
if (error.asJSON){
console.log(
new JSONErrorResult(error).toString()
);
} else {
console.log(
chalk.red(error.message || stringify(error))
);
if (!usage && error.stack && verbose){
console.log(
VError.fullStack(error)
);
}
}
}
// TODO (mirande): try to find a more controllable way to singal an error - this isn't easily testable.
if (exit){
process.exit(1);
}
}
function stringify(err){
return _.isString(err) ? err : util.inspect(err);
}
// Adapted from: https://github.com/bcoe/yargs/blob/master/lib/validation.js#L83-L110
function checkForUnknownArguments(yargs, argv, command){
const aliasLookup = {};
const flags = yargs.getOptions().key;
const demanded = yargs.getDemanded();
const unknown = [];
Object.keys(yargs.parsed.aliases || {}).forEach((key) => {
yargs.parsed.aliases[key].forEach((alias) => {
aliasLookup[alias] = key;
});
});
function isUnknown(key){
return (key !== '$0' && key !== '_' && key !== 'params' &&
!Object.prototype.hasOwnProperty.call(demanded, key) &&
!Object.prototype.hasOwnProperty.call(flags, key) &&
!Object.prototype.hasOwnProperty.call(aliasLookup, 'no-' + key) &&
!Object.prototype.hasOwnProperty.call(aliasLookup, key));
}
function aliasFor(key){
return command.unaliasOption(key);
}
Object.keys(argv).forEach((key) => {
const alias = aliasFor(key);
if (isUnknown(key) && (!alias || isUnknown(alias))){
unknown.push(key);
}
});
if (unknown.length){
throw unknownArgumentError(unknown);
}
}
/**
* Parses parameters specified with the given command. The parsed params are stored as
* `argv.params`.
* @param {object} yargs The yargs command line parser
* @param {Array<String>} argv The parsed command line
* @param {Array<String>} path The command path the params apply to
* @param {string} params The params to parse.
*/
function parseParams(yargs, argv, path, params){
let required = 0;
let optional = 0;
let variadic = false;
argv.params = {};
const extra = argv._.slice(path.length);
argv._ = argv._.slice(0, path.length);
params.replace(/(<[^>]+>|\[[^\]]+\])/g, (match) => {
if (variadic){
throw variadicParameterPositionError(variadic);
}
const isRequired = match[0] === '<';
const param = match
.slice(1, -1)
.replace(/(.*)\.\.\.$/, (m, param) => {
variadic = true;
return param;
});
if (isRequired){
required++;
} else {
optional++;
}
let value;
if (variadic){
variadic = param; // save the name
value = extra.slice(-1 + required + optional).map(String);
if (isRequired && !value.length){
throw variadicParameterRequiredError(param);
}
} else {
if (isRequired && optional > 0){
throw requiredParameterPositionError(param);
}
value = extra[-1 + required + optional];
if (value){
value = String(value);
}
if (isRequired && typeof value === 'undefined'){
throw requiredParameterError(param);
}
}
const params = param.split('|');
params.forEach(p => {
argv.params[p] = value;
});
});
if (!variadic && required+optional < extra.length){
throw unknownParametersError(extra.slice(required + optional));
}
}
/**
* @param {object} options
* @returns {CLIRootCategory} The root category for the app command line args.
*/
function createAppCategory(options){
return new CLIRootCategory(options);
}
function createCategory(parent, name, description, options){
if (_.isObject(description)){
options = description;
description = '';
}
const cat = new CLICommandCategory(name, description, options);
parent.addItem(cat);
return cat;
}
function createCommand(category, name, description, options){
if (_.isObject(name)){
options = name;
name = '$0';
description = '';
}
if (_.isObject(description)){
options = description;
description = '';
}
const cmd = new CLICommand(name, description, options);
category.addItem(cmd);
return cmd;
}
/**
* Top-level invocation of the command processor.
* @param {CLICommandItem} command
* @param {Array} args
* @returns {*} The argv from yargs parsing.
* Options/booleans are attributes of the object. The property `clicommand` contains the command corresponding
* to the requested command. `clierror` contains any error encountered durng parsing.
*/
function parse(command, args, width = Yargs.terminalWidth()){
Yargs.reset();
Yargs.wrap(width);
return command.parse(args, Yargs);
}
function baseError(message, data){
const error = new Error();
// yargs doesn't pass the full error if a message is defined, only the message
// since we need the full object, use an alias
error.message = message;
error.data = data || null;
return error;
}
function usageError(message, data, type, item){
const error = baseError(message, data);
error.isUsageError = true;
error.type = type;
error.item = item;
return error;
}
function applicationError(message, data, type){
const error = baseError(message, data);
error.isApplicationError = true;
error.type = type;
return error;
}
function unknownCommandError(command, item){
const commandString = command.join(' ');
return usageError(
`No such command '${commandString}'`,
command,
unknownCommandError,
item
);
}
function unknownArgumentError(argument){
const argsString = argument.join(', ');
const s = argument.length > 1 ? 's' : '';
return usageError(
`Unknown argument${s} '${argsString}'`,
argument,
unknownArgumentError
);
}
function requiredParameterError(param){
return usageError(
`Parameter '${param}' is required.`,
param,
requiredParameterError
);
}
function variadicParameterRequiredError(param){
return usageError(
`Parameter '${param}' must have at least one item.`,
param,
variadicParameterRequiredError
);
}
function variadicParameterPositionError(param){
return applicationError(
`Variadic parameter '${param}' must the final parameter.`,
param,
variadicParameterPositionError
);
}
function requiredParameterPositionError(param){
return applicationError(
`Required parameter '${param}' must be placed before all optional parameters.`,
param,
requiredParameterPositionError
);
}
function unknownParametersError(params){
const paramsString = params.join(' ');
return usageError(
`Command parameters '${paramsString}' are not expected here.`,
params,
unknownParametersError
);
}
function showHelp(cb){
Yargs.showHelp(cb);
}
module.exports = {
parse,
createCommand,
createCategory,
createAppCategory,
createErrorHandler,
showHelp,
errors: {
usageError,
unknownCommandError,
unknownArgumentError,
requiredParameterError,
variadicParameterRequiredError,
variadicParameterPositionError,
requiredParameterPositionError,
unknownParametersError
},
test: {
consoleErrorLogger
}
};