UNPKG

cli-kit

Version:

Everything you need to create awesome command line interfaces

535 lines (473 loc) 19.6 kB
import Command from './parser/command.js'; import Context from './parser/context.js'; import debug from './lib/debug.js'; import E from './lib/errors.js'; import Extension from './parser/extension.js'; import helpCommand, { renderHelp } from './commands/help.js'; import Parser from './parser/parser.js'; import pluralize from 'pluralize'; import Terminal from './terminal.js'; import { assertNodeJSVersion, declareCLIKitClass } from './lib/util.js'; const { error, log, warn } = debug('cli-kit:cli'); const { highlight, note } = debug.styles; const { chalk } = debug; const defaultStyles = { bold: chalk.bold, dim: chalk.dim, italic: chalk.italic, underline: chalk.underline, inverse: chalk.inverse, hidden: chalk.hidden, strikethrough: chalk.strikethrough, black: chalk.black, red: chalk.red, green: chalk.green, yellow: chalk.yellow, blue: chalk.blue, magenta: chalk.magenta, cyan: chalk.cyan, white: chalk.white, gray: chalk.gray, bgBlack: chalk.bgBlack, bgRed: chalk.bgRed, bgGreen: chalk.bgGreen, bgYellow: chalk.bgYellow, bgBlue: chalk.bgBlue, bgMagenta: chalk.bgMagenta, bgCyan: chalk.bgCyan, bgWhite: chalk.bgWhite, uppercase: s => String(s).toUpperCase(), lowercase: s => String(s).toLowerCase(), bracket: s => `[${s}]`, paren: s => `(${s})`, highlight: chalk.cyan, lowlight: chalk.blue, ok: chalk.green, notice: chalk.yellow, alert: chalk.red, note: chalk.gray, warn: chalk.yellow, error: chalk.red, heading: s => String(s).toUpperCase(), subheading: chalk.gray }; /** * Defines a CLI context and is responsible for parsing the command line arguments. * * @extends {Context} */ export default class CLI extends Context { /** * Created a CLI instance. * * @param {Object} [params] - Various options. * @param {Boolean} [params.autoHideBanner=true] - When `true` and a `banner` is set, it will * detect if the first characters written to `stdout` or `stderr` match a JSON object/array or * XML document, then suppresses the banner. * @param {String|Function} [params.banner] - A banner or a function that returns the banner * to be displayed before each command. * @param {Boolean} [params.colors=true] - Enables colors, specifically on the help screen. * @param {String|Function} [params.defaultCommand="help"] - The default command to execute. * When value is a `String`, it looks up the command and calls it. If value is a `Function`, it * simply invokes it. * @param {Boolean} [params.errorIfUnknownCommand=true] - When `true`, `help` is enabled, and * the parser didn't find a command, but it did find an unknown argument, it will show the help * screen with an unknown command error. * @param {String|Function|Object} [params.help] - Additional help content to display on the * help screen. When may be an object with the properties `header` and `footer` which values * that are either a string or an async function that resolves a string. When value is a string * or function, it is trasnformed into a object with the value being used as the header. Note * that the command description is not displayed when a header message has been defined. * @param {Number} [params.helpExitCode] - The exit code to return when the help command is * finished. * @param {String} [params.helpTemplateFile] - Path to a template to render for the help * command. * @param {Boolean} [params.hideNoBannerOption] - When `true` and a `banner` is specified, it * does not add the `--no-banner` option. * @param {Boolean} [params.hideNoColorOption] - When `true` and `colors` is enabled, it does * not add the `--no-color` option. * @param {String} [params.name] - The name of the program. If not set, defaults to `"program"` * in the help outut and `"This application"` in the Node version assertion. * @param {String} [params.nodeVersion] - The required Node.js version to run the app. * @param {Boolean} [params.showBannerForExternalCLIs=false] - If `true`, shows the `CLI` * banner, assuming banner is enabled, for non-cli-kit enabled CLIs. * @param {Boolean} [params.showHelpOnError=true] - If an error occurs and `help` is enabled, * then display the error before the help information. * @param {Object} [params.styles] - Custom defined style functions. * @param {Terminal} [params.terminal] - A custom terminal instance, otherwise uses the default * global terminal instance. * @param {String} [params.title='Global'] - The title for the global context. * @param {String|Function} [params.version] - The program version or a function that resolves * a version. * @access public */ constructor(params = {}) { if (!params || typeof params !== 'object' || Array.isArray(params)) { throw E.INVALID_ARGUMENT('Expected CLI parameters to be an object or Context', { name: 'params', scope: 'CLI.constructor', value: params }); } if (params.banner !== undefined && typeof params.banner !== 'string' && typeof params.banner !== 'function') { throw E.INVALID_ARGUMENT('Expected banner to be a string or function', { name: 'banner', scope: 'CLI.constructor', value: params.banner }); } if (params.extensions && typeof params.extensions !== 'object') { throw E.INVALID_ARGUMENT( 'Expected extensions to be an array of extension paths or an object of names to extension paths', { name: 'extensions', scope: 'CLI.constructor', value: params.extensions } ); } if (params.helpExitCode !== undefined && typeof params.helpExitCode !== 'number') { throw E.INVALID_ARGUMENT('Expected help exit code to be a number', { name: 'helpExitCode', scope: 'CLI.constructor', value: params.helpExitCode }); } if (params.terminal && !(params.terminal instanceof Terminal)) { throw E.INVALID_ARGUMENT('Expected terminal to be a Terminal instance', { name: 'terminal', scope: 'CLI.constructor', value: params.terminal }); } 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: 'CLI.constructor', value: params.defaultCommand }); } // make sure we have a `name` and `title` for the context if (!params.name) { params.name = 'program'; } if (!params.title) { params.title = 'Global'; } // extract the extensions... we initialize them ourselves const { extensions } = params; delete params.extensions; super(params); declareCLIKitClass(this, 'CLI'); this.appName = params.appName || params.name; this.autoHideBanner = params.autoHideBanner !== false; this.colors = params.colors !== false; this.defaultCommand = params.defaultCommand; this.errorIfUnknownCommand = params.errorIfUnknownCommand !== false; this.help = params.help; this.helpExitCode = params.helpExitCode; this.helpTemplateFile = params.helpTemplateFile; this.hideNoBannerOption = params.hideNoBannerOption; this.hideNoColorOption = params.hideNoColorOption; this.nodeVersion = params.nodeVersion; this.showBannerForExternalCLIs = params.showBannerForExternalCLIs; this.showHelpOnError = params.showHelpOnError; this.styles = Object.assign({}, defaultStyles, params.styles); this.terminal = params.terminal || new Terminal(); this.version = params.version; this.warnings = []; this.terminal.on('SIGINT', () => process.kill(process.pid, 'SIGINT')); // add the built-in help if (this.help) { if (this.defaultCommand === undefined) { this.defaultCommand = 'help'; } // note: we must clone the help command params since the object gets modified this.command('help', { ...helpCommand }); this.option('-h, --help', 'Displays the help screen'); } // add the --no-banner flag if (this.banner && !this.hideNoBannerOption) { this.option('--no-banner', 'Suppress the banner'); } // add the --no-colors flag if (this.colors && !this.hideNoColorOption) { this.option('--no-color', { aliases: [ '--no-colors' ], desc: 'Disable colors' }); } // add the --version flag if (this.version && !this.lookup.short.v && !this.lookup.long.version) { this.option('-v, --version', { callback: async ({ exitCode, opts, next }) => { if (await next() === true) { let version = this.version; if (typeof version === 'function') { version = await version(opts); } (opts.terminal || this.terminal).stdout.write(`${version}\n`); exitCode(0); return false; } }, desc: 'Outputs the version' }); } // add the extensions now that the auto-generated options exist if (extensions) { const exts = Array.isArray(extensions) ? extensions : Object.entries(extensions); for (const ext of exts) { try { this.extension.apply(this, Array.isArray(ext) ? [ ext[1], ext[0] ] : [ ext ]); } catch (e) { this.warnings.push(`Error loading extension "${ext}"`); warn(e); } } } } /** * Parses the command line arguments and runs the command. * * @param {Array.<String>} [_argv] - An array of arguments to parse. If not specified, it * defaults to the `process.argv` starting with the 3rd argument. * @param {Object} [opts] - Various options. * @param {Object} [opts.data] - User-defined data to pass into the selected command. * @param {Function} [opts.exitCode] - A function that sets the exit code. * @param {Array.<String>} [params.parentContextNames] - An array of parent context names. * @param {Boolean} [opts.remoteHelp=false] - When `true`, don't execute the built-in help * command. This is set when a request comes from a remote connection. * @param {Termianl} [opts.terminal] - A terminal instance to override the default CLI terminal * instance. * @returns {Promise.<Arguments>} * @access public */ async exec(_argv, opts = {}) { assertNodeJSVersion(this); if (!_argv) { _argv = process.argv.slice(2); } else if (!Array.isArray(_argv)) { throw E.INVALID_ARGUMENT('Expected arguments to be an array', { name: 'args', scope: 'CLI.exec', value: _argv }); } if (!opts || typeof opts !== 'object') { throw E.INVALID_ARGUMENT('Expected opts to be an object', { name: 'opts', scope: 'CLI.exec', value: opts }); } if (!opts.data) { opts.data = {}; } else if (typeof opts.data !== 'object') { throw E.INVALID_ARGUMENT('Expected data to be an object', { name: 'opts.data', scope: 'CLI.exec', value: opts.data }); } if (!opts.terminal) { opts.terminal = this.terminal; } else if (!(opts.terminal instanceof Terminal)) { throw E.INVALID_ARGUMENT('Expected terminal to be a Terminal instance', { name: 'opts.terminal', scope: 'CLI.exec', value: opts.terminal }); } let exitCode = undefined; let showHelpOnError = this.prop('showHelpOnError'); const parser = new Parser(opts).link(this); const __argv = _argv.slice(0); opts.exitCode = code => code === undefined ? exitCode : (exitCode = code || 0); opts.styles = Object.assign({}, this.styles, opts.styles); let results = { _: undefined, _argv, // the original unparsed arguments __argv, // the parsed arguments argv: undefined, bannerFired: false, bannerRendered: false, cli: this, cmd: undefined, console: opts.terminal.console, contexts: undefined, data: opts.data, exitCode: opts.exitCode, help: () => renderHelp(results.cmd, opts), result: undefined, setExitCode: opts.exitCode, styles: opts.styles, terminal: opts.terminal, unknown: undefined, warnings: this.warnings }; const renderBanner = async (state) => { const { argv, cli, cmd = cli, terminal } = state; // if --no-banner, then return // or if we're running an extension that is not a cli-kit extension, then return and // let the extension CLI render its own banner if (state.bannerRendered || (argv && !argv.banner) || (cmd instanceof Extension && !cmd.isCLIKitExtension && !cmd.get('showBannerForExternalCLIs'))) { return; } state.bannerRendered = true; // copy the banner to the state // if the banner is a function, run it now if (cmd.banner !== undefined) { state.banner = cmd.banner; cmd._origBanner = cmd.banner; Object.defineProperty(cmd, 'banner', { get() { return cmd._origBanner; }, set(value) { state.banner = value; } }); } for (let p = cmd.parent; p; p = p.parent) { if (state.banner === undefined && (!(state.bannerFired instanceof Error) || p.banner !== undefined)) { state.banner = p.banner; } p._origBanner = p.banner; Object.defineProperty(p, 'banner', { get() { return p._origBanner; }, set(value) { state.banner = value; } }); } if (typeof state.banner === 'function') { state.banner = await state.banner(state); } const printBanner = () => { if (typeof state.banner === 'function') { throw new Error('Banner function not supported here'); } if (state.banner) { state.banner = String(state.banner).trim(); } if (state.banner) { terminal.stdout.write(`${state.banner}\n\n`); } }; if (cmd.prop('autoHideBanner')) { // wait to show banner terminal.onOutput(() => printBanner()); } else { // show banner now printBanner(); } }; const bannerHook = async state => { if (!state.bannerFired) { state.bannerFired = true; try { await this.hook('banner', renderBanner)(state); } catch (err) { state.bannerFired = err; throw err; } } }; try { const cli = this; log(`Parsing ${__argv.length} argument${__argv.length !== 1 ? 's' : ''}`); // parse the command line arguments const { _, argv, contexts, required, unknown } = await parser.parse({ args: __argv, ctx: cli, data: results.data }); log('Parsing complete: ' + `${pluralize('option', Object.keys(argv).length, true)}, ` + `${pluralize('unknown option', Object.keys(unknown).length, true)}, ` + `${pluralize('arg', _.length, true)}, ` + `${pluralize('context', contexts.length, true)} ` + note(`(exit: ${results.exitCode()})`) ); const cmd = contexts[0]; // check for missing arguments and options if help is disabled or is not set if (!this.help || !argv.help) { // `_` already contains all known parsed arguments, but may not contain all required // arguments, thus we must loop over the remaining arguments and check if there are // any missing required arguments. // // note that we stop looping if we find an argument with multiple arguments since // we've already gobbled up all the values let i = _.length; const len = cmd.args.length; if (i === 0 || (i < len && !cmd.args[i - 1].multiple)) { for (; i < len; i++) { if (cmd.args[i].required && (!cmd.args[i].multiple || !argv[cmd.args[i].name].length)) { throw E.MISSING_REQUIRED_ARGUMENT( `Missing required argument "${cmd.args[i].name}"`, { name: 'args', scope: 'Parser.parse', value: cmd.args[i] } ); } } } if (required.size) { throw E.MISSING_REQUIRED_OPTION( `Missing ${required.size} required option${required.size === 1 ? '' : 's'}:`, { name: 'options', scope: 'Parser.parse', required: required.values() } ); } } results._ = _; results.argv = argv; results.cmd = cmd; results.cli = cli; results.contexts = contexts; results.parentContextNames = opts.parentContextNames; results.unknown = unknown; // check if we haven't already errored if (results.exitCode() === undefined) { // determine the command to run if (this.help && argv.help && (!cmd.isExtension || cmd.isCLIKitExtension)) { // disable the built-in help if the help is to be rendered remotely // note: the current `cmd` could be a command under an extension, so we call // `cmd.prop()` to scan the command's parents to see if this command is // actually remote if (!cmd.prop('remoteHelp')) { log(`Selected help command, was "${cmd.name}"`); results.cmd = this.commands.get('help'); contexts.unshift(results.cmd); } } else if (typeof this.defaultCommand === 'string' && ( // if we don't have an action or command, then do the default command !(cmd instanceof Command) || // if we have a command, but the command does not have an action, then do // the default command (typeof cmd.action !== 'function' && (!(cmd.action instanceof Command) || typeof cmd.action.action !== 'function') ) ) && (!cmd.prop('remoteHelp') || this.defaultCommand !== 'help') ) { log(`Selected default command: ${highlight(this.defaultCommand)}`); results.cmd = this.commands.get(this.defaultCommand); if (!(results.cmd instanceof Command)) { throw E.DEFAULT_COMMAND_NOT_FOUND(`The default command "${this.defaultCommand}" was not found!`); } contexts.unshift(results.cmd); } // now that we've made it past the parsing and validation, we are going to execute // the command and thus we want to turn off show help on error unless the error // explicitly requests help to be shown showHelpOnError = false; // handle the banner await bannerHook(results); results = await this.hook('exec', async results => { // execute the command if (results.cmd && typeof results.cmd.action === 'function') { log(`Executing command: ${highlight(results.cmd.name)}`); results.result = await results.cmd.action.call(results.cmd, results); } else if (results.cmd && results.cmd.action instanceof Command && typeof results.cmd.action.action === 'function') { // I think this is related to the legacy extension stuff... log(`Executing command: ${highlight(results.cmd.action.name)} (via ${highlight(results.cmd.name)})`); results.result = await results.cmd.action.action.call(results.cmd.action, results); } else if (typeof this.defaultCommand === 'function') { log(`Executing default command: ${highlight(this.defaultCommand.name || 'anonymous')}`); results.result = await this.defaultCommand.call(this.defaultCommand, results); } else { log('No command to execute, returning parsed arguments'); } return results; })(results); } process.exitCode = results.exitCode(); return results; } catch (err) { error(err.stack || err.message || err.toString() || 'Unknown error'); if (err.json === undefined && results.cmd?.prop('jsonMode')) { err.json = true; } else { // the banner rendered during an error does not fire the hook await renderBanner(results); } const help = this.help && (showHelpOnError !== false || err.showHelp) && this.commands.get('help'); if (help) { results.contexts = err.contexts || parser.contexts || [ this ]; results.err = err; results.result = await help.action(results); process.exitCode = results.exitCode(); return results; } throw err; } } }