UNPKG

@kitmi/jacaranda

Version:

JavaScript application framework

560 lines (462 loc) 19.4 kB
/** * Parse command line arguments using minimist and store the parsed object into app.argv, and add app.showUsage() helper function * @module Feature_CommandLine */ import path from 'node:path'; import { _, pushIntoBucket, eachAsync_, findAsync_ } from '@kitmi/utils'; import { ApplicationError, InvalidConfiguration } from '@kitmi/types'; import Feature from '../Feature'; import minimist from 'minimist'; function translateMinimistOptions(opts) { let m = {}; _.forOwn(opts, (detail, name) => { if (detail.bool) { pushIntoBucket(m, 'boolean', name); } else { pushIntoBucket(m, 'string', name); } if ('default' in detail) { _.set(m, `default.${name}`, detail.default); } if (detail.alias) { _.set(m, `alias.${name}`, detail.alias); } }); return m; } function optionDecorator(name) { return name.length == 1 ? '-' + name : '--' + name; } let gArgv = process.argv.slice(2); /** * Error caused by command line arguments. * @class * @extends ApplicationError */ class CommandLineArgumentError extends ApplicationError { /** * @param {string} message - Error message * @param {string} name - The related config item * @param {boolean} nonOption - Whether it is an option */ constructor(message, name, nonOption) { super(message, 'E_CLI_INVALID_ARG', { name, nonOption }); } } /** * Command line helper object. */ class CommandLine { constructor(app, usage) { this.app = app; this.usage = usage; this.parse(usage.options); } injectUsage(injects) { this.injects = injects; } parse(options) { const minimistOpts = translateMinimistOptions(options); this.argv = minimist(gArgv, minimistOpts); } option(name) { return this.argv[name]; } arg(name) { if (this.args?.[name]) return this.args[name]; let index = _.findIndex(this.usage.arguments, (arg) => arg.name === name); if (index === -1 || this.argv._.length <= index) { return undefined; } this.args || (this.args = {}); return (this.args[name] = this.argv._[index]); } updateOption(name, value) { this.argv[name] = value; let opts = this.usage.options[name]; if (opts.alias) { _.each(opts.alias, (a) => { this.argv[a] = value; }); } } async valueOrFunctionCall_(functor) { if (typeof functor === 'function') { return await functor(this); } return functor; } async doFilter_(name, opt, argIndex) { if (opt.filter) { if (typeof argIndex === 'undefined') { if (!(typeof opt.filter !== 'function')) { throw new InvalidConfiguration( `The "filter" in the inquirer option for argument option "${name}" should be a function!`, this.app, `commandLine.options[${name}].filter` ); } this.updateOption(name, await opt.filter(this.argv[name], this)); } else { if (!(typeof opt.filter !== 'function')) { throw new InvalidConfiguration( `The "filter" in the inquirer option for argument value "${name}" at position ${argIndex} should be a function!`, this.app, `commandLine.arguments[${argIndex}].filter` ); } this.argv._[argIndex] = await opt.filter(this.argv._[argIndex], this); } } } argExist(name, argIndex) { return typeof argIndex === 'undefined' ? name in this.argv : this.argv._.length > argIndex; } async inquire_(startIndex = 0) { const inquirer = await this.app.tryRequire_('inquirer', true); const doInquire_ = (item, argIndex) => inquirer.prompt([item]).then((answers) => { // eslint-disable-next-line no-undef console.log(); _.forOwn(answers, (ans, name) => { if (typeof argIndex === 'undefined') { this.updateOption(name, ans); } else { if (this.argv._.length !== argIndex) { throw new CommandLineArgumentError( `Invalid argument value "${name}" at position ${argIndex}!`, name, true ); } this.argv._ = this.argv._.concat([ans]); } }); }); const prepareInquire_ = async (opts, name, argIndex) => { let argExists = this.argExist(name, argIndex); if ('inquire' in opts && !argExists) { //need inquire and the value not given through command line let inquire = await this.valueOrFunctionCall_(opts.inquire); if (inquire) { let type; let q = { name: name, message: opts.promptMessage || opts.desc }; if (opts.promptType) { type = opts.promptType; if (type === 'list' || type === 'rawList' || type === 'checkbox' || type === 'expand') { if (!opts.choicesProvider) { throw new InvalidConfiguration( typeof argIndex === 'undefined' ? `Missing "choicesProvider" in the inquirer option for argument option "${name}"!` : `Missing "choicesProvider" in the inquirer option for argument value "${name}" at postion ${argIndex}!`, this.app, typeof argIndex === 'undefined' ? `commandLine.options[${name}].choicesProvider` : `commandLine.arguments[${argIndex}].choicesProvider` ); } q.choices = await this.valueOrFunctionCall_(opts.choicesProvider); } } else if (opts.bool) { type = 'confirm'; } else { type = 'input'; } q.type = type; if ('promptDefault' in opts) { q.default = await this.valueOrFunctionCall_(opts.promptDefault); } await doInquire_(q, argIndex); await this.doFilter_(name, opts, argIndex); if (opts.afterInquire) { const ignoreRest = await opts.afterInquire(this); if (ignoreRest) { return true; } } } } else if (argExists) { await this.doFilter_(name, opts, argIndex); if (opts.onArgumentExists) { const ignoreRest = await opts.onArgumentExists(this); if (ignoreRest) { return true; } } } if ((await this.valueOrFunctionCall_(this.usage.showArguments)) && this.argExist(name, argIndex)) { if (typeof argIndex === 'undefined') { // eslint-disable-next-line no-undef console.log('option', name, `(${opts.desc})`, ':', this.argv[name]); } else { // eslint-disable-next-line no-undef console.log(`<${name}>`, ':', this.argv._[argIndex]); } } }; if (!_.isEmpty(this.usage.arguments)) { await findAsync_(this.usage.arguments.slice(startIndex), async (argOpt, index) => { let { name, ...opts } = argOpt; return prepareInquire_(opts, name, startIndex + index); }); } return ( _.isEmpty(this.usage.options) || findAsync_(this.usage.options, (opts, name) => prepareInquire_(opts, name)) ); } async preValidate_(argOffset) { let silentMode = this.usage.silentMode; if (silentMode && typeof silentMode === 'function') { silentMode = silentMode(this); } this.silentMode = silentMode; if (silentMode) { await this.processSilentModeArguments_(argOffset); } else { if (!argOffset) { this.showBannar(); } await this.inquire_(argOffset); } } /** * validate parsed and filled argument options. */ async validate_() { const checkRequire_ = (opts) => this.valueOrFunctionCall_(opts.required); let errors = []; if (!_.isEmpty(this.usage.arguments)) { let argNum = this.argv._.length; if (argNum < this.usage.arguments.length) { let args = []; let i = 0; await eachAsync_(this.usage.arguments, async (arg) => { let required = await checkRequire_(arg); if (required) { if (i >= argNum) { let msg = `Missing required argument "${arg.name}"!`; if (this.usage.showUsageOnError) { errors.push(msg); } else { throw new CommandLineArgumentError(msg, arg.name, true); } } else { args.push(this.argv._[i++]); } } }); this.argv._ = args; } } this.usage.options && (await eachAsync_(this.usage.options, async (opts, name) => { let required = await checkRequire_(opts); if (required && !(name in this.argv)) { let msg = `Missing required argument option of "${name}"!`; if (this.usage.showUsageOnError) { errors.push(msg); } else { throw new CommandLineArgumentError(msg, name); } } })); if (errors.length > 0) { this.showUsage({ afterBanner: () => 'Error(s):\n' + errors.map((msg) => ' - ' + msg).join('\n') + '\n\n', }); process.exit(1); } } async processSilentModeArguments_(argOffset = 0) { if (this.usage.arguments) { await findAsync_(this.usage.arguments.slice(argOffset), async (arg, index) => { index += argOffset; if (this.argv._.length <= index) { if (arg.hasOwnProperty('silentModeDefault')) { for (let i = this.argv._.length; i < index; i++) { this.argv._.push(undefined); } this.argv._.push(await this.valueOrFunctionCall_(arg.silentModeDefault)); } } else { const { name, ...opts } = arg; await this.doFilter_(name, opts, index); if (opts.onArgumentExists) { const ignoreRest = await opts.onArgumentExists(this); if (ignoreRest) { return true; } } } }); } if (this.usage.options) { await eachAsync_(this.usage.options, async (opts, name) => { if (this.argExist(name)) { await this.doFilter_(name, opts); if (opts.onArgumentExists) { const ignoreRest = await opts.onArgumentExists(this); if (ignoreRest) { return true; } } } else if (opts.hasOwnProperty('silentModeDefault')) { this.updateOption(name, await this.valueOrFunctionCall_(opts.silentModeDefault)); } }); } } getBanner() { if (this.usage.banner) { let banner = ''; if (typeof this.usage.banner === 'function') { banner += this.usage.banner(this); } else if (typeof this.usage.banner === 'string') { banner += this.usage.banner; } else { throw new InvalidConfiguration( 'Invalid banner value of commandLine feature.', this.app, `commandLine.banner` ); } banner += '\n'; return banner; } return undefined; } getUsage(injects) { injects = { ...this.injects, ...injects }; let usage = ''; let banner = !this.bannerShown && this.getBanner(); if (banner) { usage += banner + '\n'; } if (injects && injects.afterBanner) { usage += injects.afterBanner(); } let fmtArgs = ''; if (!_.isEmpty(this.usage.arguments)) { fmtArgs = ' ' + this.usage.arguments.map((arg, index) => (this.argv._.length > index ? this.argv._[index] : (arg.required ? `<${arg.name}>` : `[${arg.name}]`))).join(' '); } usage += `Usage: ${this.usage.program || path.basename(process.argv[1])}${fmtArgs} [options]\n\n`; if (injects && injects.afterCommandLine) { usage += injects.afterCommandLine(); } if (!_.isEmpty(this.usage.options)) { usage += `Options:\n`; _.forOwn(this.usage.options, (opts, name) => { let line = ' ' + optionDecorator(name); if (opts.alias) { line += _.reduce(opts.alias, (sum, a) => sum + ', ' + optionDecorator(a), ''); } line += '\n'; line += ' ' + opts.desc + '\n'; if ('default' in opts) { line += ' default: ' + opts.default.toString() + '\n'; } if (opts.required) { if (typeof opts.required === 'function') { line += ' conditional\n'; } else { line += ' required\n'; } } if (opts.choicesProvider && Array.isArray(opts.choicesProvider)) { line += ' available values:\n'; opts.choicesProvider.forEach((choice) => { line += typeof choice === 'string' ? ` "${choice}"\n` : ` "${choice.value}": ${choice.name}\n`; }); } line += '\n'; usage += line; }); } if (injects && injects.afterOptions) { usage += injects.afterOptions(); } return usage; } showBannar() { let banner = this.getBanner(); if (banner) { // eslint-disable-next-line no-undef console.log(banner); this.bannerShown = true; } } showUsage(injects) { // eslint-disable-next-line no-undef console.log(this.getUsage(injects)); } } export default { /** * This feature is loaded at initialization stage * @member {string} */ stage: Feature.INIT, packages: ['minimist', 'inquirer'], /** * Load the feature * @param {App} app - The cli app module object * @param {object} usageOptions - Options for the feature * @property {string} [usageOptions.banner] - Banner message or banner generator function * @property {string} [usageOptions.program] - Executable name * @property {array} [usageOptions.arguments] - Command line arguments, identified by the position of appearance * @property {object} [usageOptions.options] - Command line options * @property {boolean|function} [usageOptions.silentMode] - Whether to run in silient mode, default false * @property {boolean|function} [usageOptions.nonValidationMode] - Whether to run validation * @property {boolean} [usageOptions.showUsageOnError] * * @example * options: { [argumentKey]: { * desc, // {string} - description * alias, // {array.<string>} - alias array * bool, // {boolean} - whether it is a boolean value * default, // {*} - default value * inquire, // {boolean | function(cli).<boolean>} - whether to enable interactive query * promptMessage, // {string} - prompt message for query, will use desc if not set * promptType, // {string} - prompt type, can be one of [ input, number, confirm, list, rawlist, expand, checkbox, password, editor ] * promptDefault, // {* | function(cli).<*>} - default value appeared on query or a async function to return the default value * choicesProvider, // {array | function(cli).<array> | function.<function(string).<array>>} - required for prompt type list, rawlist, expand, checkbox * filter, // {function(argv, cli).<argv>} - filter to process the argument value * afterInquire, // {function} - after inquire hook, * onArgumentExists, // {function} - when argument exists, * silentModeDefault // {*} - default value when run in silient mode, * } } * * Note: If you need to override option value during parsing, you should call `updateOption(name, value)` to automatically update all alias as well * * @returns {Promise.<*>} */ load_: async (app, options, name) => { const { testArgs, ...usageOptions } = app.featureConfig( options, { schema: { testArgs: { type: 'array', optional: true }, }, keepUnsanitized: true, }, name ); if (testArgs) { gArgv = testArgs; } app.commandLine = new CommandLine(app, usageOptions); await app.commandLine.preValidate_(); if (usageOptions.beforeValidate) { await usageOptions.beforeValidate(app.commandLine); } let nonValidationMode = usageOptions.nonValidationMode; if (nonValidationMode && typeof nonValidationMode === 'function') { nonValidationMode = nonValidationMode(app.commandLine); } app.commandLine.nonValidationMode = nonValidationMode; if (!nonValidationMode) { await app.commandLine.validate_(); } }, };