diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85bdee1..1fcc847 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,20 +32,24 @@ jobs: name: release needs: test runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + issues: write + pull-requests: write steps: - name: Checkout uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 22 + node-version: 24 - run: npm install - name: Publish run: npm run release env: - GITHUB_TOKEN: ${{ secrets.PACKAGES_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GIT_AUTHOR_NAME: 'Dependant Bot' GIT_AUTHOR_EMAIL: 'release-bot@codedependant.net' GIT_COMMITTER_NAME: 'Dependant Bot' GIT_COMMITTER_EMAIL: 'release-bot@codedependant.net' - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index ad2d935..744354a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ cli.js pnpm-lock.yaml .tap -.tap-output/ +.tap-out +.vimspector.json diff --git a/README.md b/README.md index 2eeace6..234632b 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,6 @@ name | required | type | description **filter** | `false` | `function` | Receives the user input and return the filtered value to be used **inside** the program. The value returned will be added to the Answers hash. **required_with** | `false` | `Array` | A non-empty array which says that if the flag is set, then the specified other flags must also be set, i.e. "mutual inclusion." **required_without** | `false` | `Array` | A non-empty array which says that if the flag is set, then none of the other specified flags may also be set, i.e. "mutual exclusion." -**affirmative** | `false` | `String` | **interactive mode only** For `Boolean` flags, this is the value to display when the flag is true (default is `yes`) -**negative** | `false` | `String` | **interactive mode only** For `Boolean` flags, this is the value to display when the flag is true (default is `no`) ### Nested Flags diff --git a/eslint.config.js b/eslint.config.js index 6adce93..cde2123 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ const logdna = require('eslint-config-logdna') module.exports = defineConfig([ { 'extends': [logdna] - , 'files': ['lib/**/*.js', '*.js', 'test/**/*.js'] + , 'files': ['lib/**/*.js', 'test/**/*.js', 'index.js'] , 'languageOptions': { ecmaVersion: 2022 , sourceType: 'commonjs' diff --git a/examples/commands/hello.js b/examples/commands/hello.js index 6dad62b..6279311 100644 --- a/examples/commands/hello.js +++ b/examples/commands/hello.js @@ -27,9 +27,7 @@ module.exports = new cli.Command({ 'type': Boolean , 'shorthand': 'e' , 'description': 'Say hello in a very excited manner' - , 'default': true - , 'affirmative': 'Yes' - , 'negative': 'Nope' + , 'default': false } , volume: { @@ -39,13 +37,6 @@ module.exports = new cli.Command({ , 'default': 'normal' , 'shorthand': 'v' } - , things: { - type: String - , description: 'which thing' - , choices: ['one', 'two', 'three'] - , multi: true - , shorthand: 't' - } } , onContent: (content) => { // command success diff --git a/examples/commands/sub/hub.js b/examples/commands/sub/hub.js index 3595542..55afc6d 100644 --- a/examples/commands/sub/hub.js +++ b/examples/commands/sub/hub.js @@ -13,8 +13,6 @@ module.exports = new cli.Command({ 'type': Boolean , 'default': true , 'description': 'do you like hub' - , affirmative: 'I think so' - , negative: 'i do not think so' } } , async run(cmd, data) { diff --git a/gh-pages/guides/commands.md b/gh-pages/guides/commands.md index 50c5203..9a9aec6 100644 --- a/gh-pages/guides/commands.md +++ b/gh-pages/guides/commands.md @@ -168,8 +168,6 @@ name | required | type | description **filter** | `false` | `function` | **interactive mode only** Receives the user input and return the filtered value to be used **inside** the program. The value returned will be added to the Answers hash. **required_with** | `false` | `Array` | A non-empty array which says that if the flag is set, then the specified other flags must also be set, i.e. "mutual inclusion." **required_without** | `false` | `Array` | A non-empty array which says that if the flag is set, then none of the other specified flags may also be set, i.e. "mutual exclusion." -**affirmative** | `false` | `String` | **interactive mode only** For `Boolean` flags, this is the value to display when the flag is true (default is `yes`) -**negative** | `false` | `String` | **interactive mode only** For `Boolean` flags, this is the value to display when the flag is true (default is `no`) ### Types diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..6415750 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,19 @@ +{ + "exclude": [ + "deployment", + "compose" + ], + "include": [ + "lib/**/*.js", + "*.js" + ], + "compilerOptions": { + "baseUrl": ".", + "moduleResolution": "Node", + "checkJs": false, + "target": "ES2025", + "module": "CommonJS", + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + } +} diff --git a/lib/command/flag-to-prompt.js b/lib/command/flag-to-prompt.js index 03e5c90..8aa099a 100644 --- a/lib/command/flag-to-prompt.js +++ b/lib/command/flag-to-prompt.js @@ -5,20 +5,23 @@ const invertWhen = require('./invert-when') module.exports = flagToPrompt +function toChoice(choice) { + return typeof choice === 'string' ? {name: choice, value: choice} : choice +} + function flagToPrompt(name, opt = {}) { const display = name.replace(':', ' ') - const t = flagType(opt) + const flag_type = flagType(opt) return { - type: t - , name: name - , message: display + ': ' + (opt.description || '(no description)') - , choices: opt.choices - , initial: opt.default || null - , skip: invertWhen(opt.when) - , result: opt.filter - , format: opt.transformer - , affirmative: opt.affirmative - , negative: opt.negative + 'type': flag_type + , 'name': name + , 'message': display + ': ' + (opt.description || '(no description)') + , 'choices': Array.isArray(opt.choices) ? opt.choices.map(toChoice) : undefined + , 'default': opt.default || null + , 'skip': invertWhen(opt.when) + , 'filter': opt.filter + , 'transformer': opt.transformer + , 'mask': flag_type === 'password' ? true : undefined } } diff --git a/lib/command/flag-type.js b/lib/command/flag-type.js index 4363cf3..4088522 100644 --- a/lib/command/flag-type.js +++ b/lib/command/flag-type.js @@ -10,16 +10,11 @@ function flagType(flag) { , multi: true }) } - if (flag.type === Boolean) { - if (flag.affirmative && flag.negative) { - return 'toggle' - } - return 'confirm' - } + if (flag.type === Boolean) return 'confirm' if (flag.type === Number) return 'number' if (flag.mask) return 'password' if (flag.choices) { - if (flag.multi) return 'multiselect' + if (flag.multi) return 'checkbox' return 'select' } diff --git a/lib/command/index.d.ts b/lib/command/index.d.ts new file mode 100644 index 0000000..30bfd7b --- /dev/null +++ b/lib/command/index.d.ts @@ -0,0 +1,123 @@ +/** + * Base command class for creating interactive cli programs + * @module module:seeli/lib/command + * @author Eric Satterwhite + */ +declare class Command extends Map { + /** + * Constructs and returns the final command usage + */ + get usage(): string; + + /** + * The description of the command + */ + get description(): string; + + /** + * The final parsed out command line input as key/value pairs + */ + get argv(): any; + + /** + * Constructs and returns an object of flags and their types for consumption by the command + */ + get conf(): any; + + /** + * Maps and returns any shorthand switches to their parent flags for consumption by the command + */ + get shorthands(): any; + + /** + * Merges passing in object as configuration overrides + * @param options Configuration overrides to set + */ + setOptions(...opts: any[]): Command; + + /** + * Dispatches an event for each flag that has the event flag enabled + */ + dispatch(): Command; + + /** + * Method used to setup and execute the commands interactive mode + * @param cmd Optional argument for your command specific usage + * @param callback An optional callback to be executed when the command is complete. + */ + interactive(cmd?: any): Promise; + + /** + * Resets the internal command cache to its internal state + * @chainable + * @return Command + */ + reset(): Command; + + /** + * Executes the command as defined + * @param cmd Optional argument for your command specific usage + * @param callback An optional callback to be executed when the command is complete. + * Will be passed the contents return by the command + * @return String|undefined Will return the result from the command specific run directive if there is any. + */ + run(cmd?: any, depth?: number): Promise; + + /** + * Validates the current data set before running the command + * @param command The name of the command being executed + */ + validate(command?: string): void; + + /** + * Pass through function to inquirer for prompting input at the terminal + * @param options Inquirer prompt options + * @returns Promise object representing the end user input from the question + */ + prompt(options: any): Promise; + + /** + * Colorizes a text blob + * @param color The color to use. can be one of `red`, `blue`,`green`, `yellow`,`bold`, `grey`, `dim`, `black`, `magenta`, `cyan` + * @param text text to colorize + * @returns colorized version of the text + */ + colorize(color: string, text: string): string; + + /** + * Registers a new sub command + * @param command The command to register + */ + use(command: Command): Command; + + /** + * Convert all registered flags to inquierer compatible prompt objects + * @returns array of inquirer prompt objects + */ + toPrompt(): any[]; + + /** + * Get all available flags for this command + */ + get flags(): string[]; + + /** + * Get the command tree structure + */ + get tree(): any; + + /** + * Run a command directly + * @param args Arguments to pass to the command + */ + static run(...args: any[]): Promise; + + /** + * Constructor for Command class + * @param options instance configuration + */ + constructor(...options: any[]); +} + +export = Command; + diff --git a/lib/command/index.js b/lib/command/index.js index 5c1a560..f5f38ad 100644 --- a/lib/command/index.js +++ b/lib/command/index.js @@ -7,7 +7,7 @@ * @requires options * @requires events * @requires os - * @requires inquirer + * @requires @inquirer/prompts * @requires nopt * @requires chalk * @requires debug @@ -22,7 +22,6 @@ **/ const tty = require('node:tty') -const Equirer = require('enquirer') const {default: spinners} = require('cli-spinners') const {default: ora} = require('ora') const nopt = require('nopt') @@ -45,7 +44,7 @@ const colorize = require('../colorize') const stop_flags = new Set(['help', 'interactive', 'skip', 'color']) const ARGV = 'argv' const on_exp = /^on([A-z])/ -const kPrompt = Symbol.for('kPrompt') +const PROMPTS = require('./prompt-map') function noop() { return Promise.resolve(null) @@ -93,6 +92,10 @@ const defaults = { * @param {String} [options.ui="dots"] ui progress indicator * @param {Object} [options.flags] cli flags. top level keys will be used as the long hand flag * @param {Function} [options.run] A function that will be used as the primary drive of the command. It should perform what ever action the command was intended to do + * @param {Boolean} [options.strict=false] Enable strict mode for flag validation + * @param {Boolean} [options.interactive=true] Enable interactive mode by default + * @param {String} [options.name=null] The name of the command + * @param {Array} [options.commands=[]] Subcommands to register * @example var hello = new Command({ options:{ description:"diaplays a simple hello world command" @@ -152,7 +155,6 @@ class Command extends Registry { this._optcache = null this.parsed = null this.options = Object.create(null) - this[kPrompt] = new Equirer() this.reset() const subcommands = options.reduce((acc, opts) => { @@ -166,11 +168,6 @@ class Command extends Registry { this.setOptions(defaults, ...options) - // Allow injecting a custom enquirer instance for testing - if (this.options.enquirer) { - this[kPrompt] = this.options.enquirer - } - this._shcache = this.shorthands const spinner = spinners[this.options.ui] @@ -199,6 +196,7 @@ class Command extends Registry { * constructs and retuns the final command usage * @property usage * @type string + * @returns {string} The formatted usage string **/ get usage() { return usage.from(this) @@ -208,6 +206,7 @@ class Command extends Registry { * The description of the command * @property description * @type String + * @returns {string} The command description **/ get description() { return this.options.description @@ -218,6 +217,7 @@ class Command extends Registry { * @property module:lib/command.Command.argv * @type object * @throws module:seeli/lib/exceptions/UnknownFlagException + * @returns {object} The parsed command line arguments **/ get argv() { if (this.parsed) return this.parsed @@ -376,18 +376,18 @@ class Command extends Registry { if (current.interactive === false) continue if (Array.isArray(current.type)) { const previous = toArray(answers[flag]) - const answer = (await this.ask(flag, current)) + const [answer] = (await this.ask(flag, current)) previous.push(...toArray(answer)) answers[flag] = previous continue } const arg = toQuestion(flag, current, answers) const res = await this.prompt(arg) - answers[flag] = res[flag] + Object.assign(answers, res) // If the flag has a validation function, call it now to possibly terminate early. // Pass the shape of `this.parsed` to match the parameter from non-interactive mode. if (typeof current.validate === 'function') { - const is_valid = current.validate.call(this, {...this.parsed, ...answers}) + const is_valid = await current.validate.call(this, {...this.parsed, ...answers}) throwIfFlagFailedValidation(flag, is_valid) } } @@ -641,9 +641,26 @@ class Command extends Registry { * @param {Object} options Inquirer prompt options * @returns {Promise} Promise object representing the end user input from the question **/ - prompt(opts) { - const enquirer = this[kPrompt] - return enquirer.prompt(opts) + async prompt(opts) { + const answers = {} + const theme = conf.get('theme') || {} + for (const question of toArray(opts)) { + const activePrompt = PROMPTS.get(question.type) + try { + const value = await activePrompt({theme, ...question}) + answers[question.name] = isFunction(question.filter) + ? question.filter(value) + : value + } catch (err) { + if (err?.name === 'ExitPromptError') { + const exit = conf.get('exitOnCancel') + if (exit) return process.exit(0) + } + throw err + } + } + + return answers } /** @@ -684,7 +701,7 @@ class Command extends Registry { const question = toQuestion(name, opts) while (true) { - const answer = await this[kPrompt].prompt(question) + const answer = await this.prompt(question) const value = typecast(answer[name]) if (value === '') break if (question.type === 'number' && isNaN(value)) break @@ -744,8 +761,7 @@ class Command extends Registry { module.exports = Command /* c8 ignore start */ -function transform(input, answers, status) { - if (!status.isFinal) return input +function transform(input) { if (this.type === 'number' && isNaN(input)) return '' return chalk.cyan(input) } @@ -763,10 +779,11 @@ function toQuestion(flag, opts, answers) { // TODO(esatterwhite) // wrap validate to throw returned errors so `ask` // can return them - arg.when = opts.when ? opts.when.bind(null, answers) : undefined - arg.validate = opts.validate ? opts.validate.bind(null, answers) : undefined - arg.filter = opts.filter ? opts.filter.bind(null) : undefined - arg.transformer = opts.transformer ? opts.transformer : transform.bind(arg) + arg.when = isFunction(opts.when) ? opts.when.bind(null, answers) : undefined + arg.validate = isFunction(opts.validate) ? opts.validate.bind(null, answers) : undefined + arg.filter = isFunction(opts.filter) ? opts.filter.bind(null) : undefined + arg.transformer = isFunction(opts.transformer) ? opts.transformer : transform.bind(arg) + return arg } diff --git a/lib/command/prompt-map.js b/lib/command/prompt-map.js new file mode 100644 index 0000000..43ad6eb --- /dev/null +++ b/lib/command/prompt-map.js @@ -0,0 +1,14 @@ +'use strict' + +const {checkbox, confirm, input, number, password, select} = require('@inquirer/prompts') + +module.exports = new Map([ + ['checkbox', select] +, ['confirm', confirm] +, ['input', input] +, ['number', number] +, ['password', password] +, ['select', checkbox] +, ['multiselect', checkbox] +, ['text', input] +]) diff --git a/lib/conf.js b/lib/conf.js index 301b4f7..22e739b 100644 --- a/lib/conf.js +++ b/lib/conf.js @@ -19,6 +19,8 @@ let config = { , help: path.resolve(path.join(__dirname, 'commands', 'help')) , exitOnError: false , exitOnContent: false +, exitOnCancel: true +, theme: {} } try { @@ -33,7 +35,6 @@ try { , ...override , help: help } - debug(config) } catch (e) { debug('unable to load configuration. using config', e) } diff --git a/lib/registry.d.ts b/lib/registry.d.ts new file mode 100644 index 0000000..bd74106 --- /dev/null +++ b/lib/registry.d.ts @@ -0,0 +1,60 @@ +/** + * Command registry for seeli + * @module index.js + * @author Eric Satterwhite + * @since 9.0.0 + */ +declare class Registry extends Map { + /** + * Constructor for Registry class + */ + constructor(); + + /** + * Resolve a command from a shallow key lookup + * @param key The key to resolve + * @returns The resolved command or undefined + */ + resolveShallow(key: any): any; + + /** + * Resolve a command from a full key lookup + * @param key The key to resolve + * @returns The resolved command or undefined + */ + resolve(key: any): any; + + /** + * Register a command + * @param name The name of the command + * @param cmd The command to register + */ + register(name: string, cmd: any): void; + + /** + * Unregister a command + * @param name The name of the command to unregister + * @returns The registry instance + */ + unregister(name: string): Registry; + + /** + * Get a list of all registered commands + * @returns Array of command names + */ + list(): string[]; + + /** + * Clear all registered commands + * @returns The registry instance + */ + clear(): Registry; + + /** + * Get the set of all command names + */ + get names(): Set; +} + +export = Registry; + diff --git a/lib/seeli.d.ts b/lib/seeli.d.ts new file mode 100644 index 0000000..f726b13 --- /dev/null +++ b/lib/seeli.d.ts @@ -0,0 +1,73 @@ +/** + * Seeli Entrypoint used for registering and managing commands + * @module module:seeli/lib/seeli + * @author Eric Satterwhite + */ +declare class Seeli extends Command { + /** + * Get configuration value + * @param args Arguments to pass to config.get + */ + static get(...args: any[]): any; + + /** + * Set configuration value + * @param args Arguments to pass to config.set + */ + static set(...args: any[]): any; + + /** + * Colorize text with a specific color + * @param txt Text to colorize + * @param color Color to use + */ + static colorize(txt: string, color: string): string; + + /** + * Get the Command class + */ + static get Command(): typeof Command; + + /** + * Get the Command class + */ + get Command(): typeof Command; + + /** + * Constructor for Seeli class + * @param args Arguments to pass to the constructor + */ + constructor(...args: any[]); + + /** + * Colorize text with a specific color + * @param txt Text to colorize + * @param color Color to use + */ + colorize(txt: string, color: string): string; + + /** + * Get or set configuration values + * @param args Arguments to pass to config + */ + config(...args: any[]): any; + + /** + * Run the Seeli application + */ + run(): void; + + /** + * Reset the Seeli instance + */ + reset(): Seeli; + + /** + * Load plugins + * @param args Plugin arguments + */ + plugin(...args: any[]): Seeli; +} + +export = Seeli; + diff --git a/package.json b/package.json index 5588cb3..c10a498 100644 --- a/package.json +++ b/package.json @@ -86,13 +86,13 @@ }, "homepage": "https://github.com/esatterwhite/node-seeli", "dependencies": { + "@inquirer/prompts": "^8.3.2", "abbrev": "^4.0.0", "chalk": "^5.6.2", "cli-spinners": "^3.4.0", "cliui": "^9.0.1", "clone": "^2.1.2", "debug": "^4.3.2", - "enquirer": "^2.4.1", "mout": "^1.2.2", "nopt": "^9.0.0", "ora": "^9.3.0", @@ -102,11 +102,14 @@ }, "devDependencies": { "@codedependant/release-config-npm": "^1.0.3", - "@semantic-release/exec": "^5.0.0", + "@inquirer/testing": "^3.3.2", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^10.0.1", "@vuepress/plugin-back-to-top": "^1.5.4", "eslint": "^10.1.0", "eslint-config-logdna": "^8.0.1", - "semantic-release": "^17.4.2", + "semantic-release": "^25.0.3", "tap": "^21.6.2", "vuepress": "^1.8.2" }, diff --git a/test/command.js b/test/command.js index 9b7fed5..dfa7898 100644 --- a/test/command.js +++ b/test/command.js @@ -4,12 +4,14 @@ const fs = require('fs') const path = require('path') const assert = require('assert') const strip = require('strip-ansi') +const {input} = require('@inquirer/prompts') +const {render} = require('@inquirer/testing') const cli = require('../') const Command = require('../lib/command') const commands = require('../lib/commands') +const PROMPTS = require('../lib/command/prompt-map') const Help = require('../lib/commands/help') const test = require('tap').test - test('command', async (t) => { // description parsing @@ -581,26 +583,32 @@ test('command', async (t) => { }) t.test('manual prompt', async (t) => { - const mockEnquirer = { - prompt: async () => { - // Simulate user input - return the expected values - return {option: 'yes'} - } - } - const cmd = new Command({ - enquirer: mockEnquirer - , run: async function() { - const value = await this.prompt({ + run: async function() { + const answer = await this.prompt({ type: 'input' , name: 'option' , message: 'do you want this option' }) - return value + return answer } }) + const prompt = cmd.prompt + + t.teardown(() => { + cmd.prompt = prompt + }) + + cmd.prompt = async function(options) { + const {answer, events} = await render(input, { + message: options.message + }) + events.type('yes') + events.keypress('enter') + return {[options.name]: await answer} + } const output = await cmd.run() t.match(output, {option: 'yes'}) }) @@ -617,23 +625,8 @@ test('command', async (t) => { }) t.test('interactive command success', async (t) => { - const mockEnquirer = { - prompt: async (question) => { - switch (question.name) { - case 'fake': { - return {fake: ['yes']} - } - case 'other': { - return {other: ''} - } - - } - return {} - } - } const cmd = new Command({ interactive: true - , enquirer: mockEnquirer , strict: true , args: ['--interactive'] , requires_one: ['fake', 'other'] @@ -658,6 +651,11 @@ test('command', async (t) => { return ['yes'] } + cmd.prompt = function(arg) { + // Mock the prompt to return predefined answers + const answer = arg.name === 'fake' ? 'yes' : undefined + return Promise.resolve({[arg.name]: answer}) + } const answers = await cmd.run() t.match(answers, { fake: ['yes'] @@ -666,24 +664,8 @@ test('command', async (t) => { }) t.test('interactive command success with `required_with`', async (t) => { - const mockEnquirer = { - prompt: async (question) => { - switch (question.name) { - case 'fake': { - return {fake: 'yes'} - } - case 'other': { - return {other: 'yes'} - } - - } - return {} - } - } - const cmd = new Command({ interactive: true - , enquirer: mockEnquirer , strict: true , args: ['--interactive'] , flags: { @@ -702,6 +684,31 @@ test('command', async (t) => { } }) + const prompt = cmd.prompt + const input = PROMPTS.get('input') + + // shim the input prompt + PROMPTS.set('input', function(options) { + const promise = render(input, options) + return promise + }) + + t.teardown(() => { + cmd.prompt = prompt + PROMPTS.set('input', input) + }) + + cmd.prompt = async function(arg) { + // returns the wrapped prompts for progmattic control + const result = await prompt.call(cmd, arg) + + // Mock the prompt to return predefined answers + result[arg.name].events.type('yes') + result[arg.name].events.keypress('enter') + const ans = await result[arg.name].answer + return {[arg.name]: ans} + } + const answers = await cmd.run() t.match(answers, { fake: 'yes' @@ -710,23 +717,8 @@ test('command', async (t) => { }) t.test('interactive command that fails with required_without', async (t) => { - const mockEnquirer = { - prompt: async (question) => { - switch (question.name) { - case 'fake': { - return {fake: 'yes'} - } - case 'other': { - return {other: 'yes'} - } - - } - return {} - } - } const cmd = new Command({ interactive: true - , enquirer: mockEnquirer , strict: true , args: ['--interactive'] , flags: { @@ -745,6 +737,29 @@ test('command', async (t) => { } }) + const prompt = cmd.prompt + const input = PROMPTS.get('input') + + // shim the input prompt + PROMPTS.set('input', function(options) { + return render(input, options) + }) + + t.teardown(() => { + cmd.prompt = prompt + PROMPTS.set('input', input) + }) + + cmd.prompt = async function(arg) { + // returns the wrapped prompts for progmattic control + const result = await prompt.call(cmd, arg) + + // Mock the prompt to return predefined answers + result[arg.name].events.type('yes') + result[arg.name].events.keypress('enter') + return {[arg.name]: await result[arg.name].answer} + } + t.rejects(cmd.run(), { code: 'EMUTEXCLUSIVE' , message: '`other` is mutually exclusive with fake. Erroneously set: fake' @@ -752,23 +767,8 @@ test('command', async (t) => { }) t.test('interactive command that fails with required_with', async (t) => { - const mockEnquirer = { - prompt: async (question) => { - switch (question.name) { - case 'fake': { - return {} - } - case 'other': { - return {other: 'yes'} - } - - } - return {} - } - } const cmd = new Command({ interactive: true - , enquirer: mockEnquirer , strict: true , args: ['--interactive'] , flags: { @@ -786,6 +786,32 @@ test('command', async (t) => { return data } }) + + const prompt = cmd.prompt + const input = PROMPTS.get('input') + + // shim the input prompt + PROMPTS.set('input', function(options) { + return render(input, options) + }) + + t.teardown(() => { + cmd.prompt = prompt + PROMPTS.set('input', input) + }) + + cmd.prompt = async function(arg) { + const input = arg.name === 'fake' ? '' : 'yes' + + // returns the wrapped prompts for progmattic control + const result = await prompt.call(cmd, arg) + + result[arg.name].events.type(input) + result[arg.name].events.keypress('enter') + // Mock the prompt to return predefined answers + return {[arg.name]: await result[arg.name].answer} + } + t.rejects(cmd.run(), { code: 'EMUTINCLUSIVE' , message: '`other` is mutually inclusive with fake. Not set: fake' @@ -794,36 +820,25 @@ test('command', async (t) => { t.test('interactive command with custom flag `validate`', async (t) => { let validate_call_count = 0 - const mockEnquirer = { - prompt: async (question) => { - switch (question.name) { - case 'other': { - return {other: 'my_string'} - } - } - return {} - } - } const cmd = new Command({ interactive: true - , enquirer: mockEnquirer , strict: true , args: ['--interactive'] , flags: { other: { type: String , description: 'other flag' - , validate: (cmd) => { - t.match(cmd, { - interactive: true + , validate: async (value) => { + t.match(value, { + other: 'my_string' , argv: { remain: [] , cooked: ['--interactive'] , original: ['--interactive'] } + , interactive: true , color: true - , other: 'my_string' }, 'cmd appears to be `this.parsed`') validate_call_count += 1 } @@ -834,6 +849,28 @@ test('command', async (t) => { } }) + const prompt = cmd.prompt + const input = PROMPTS.get('input') + + // shim the input prompt + PROMPTS.set('input', function(options) { + return render(input, options) + }) + + t.teardown(() => { + cmd.prompt = prompt + PROMPTS.set('input', input) + }) + + cmd.prompt = async function(arg) { + const answer = 'my_string' + const result = await prompt.call(cmd, arg) + + result[arg.name].events.type(answer) + result[arg.name].events.keypress('enter') + return {[arg.name]: await result[arg.name].answer} + } + const answer = await cmd.run() t.equal(answer, 'my_string', 'run function produced the right result') t.equal(validate_call_count, 1, 'validate function was only called once') @@ -896,19 +933,18 @@ test('command', async (t) => { const out = cmd.toPrompt() t.ok(Array.isArray(out), 'output is an array') t.equal(out.length, 2, 'expected prompt count') - t.match(out, [{ type: 'confirm' , message: 'one: boolean flag' , skip: Function , validate: undefined - , result: undefined + , filter: undefined }, { type: 'number' , message: /no description/ig - , when: undefined + , skip: undefined , validate: undefined - , result: Function + , filter: Function }]) }) diff --git a/test/flag-to-prompt.js b/test/flag-to-prompt.js index bdb35af..d605c42 100644 --- a/test/flag-to-prompt.js +++ b/test/flag-to-prompt.js @@ -33,12 +33,12 @@ test('flagToPrompt', async (t) => { t.match(out, { name: 'foobar' - , type: 'multiselect' + , type: 'checkbox' , message: 'foobar: hello world' - , choices: ['one'] + , choices: [{name: 'one', value: 'one'}] , skip: Function - , result: Function - , format: Function + , filter: Function + , transformer: Function }) }) }).catch(threw) diff --git a/test/flag-type.js b/test/flag-type.js index 6d66429..1a0879e 100644 --- a/test/flag-type.js +++ b/test/flag-type.js @@ -16,7 +16,7 @@ test('flagType', async (t) => { , [{type: [Number, Array]}, 'number', '[Number, Array] === number'] , [{type: String, mask: true}, 'password', 'mask=true === password'] , [{type: String, choices: []}, 'select', 'choices === select'] - , [{type: String, choices: [], multi: true}, 'multiselect', 'choices + multi === multiselect'] + , [{type: String, choices: [], multi: true}, 'checkbox', 'choices + multi === checkbox'] // eslint-disable-line max-len , [{type: Function}, 'input', 'unexpected type === input'] ]