UNPKG

cli-kit

Version:

Everything you need to create awesome command line interfaces

340 lines (296 loc) 10.4 kB
import Context from './context.js'; import debug from '../lib/debug.js'; import E from '../lib/errors.js'; import fs from 'fs'; import helpCommand from '../commands/help.js'; import path from 'path'; import { declareCLIKitClass } from '../lib/util.js'; const { log } = debug('cli-kit:command'); const { highlight } = debug.styles; const formatRegExp = /^([@! ]*[\w-_]+(?:\s*,\s*[@! ]*[\w-_]+)*)((?:\s*[<[]~?[\w-_]+[>\]])*)?$/; const nameRegExp = /^([@! ]*)([\w-_]+)\s*$/; /** * Defines a command and its options and arguments. * * @extends {Context} */ export default class Command extends Context { /** * Internal object for tracking aliases. * * @type {Object} * @private */ _aliases = {}; /** * Custom help header and footer content. * * @type {Object} * @access private */ _help = {}; /** * Constructs a command instance. * * @param {String} name - The command name or absolute path to a file. * @param {Object|CLI|Command|Context|Function} [params] - Command parameters or an action * function. * @param {Function|Command} [params.action] - A function to call when the command is found. * @param {Set.<String>|Array.<String>|String|Object} [params.aliases] - An array of command * aliases. * @param {Function} [params.callback] - A function to call when the command has been parsed. * @param {String|Function} [params.defaultCommand] - The default command to execute when this * command has no `action`. When value is a `String`, it looks up the subcommand and calls it. * If value is a `Function`, it simply invokes it. * @param {Boolean} [params.hidden=false] - When `true`, the option is not displayed on the * help screen or auto-suggest. * @access public * * @example * new Command('foo') * new Command('foo', {}) * new Command(new Command('foo')) */ constructor(name, params = {}) { super(null); // null will skip the init since we do it applyParams() declareCLIKitClass(this, 'Command'); this.applyParams(name, params); } /** * Initializes the command. * * @param {String} name - The command name. * @param {Object} params - Various parameters. * @access private */ applyParams(name, params) { if (name && typeof name === 'string' && path.isAbsolute(name) && fs.existsSync(name)) { params.modulePath = name; name = path.parse(name).name; } if (!name || typeof name !== 'string') { throw E.INVALID_ARGUMENT('Expected command name to be a non-empty string', { name: 'name', scope: 'Command.constructor', value: name }); } // parse the name and create the aliases and args: "ls, list <bar>" const format = name.trim(); const m = format.match(formatRegExp); if (!m || !m[1]) { throw E.INVALID_ARGUMENT('Expected command name to be a non-empty string', { name: 'name', scope: 'Command.constructor', value: name }); } if (typeof params === 'function') { params = { action: params }; } // reset the name name = null; // get the aliases from the format and find the command name const aliases = new Set(); for (let alias of m[1].split(',')) { const n = alias.match(nameRegExp); if (!n) { throw E.INVALID_ARGUMENT('Invalid command alias format', { name: 'alias', scope: 'Command.constructor', value: alias }); } if (!n[1].includes('@') && !name) { name = n[2]; } else { aliases.add(n[1].includes('!') ? `!${n[2]}` : n[2]); } } if (!name) { throw E.INVALID_ARGUMENT('Expected command name format to contain at least one non-aliased name', { name: 'format', scope: 'Command.constructor', value: format }); } if (!params || (typeof params !== 'object' || Array.isArray(params))) { throw E.INVALID_ARGUMENT('Expected command parameters to be an object', { name: 'params', scope: 'Command.constructor', value: params }); } if (params.callback && typeof params.callback !== 'function') { throw E.INVALID_ARGUMENT('Expected command callback to be a function', { name: 'callback', scop: 'Command.constructor', value: params.callback }); } if (params.defaultCommand !== undefined && (!params.defaultCommand || (typeof params.defaultCommand !== 'string' && typeof params.defaultCommand !== 'function'))) { throw E.INVALID_ARGUMENT('Expected default command to be a string or function', { name: 'defaultCommand', scope: 'Command.constructor', value: params.defaultCommand }); } if (params.clikit instanceof Set) { // params is a cli-kit object if (params.clikit.has('CLI')) { // since a command cannot have a title "global" (only a `CLI` object can have that), // we must delete it so that the title is reset to the command name if (params.title === 'Global') { delete params.title; } delete params.terminal; // add an action handler that eitehr executes a specific command or the help for // for this command (e.g. this command is an extension) params.action = async parser => { const { defaultCommand } = params; if (defaultCommand === 'help' && this.get('help')) { await helpCommand.action(parser); } else { const cmd = defaultCommand && this.commands[defaultCommand]; if (cmd) { return cmd.action.call(cmd, parser); } } }; } else if (!params.clikit.has('Command')) { // must be a command or extension throw E.INVALID_CLIKIT_OBJECT('Expected command options to be a CLI or Command object', { name: 'clikit', scope: 'Command.constructor', value: params.clikit }); } } if (params.action && typeof params.action !== 'function' && !(params.action instanceof Command)) { throw E.INVALID_ARGUMENT('Expected command action to be a function or Command instance', { name: 'action', scope: 'Command.constructor', value: params.action }); } params.name = name; const args = m[2] && m[2].trim().split(/\s+/); if (args?.length) { params.args = params.args ? [ ...args, ...params.args ] : args; } this.init(params); if (params.action) { this.action = params.action; } else if (typeof params.defaultCommand === 'function') { this.action = params.defaultCommand; } else if (typeof params.defaultCommand === 'string') { this.action = this.lookup.commands[params.defaultCommand]; } // mix aliases Set with params.aliases this._aliases = this.createAliases(aliases, params.aliases); this.callback = params.callback; this.clikitHelp = params.clikitHelp; this.defaultCommand = params.defaultCommand; this.help = params.help || {}; this.hidden = !!params.hidden; this.loaded = !params.modulePath; this.modulePath = params.modulePath; // mix in any other custom props for (const [ key, value ] of Object.entries(params)) { if (!Object.prototype.hasOwnProperty.call(this, key)) { this[key] = value; } } } /** * A map of aliases an whether they are visible. * * @type {Object} * @access public */ get aliases() { return this._aliases; } set aliases(value) { this._aliases = this.createAliases(value); } /** * Merges multiple alias constructs into a single alias object. * * @param {...Set.<String>|Array.<String>|String|Object} values - One or more alias values. * @returns {Object} * @access private */ createAliases(...values) { const result = {}; for (let value of values) { if (!value) { continue; } if (value instanceof Set) { value = Array.from(value); } if (typeof value === 'object' && !Array.isArray(value)) { Object.assign(result, value); continue; } if (!Array.isArray(value)) { value = [ value ]; } for (const alias of value) { if (!alias || typeof alias !== 'string') { throw E.INVALID_ARGUMENT('Expected command aliases to be an array of strings', { name: 'aliases.alias', scope: 'Command.constructor', value: alias }); } for (const a of alias.split(/[ ,|]+/)) { if (a === '!') { throw E.INVALID_ALIAS(`Invalid command alias "${alias}"`, { name: 'aliases', scope: 'Command.constructor', value: alias }); } if (a[0] === '!') { result[a.substring(1)] = 'hidden'; } else { result[a] = 'visible'; } } } } return result; } /** * Custom help header and footer content. A string, function, or object with `header` and * `footer` properties may be used to set the `help` property, but the internal value will * always be an object with `header` and `footer` properties. * * @type {Object} * @access public */ get help() { return this._help; } set help(value) { if (typeof value === 'string' || typeof value === 'function') { this._help.header = value; } else if (typeof value === 'object') { if (value.header) { if (typeof value.header === 'string' || typeof value.header === 'function') { this._help.header = value.header; } else { throw E.INVALID_ARGUMENT('Expected help content header to be a string or function'); } } if (value.footer) { if (typeof value.footer === 'string' || typeof value.footer === 'function') { this._help.footer = value.footer; } else { throw E.INVALID_ARGUMENT('Expected help content footer to be a string or function'); } } } else { this._help = {}; } } /** * Loads this command if it is defined in an external file. * * @returns {Promise<boolean>} Resolves `true` if it loaded the module, otherwise `false`. * @access public */ async load() { if (this.loaded) { return false; } try { log(`Importing ${highlight(this.modulePath)}`); let ctx = await import(`file://${this.modulePath}`); if (!ctx || typeof ctx !== 'object') { throw new Error('Command must export an object'); } // if this is an ES6 module, grab the default export if (ctx.default) { ctx = ctx.default; } if (!ctx || typeof ctx !== 'object') { throw new Error('Command must export an object'); } this.applyParams(ctx.name || this.name, ctx); this.loaded = true; return true; } catch (err) { throw E.INVALID_COMMAND(`Bad command "${this.name}": ${err.message}`, { name: this.name, scope: 'Command.load', value: err }); } } /** * Returns the schema for this command and all child contexts. * * @returns {Object} * @access public */ schema() { return { desc: this.desc }; } }