src/abstracts/cliCommand.js
/**
* A helper class for creating commands for the CLI.
* @abstract
* @version 1.0
*/
class CLICommand {
/**
* Class constructor.
* @throws {TypeError} If instantiated directly.
* @abstract
*/
constructor() {
if (new.target === CLICommand) {
throw new TypeError(
'CLICommand is an abstract class, it can\'t be instantiated directly'
);
}
/**
* The CLI command instruction. For example `my-command [target]`.
* @type {string}
*/
this.command = '';
/**
* A description of the command for the help interface.
* @type {string}
*/
this.description = '';
/**
* A more complete description of the command to show when the command help interface is
* invoked.
* If left empty, it won't be used.
* @type {string}
*/
this.fullDescription = '';
/**
* A list with the name of the options the command supports. New options can be added using
* the `addOption` method.
* @type {Array}
*/
this.options = [];
/**
* A dictionary of command options settings by their option name. New options can be added
* using the `addOption` method.
* @type {Object}
*/
this.optionsByName = {};
/**
* This is a useful flag for when the command is ran as a result of another command. It lets
* the interface know that it can search for option values on a parent command, if there's one.
* @type {boolean}
*/
this.checkOptionsOnParent = true;
/**
* Whether the command and its description should be shown on the CLI interface list of
* commands.
* @type {boolean}
*/
this.hidden = false;
/**
* Whether or not a sub program should be executed for this command. Take for example the case
* of `git`, where `git checkout [branch]` executes `git` as main program, and `checkout` as a
* sub program. If this is `true`, then a binary with the name of the command should be
* exported on the `package.json`.
* @type {boolean}
*/
this.subProgram = false;
/**
* This is the name of the program that runs the command. It will be added when the command
* is registered on the program.
* @type {string}
*/
this.cliName = '';
/**
* Whether or not the command supports unknown options. If it does, it will be sent to the
* `onActivation` method as a parameter.
* @type {Boolean}
*/
this.allowUnknownOptions = false;
/**
* This dictionary will be completed when the command gets activated. If the command supports
* unknown options (`allowUnknownOptions`), they'll be parsed and sent to the `handle` method
* as the last parameter.
* @type {Object}
*/
this._unknownOptions = {};
/**
* Once registered on the program, this property will hold a reference to the real command
* the program generates.
* @type {?Command}
* @ignore
* @access protected
*/
this._command = null;
}
/**
* Add a new option for the command.
* @example
* // To capture an option
* this.addOption(
* 'type',
* '-t, --type [type]',
* 'The type of thingy you want to use?',
* );
*
* // As a simple flag
* this.addOption(
* 'ready',
* '-r, --ready',
* 'Is it read?',
* false
* );
*
* @param {string} name The option name.
* @param {string} instruction The option instruction, for example: `-t, --type [type]`.
* @param {string} [description=''] The option description.
* @param {string} [defaultValue=''] The option default value, in case is not used on execution.
*/
addOption(name, instruction, description = '', defaultValue = '') {
this.optionsByName[name] = {
name,
instruction,
description,
defaultValue,
};
this.options.push(name);
}
/**
* Register this command on a CLI program.
* @param {Command} program A Commander instance.
* @param {Object} cli The main CLI interface, just for the name.
* @param {string} cli.name The CLI interface name.
* @see https://yarnpkg.com/en/package/commander
*/
register(program, cli) {
// Get the real name of the command.
const commandName = this.command.replace(/\[\w+\]/g, '').trim();
// Set a listener on the program in order to detect when it gets executed.
program.on(`command:${commandName}`, (args, unknown) => this._onActivation(args, unknown));
// Get the name of the program
this.cliName = cli.name;
const options = {};
// If the command should be hidden...
if (this.hidden) {
// ...remove it from the help interface.
options.noHelp = true;
}
let command;
// If the command is a sub program...
if (this.subProgram) {
/**
* ...it gets added without the `.description` property. That's how Commander differentiates
* a main program command and a sub program command.
*/
command = program.command(this.command, this.description, options);
} else {
// ...otherwise, it gets added as a sub command of the main program.
command = program
.command(this.command, '', options)
.description(this.description);
}
// Register all the command options.
this.options.forEach((name) => {
const option = this.optionsByName[name];
command = command.option(
option.instruction,
option.description
);
});
// Add the handler for when the command gets executed.
command.action(this._handle.bind(this));
// Enable unknown options if the command supports it
command.allowUnknownOption(this.allowUnknownOptions);
// Save the reference
this._command = command;
}
/**
* Generate an instruction for this command.
* @example
* // Let's say this command is `destroy [target] [--once]`
*
* this.generate({ target: 'pluto' });
* // Will return `destroy pluto`
*
* this.generate({ target: 'moon', once: true });
* // Will return `destroy moon --once`
* @param {Object} [args={}] A dictionary with the arguments and options for the command. If the
* command includes an argument on its `command` property, that
* argument is required.
* @return {string} The command instruction to run on the CLI interface.
*/
generate(args = {}) {
let cmd = this.command;
const cmdOptions = [];
// Loop all the `args`...
Object.keys(args).forEach((name) => {
const value = args[name];
const asPlaceholder = `[${name}]`;
// Check if the current argument should be used on the command instruction...
if (cmd.includes(asPlaceholder)) {
// ...if so, replace it on the main command.
cmd = cmd.replace(asPlaceholder, value);
} else if (this.optionsByName[name]) {
// ...otherwise, check if there's an option with the same name as the argument.
const option = this.optionsByName[name];
/**
* Remove the shorthand version of the option instruction, if there's one. For example:
* `-t, --type [type]` -> `--type [type]`.
*/
let instruction = option.instruction.split(',').pop().trim();
// If the option instruction includes the argument as a value (`[argument-name]`)...
if (instruction.includes(asPlaceholder)) {
// ...replace it on the option instruction.
instruction = instruction.replace(asPlaceholder, value);
} else if (value === false) {
/**
* ...but if the value is `false`, then we clear the instruction as it won't be included
* on the generated string.
*/
instruction = '';
}
// If there's an option instruction...
if (instruction) {
// ...add it to the list.
cmdOptions.push(instruction);
}
} else if (this.allowUnknownOptions) {
/**
* Finally, if is not on the command options and the command supports unknown options,
* just add it.
*/
let instruction = `--${name}`;
// If the option is not a flag, add its value.
if (value !== true) {
instruction += ` ${value}`;
}
// Push it to the list
cmdOptions.push(instruction);
}
});
let options = '';
// If after the loop, there are option instructions to add...
if (cmdOptions.length) {
// ...put them all together on a single string, separated by a space
options = ['', ...cmdOptions].join(' ');
}
// Return the complete command instruction
return `${this.cliName} ${cmd}${options}`;
}
/**
* Handle the command execution.
* This method will receive first the captured arguments, then the executed command information
* from Commander and finally, a dictionary with the options and their values.
* @example
* // Let's say the command is `run [target] [--production]`.
* // And now, it was executed with `run my-target`
* handle(target, command, options) {
* console.log(target);
* // Will output `my-target`
* console.log(options.production)
* // Will output `false`
* }
* @throws {Error} if not overwritten.
* @abstract
*/
handle() {
throw new Error('This method must be overwritten');
}
/**
* A simple wrapper for a `console.log`. Outputs a variable to the CLI interface.
* @param {string} text The text to output.
*/
output(text) {
// eslint-disable-next-line no-console
console.log(text);
}
/**
* This is the real method that receives the execution of the command and parses it in order to
* create the options dictionary that the `handle` method receives.
* @param {Array} args The list of arguments sent by Commander.
* @ignore
* @access protected
*/
_handle(...args) {
// The actual command is always the last argument.
const command = args[args.length - 1];
const options = {};
// Loop all the known options the command can receive
Object.keys(this.optionsByName).forEach((name) => {
const option = this.optionsByName[name];
let value = '';
// If the option is on the command...
if (command[name]) {
// ...then that's the value that will be used.
value = command[name];
}
/**
* If no value was found yet, the flag to check on the parent is `true`, there's a parent
* command and it has an option with that name...
*/
if (
!value &&
this.checkOptionsOnParent &&
command.parent &&
command.parent[name]
) {
// ...then that's the value that will be used.
value = command.parent[name];
}
// If no value was found and there's a default value registered for the option...
if (!value && typeof option.defaultValue !== 'undefined') {
// ...then that's the value that will be used.
value = option.defaultValue;
}
// Set the option on the dictionary with the value found.
options[name] = value;
});
// Copy the arguments list.
const newArgs = args.slice();
// Add the new options dictionary.
newArgs.push(options);
// If the method supports unknown options, add them as the last argument.
if (this.allowUnknownOptions) {
newArgs.push(this._unknownOptions);
}
// Call the abstract method that handles the execution.
this.handle(...newArgs);
}
/**
* This method gets called by the program when the command is executed and it takes care of
* switching the descriptions, if needed, and parsing the unknown options, if supported.
* @param {Array} args The list of known arguments the command received.
* @param {Array} unknownArgs The list of unknown arguments the command received.
* @ignore
* @protected
*/
_onActivation(args, unknownArgs) {
// Switch the descriptions.
this._updateDescription();
// If unknown options are allowed, parsed them and save them on the local property.
if (this.allowUnknownOptions && unknownArgs && unknownArgs.length) {
this._unknownOptions = this._parseArgs(unknownArgs);
}
}
/**
* This method gets called when the command is executed on the program and before reaching the
* handle method. It checks if the command has a different description for when it gets
* executed, and if needed, it switches it on the program.
* @ignore
* @access protected
*/
_updateDescription() {
// If the command reference is available and there's a full description...
if (this.fullDescription) {
// ...normalize it by adding the indentation the program uses to show descriptions and help.
const normalizedDescription = this.fullDescription.replace(/\n/g, '\n ');
// Change the command description.
this._command.description(normalizedDescription);
}
}
/**
* This method parses a list of CLI arguments into a dicitionary.
* @example
* const args = [
* '--include=something',
* '-i',
* 'somes',
* '--exclude',
* '--type',
* 'building',
* '-x=y',
* ];
* console.log(this._parseArgs(args));
* // Will output `{include: 'something', i: 'somes', exclude: true, type: 'building', x: 'y'}`
* @param {Array} args A list of arguments.
* @return {Object}
* @ignore
* @access protected
*/
_parseArgs(args) {
// Use Commander to normalize the arguments list.
const list = this._command.normalize(args);
// Define the dictionary to return.
const parsed = {};
/**
* Define the regex that will validate if an argument is an _"option header"_ (`--[something]`)
* or a value.
*/
const headerRegex = /^-(?:-)?/;
/**
* Every time the loop finds a header, it will be set on this variable, so the next time a value
* is found, it can be assigned to that header on the return dictionary.
*/
let currentHeader;
/**
* The commander `normalize` method transforms `-x=y` into `['-x', '-=', '-y']`. On the first
* iteration, `-x` will be marked as a header, on the following iteration, the loop will check
* for `-=`, ignore it and mark this variable as `true` so on the final iteration, despite the
* fact that the value starts `-`, the method should remove the `-` and save it as a value for
* `-x`.
*/
let nextValue = false;
// Loop the list...
list.forEach((item) => {
// Check whether the current item is a header or not.
const isHeader = item.match(headerRegex);
// If it is a header...
if (isHeader) {
// ...and the flag for short instructions is `true`...
if (nextValue) {
// ...remove the leading `-` and save it as a value for the current header.
parsed[currentHeader] = item.substr(1);
// Reset the flags.
currentHeader = null;
nextValue = false;
} else if (currentHeader && item === '-=') {
/**
* If there's a header and the current argument is `-=`, set the flag for short
* instructions to `true`.
*/
nextValue = true;
} else if (currentHeader) {
/**
* If this is another header, it means that the argument is a flag, so save the current
* header as `true` and change the current header to the current item.
*/
parsed[currentHeader] = true;
currentHeader = item.replace(headerRegex, '');
} else {
// Set the current header to the current item.
currentHeader = item.replace(headerRegex, '');
}
} else if (currentHeader) {
/**
* If there's a header, this means the current item is a value, so set it to the current
* header and reset the variable.
*/
parsed[currentHeader] = item;
currentHeader = null;
}
});
// Return the parsed object.
return parsed;
}
}
module.exports = CLICommand;