diff --git a/.ai/context.md b/.ai/context.md new file mode 100644 index 0000000..ed2c2da --- /dev/null +++ b/.ai/context.md @@ -0,0 +1,30 @@ + +### your role + +You are an expert in building node.js api applications focused on command line interfaces and termainal based applications. +You have a deep knowledge of the npm packages ora, enquirer, , as well as tap for developing the best in breed node applications. + +### your mission + +Your role is to help build and maintain a node.js application seeli which focuses on flexibile and composible command line applications. + +### technology stack +- backend: node.js, enquirer, ora, debug +- testing: tap + +### coding standards +- commas should be placed at the beginning of a line rather than the end +- add jsdoc comments for modules, exported functions and classes +- follow the existing folder structure and naming conventions +- function names should be camel cased +- variable names should be snake cased +- prefer composition over inheritence +- avoid using classes unless complex state management is required +- linting rules found in the shared eslint configuration eslint-config-logdna should be followed and applied. The command 'npm run lint' should pass with an exit code of 0 +- many linting errors can be auto fixed by running `npm run lint:fix` which lints and applies fixes where possible. +- tap executes our tests. running `npm test` will run all tests. npm test wil run a specific file. all tests should exit with a code of 0 +- class names should be upper camel case, FooBar +- function names should be standard camel case, fooBar +- all other variable names should be snake case, foo_bar +- When testing, mocking should be avoided unless strictly necessary. Interfacing with a live data store, or external service is preferable + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e2d7ba..85bdee1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [20.x, 22.x, 24.x] steps: - name: Checkout @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 14 + node-version: 22 - run: npm install - name: Publish diff --git a/.gitignore b/.gitignore index de03390..ad2d935 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ cli.js *.vim pnpm-lock.yaml +.tap +.tap-output/ diff --git a/README.md b/README.md index a9629b7..2eeace6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Test + Release](https://github.com/esatterwhite/node-seeli/actions/workflows/release.yml/badge.svg)](https://github.com/esatterwhite/node-seeli/actions/workflows/release.yml) ![package dependancies](https://david-dm.org/esatterwhite/node-seeli.png) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/47a935a723c94c73bc97d749836ee489)](https://www.codacy.com/app/esatterwhite/node-seeli?utm_source=github.com&utm_medium=referral&utm_content=esatterwhite/node-seeli&utm_campaign=Badge_Grade) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0df91a318dc444e7aedcdd4c77fda673)](https://app.codacy.com/gh/esatterwhite/node-seeli/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) # seeli ( C. L. I. ) @@ -110,37 +110,36 @@ node ./cli world --name=Mark --name=Sally --no-excited -- [seeli ( C. L. I. )](#seeli--c-l-i-) -- [API](#api) - - [Seeli.Command(``)](#seelicommandcommand) - - [Command Options](#command-options) - - [Flag Options](#flag-options) - - [Nested Flags](#nested-flags) - - [`cmd` Shape](#cmd-shape) - - [Seeli.run( )](#seelirun-) - - [Seeli.list``](#seelilistarray) - - [Seeli.use( \[name ``,\] cmd `` )](#seeliuse-name-string-cmd-command-) - - [Seeli.bold( text ``)](#seelibold-text-string) - - [Seeli.green( text ``)](#seeligreen-text-string) - - [Seeli.blue( text ``)](#seeliblue-text-string) - - [Seeli.red( text ``)](#seelired-text-string) - - [Seeli.yellow( text ``)](#seeliyellow-text-string) - - [Seeli.cyan( text ``)](#seelicyan-text-string) - - [Seeli.magenta( text ``)](#seelimagenta-text-string) - - [Seeli.redBright( text ``)](#seeliredbright-text-string) - - [Seeli.blueBright( text ``)](#seelibluebright-text-string) - - [Seeli.greenBright( text ``)](#seeligreenbright-text-string) - - [Seeli.yellowBright( text ``)](#seeliyellowbright-text-string) - - [Seeli.cyanBright( text ``)](#seelicyanbright-text-string) - - [Seeli.config( key ``, value `` )](#seeliconfig-key-string-value-object-) - - [Seeli.config( opts `` )](#seeliconfig-opts-object-) - - [Config Options](#config-options) - - [Seeli.config( key `` )](#seeliconfig-key-string-) -- [Package Configuration](#package-configuration) -- [Auto Help](#auto-help) -- [Asyncronous](#asyncronous) -- [Showing Progress](#showing-progress) -- [Events](#events) +* [API](#api) + * [Seeli.Command(``)](#seelicommandcommand) + * [Command Options](#command-options) + * [Flag Options](#flag-options) + * [Nested Flags](#nested-flags) + * [`cmd` Shape](#cmd-shape) + * [Seeli.run( )](#seelirun-) + * [Seeli.list``](#seelilistarray) + * [Seeli.use( [name ``,] cmd `` )](#seeliuse-name-string-cmd-command-) + * [Seeli.bold( text ``)](#seelibold-text-string) + * [Seeli.green( text ``)](#seeligreen-text-string) + * [Seeli.blue( text ``)](#seeliblue-text-string) + * [Seeli.red( text ``)](#seelired-text-string) + * [Seeli.yellow( text ``)](#seeliyellow-text-string) + * [Seeli.cyan( text ``)](#seelicyan-text-string) + * [Seeli.magenta( text ``)](#seelimagenta-text-string) + * [Seeli.redBright( text ``)](#seeliredbright-text-string) + * [Seeli.blueBright( text ``)](#seelibluebright-text-string) + * [Seeli.greenBright( text ``)](#seeligreenbright-text-string) + * [Seeli.yellowBright( text ``)](#seeliyellowbright-text-string) + * [Seeli.cyanBright( text ``)](#seelicyanbright-text-string) + * [Seeli.config( key ``, value `` )](#seeliconfig-key-string-value-object-) + * [Seeli.config( opts `` )](#seeliconfig-opts-object-) + * [Config Options](#config-options) + * [Seeli.config( key `` )](#seeliconfig-key-string-) +* [Package Configuration](#package-configuration) +* [Auto Help](#auto-help) +* [Asyncronous](#asyncronous) +* [Showing Progress](#showing-progress) +* [Events](#events) # API @@ -183,6 +182,8 @@ 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 new file mode 100644 index 0000000..6adce93 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +'use strict' + +const {defineConfig} = require('eslint/config') +const logdna = require('eslint-config-logdna') + +module.exports = defineConfig([ + { + 'extends': [logdna] + , 'files': ['lib/**/*.js', '*.js', 'test/**/*.js'] + , 'languageOptions': { + ecmaVersion: 2022 + , sourceType: 'commonjs' + } + , 'rules': { + 'sensible/check-require': [2, 'always', { + root: __dirname + }] + , 'logdna/require-file-extension': ['off', false] + } + } +]) diff --git a/examples/commands/hello.js b/examples/commands/hello.js index 6279311..6dad62b 100644 --- a/examples/commands/hello.js +++ b/examples/commands/hello.js @@ -27,7 +27,9 @@ module.exports = new cli.Command({ 'type': Boolean , 'shorthand': 'e' , 'description': 'Say hello in a very excited manner' - , 'default': false + , 'default': true + , 'affirmative': 'Yes' + , 'negative': 'Nope' } , volume: { @@ -37,6 +39,13 @@ 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 eaf3086..3595542 100644 --- a/examples/commands/sub/hub.js +++ b/examples/commands/sub/hub.js @@ -13,10 +13,12 @@ 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) { - return 'hub' + return data.ask ? 'hub' : 'no hub' } }) diff --git a/gh-pages/guides/commands.md b/gh-pages/guides/commands.md index 62c985f..50c5203 100644 --- a/gh-pages/guides/commands.md +++ b/gh-pages/guides/commands.md @@ -166,6 +166,11 @@ name | required | type | description **when** | `false` | `function` | **interactive mode only** Receives the current user answers hash and should return true or **false** depending on whether or not this question should be asked. **validate** | `false` | `function` | receives user input and should return true if the value is **valid**, and an error message (String) otherwise. If false is returned, a default error message is provided. **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/lib/colorize.js b/lib/colorize.js index 4acbe6f..9d106d9 100644 --- a/lib/colorize.js +++ b/lib/colorize.js @@ -7,7 +7,7 @@ * @requires chalk * @requires seeli/lib/conf **/ -const chalk = require('chalk') +const {default: chalk} = require('chalk') const conf = require('./conf') const {InvalidColorModeException} = require('./exceptions') diff --git a/lib/command/flag-to-prompt.js b/lib/command/flag-to-prompt.js index db922d3..03e5c90 100644 --- a/lib/command/flag-to-prompt.js +++ b/lib/command/flag-to-prompt.js @@ -1,19 +1,24 @@ 'use strict' const flagType = require('./flag-type') +const invertWhen = require('./invert-when') module.exports = flagToPrompt -function flagToPrompt(name, opt) { +function flagToPrompt(name, opt = {}) { const display = name.replace(':', ' ') + const t = flagType(opt) + return { - 'type': flagType(opt) - , 'name': name - , 'message': display + ': ' + (opt.description || '(no description)') - , 'choices': opt.choices - , 'default': opt.default || null - , 'when': opt.when - , 'filter': opt.filter - , 'transformer': opt.transformer + 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 } } diff --git a/lib/command/flag-type.js b/lib/command/flag-type.js index d11f445..4363cf3 100644 --- a/lib/command/flag-type.js +++ b/lib/command/flag-type.js @@ -10,12 +10,17 @@ function flagType(flag) { , multi: true }) } - if (flag.type === Boolean) return 'confirm' + if (flag.type === Boolean) { + if (flag.affirmative && flag.negative) { + return 'toggle' + } + return 'confirm' + } if (flag.type === Number) return 'number' if (flag.mask) return 'password' if (flag.choices) { - if (flag.multi) return 'checkbox' - return 'list' + if (flag.multi) return 'multiselect' + return 'select' } return 'input' diff --git a/lib/command/index.js b/lib/command/index.js index 1231d08..5c1a560 100644 --- a/lib/command/index.js +++ b/lib/command/index.js @@ -21,10 +21,12 @@ * @requires seeli/lib/lang/object **/ -const tty = require('tty') -const inquirer = require('inquirer') +const tty = require('node:tty') +const Equirer = require('enquirer') +const {default: spinners} = require('cli-spinners') +const {default: ora} = require('ora') const nopt = require('nopt') -const chalk = require('chalk') +const {default: chalk} = require('chalk') const strip = require('strip-ansi') const debug = require('debug') const toArray = require('mout/lang/toArray') @@ -35,7 +37,6 @@ const toPrompt = require('./flag-to-prompt') const flagType = require('./flag-type') const Registry = require('../registry') const conf = require('../conf') -const ora = require('../ora') const usage = require('../usage') const object = require('../lang/object') const typeOf = require('../usage/type-of') @@ -82,7 +83,6 @@ const defaults = { , run: noop } - /** * Base command class for creating re-usable commands * @constructor @@ -152,7 +152,7 @@ class Command extends Registry { this._optcache = null this.parsed = null this.options = Object.create(null) - this[kPrompt] = inquirer.createPromptModule() + this[kPrompt] = new Equirer() this.reset() const subcommands = options.reduce((acc, opts) => { @@ -166,10 +166,22 @@ 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] + this.ui = ora({ - color: conf.get('color') - , spinner: this.options.ui + color: false + , spinner: { + interval: spinner.interval + , frames: spinner.frames.map((frame) => { + return colorize(frame) + }) + } , text: 'loading' , stream: process.stdout }) @@ -364,14 +376,14 @@ 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) - Object.assign(answers, res) + answers[flag] = res[flag] // 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') { @@ -630,8 +642,8 @@ class Command extends Registry { * @returns {Promise} Promise object representing the end user input from the question **/ prompt(opts) { - const prompt = this[kPrompt] - return prompt(opts) + const enquirer = this[kPrompt] + return enquirer.prompt(opts) } /** @@ -666,13 +678,13 @@ class Command extends Registry { * @property {?Function} filter **/ - /* istanbul ignore next */ + /* c8 ignore start */ async ask(name, opts) { const results = [] const question = toQuestion(name, opts) while (true) { - const answer = await this[kPrompt](question) + const answer = await this[kPrompt].prompt(question) const value = typecast(answer[name]) if (value === '') break if (question.type === 'number' && isNaN(value)) break @@ -681,6 +693,8 @@ class Command extends Registry { } return results } + /* c8 ignore stop */ + /** * Convert all registered flags to inquierer compatible prompt objects * @method module:seeli/lib/command#toPrompt @@ -729,6 +743,7 @@ class Command extends Registry { module.exports = Command +/* c8 ignore start */ function transform(input, answers, status) { if (!status.isFinal) return input if (this.type === 'number' && isNaN(input)) return '' @@ -740,6 +755,7 @@ function isRepeatable(flag) { if (flag.multi) return false return true } +/* c8 ignore stop */ function toQuestion(flag, opts, answers) { const arg = toPrompt(flag, opts) @@ -754,7 +770,7 @@ function toQuestion(flag, opts, answers) { return arg } -function getChoices(cfg, answers) { +function getChoices(cfg) { return new Set( toArray(cfg.choices) .filter((choice) => { diff --git a/lib/command/invert-when.js b/lib/command/invert-when.js new file mode 100644 index 0000000..bf846f4 --- /dev/null +++ b/lib/command/invert-when.js @@ -0,0 +1,29 @@ +'use strict' +const kindOf = require('mout/lang/kindOf') +/** + * In switching from inquierer to enquierer + * the way to skip a question was inverted from the positive + * to the negetive (skip vs when). This maintains the behavior of when + **/ + +module.exports = invertWhen + +function invertWhen(fn) { + if (fn === undefined || fn === null) return undefined + + return (...args) => { + switch (kindOf(fn)) { + case 'Boolean': { + return !fn + } + case 'AsyncFunction': { + return fn(...args).then((value) => { return !value }) + } + + case 'Function': { + const value = fn(...args) + return !value + } + } + } +} diff --git a/lib/commands/help.js b/lib/commands/help.js index 8ac265c..1108e93 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-this */ + 'use strict' /** * Built in command for constructing help for seeli and all registered commands diff --git a/lib/conf.js b/lib/conf.js index b6aa042..301b4f7 100644 --- a/lib/conf.js +++ b/lib/conf.js @@ -1,7 +1,7 @@ 'use strict' const path = require('path') -const pkgup = require('pkg-up') +const {packageUpSync} = require('package-up') const set = require('mout/object/set') const get = require('mout/object/get') const isObject = require('mout/lang/isPlainObject') @@ -23,7 +23,7 @@ let config = { try { const cwd = get(require, 'main.path') || CWD - const pkgjson = pkgup.sync({cwd}) + const pkgjson = packageUpSync({cwd}) debug('loading configuration from %s', pkgjson) const pkg = require(pkgjson) const override = pkg.seeli || {} @@ -33,6 +33,7 @@ try { , ...override , help: help } + debug(config) } catch (e) { debug('unable to load configuration. using config', e) } diff --git a/lib/index.js b/lib/index.js index 0c6b117..817c440 100644 --- a/lib/index.js +++ b/lib/index.js @@ -11,7 +11,7 @@ * @requires seeli/lib/commands * @requires seeli/lib/conf **/ -const chalk = require('chalk') +const {default: chalk} = require('chalk') const Command = require('./command') const Seeli = require('./seeli') const commands = require('./commands') diff --git a/lib/lang/object/set.js b/lib/lang/object/set.js index 260d1bc..2f0c145 100644 --- a/lib/lang/object/set.js +++ b/lib/lang/object/set.js @@ -16,7 +16,6 @@ * { foo: { bar : { baz: 12 } } } **/ - module.exports = setProperty function setProperty(obj, key, value, sep = ':') { diff --git a/lib/ora.js b/lib/ora.js deleted file mode 100644 index aca8341..0000000 --- a/lib/ora.js +++ /dev/null @@ -1,28 +0,0 @@ -/* istanbul ignore file */ -'use strict' -const ora = require('ora') -const colorize = require('./colorize') - -const instance = ora() - -instance.constructor.prototype.frame = frame - -module.exports = ora - -function frame() { - const {frames} = this.spinner - let frame = frames[this.frameIndex] - - if (this.color) { - frame = colorize(frame, this.color) - } - - this.frameIndex = ++this.frameIndex % frames.length - const fullText = typeof this.text === 'string' ? ' ' + this.text : '' - const fullPrefixText = (typeof this.prefixText === 'string' && this.prefixText !== '') - ? this.prefixText + ' ' - : '' - - return fullPrefixText + frame + fullText -} - diff --git a/lib/registry.js b/lib/registry.js index 055f4ef..913a1f1 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -17,7 +17,6 @@ const toArray = require('mout/lang/toArray') const fill = require('mout/object/fillIn') const typeOf = require('./usage/type-of') - function copyProperties(target, source) { for (const key of Reflect.ownKeys(source)) { if (key !== 'constructor' && key !== 'prototype' && key !== 'name') { @@ -83,6 +82,8 @@ class Registry extends mix(Map, EventEmitter) { _command = _name _name = _command.options.name } + + if (!_command.options.name) _command.options.name = _name const abbrevs = abbrev(_name) const alias = _command.options.alias diff --git a/lib/seeli.js b/lib/seeli.js index 3bdc93f..bfe056c 100644 --- a/lib/seeli.js +++ b/lib/seeli.js @@ -6,7 +6,7 @@ * @requires seeli/lib/command * @requires seeli/lib/conf **/ -const chalk = require('chalk') +const {default: chalk} = require('chalk') const toArray = require('mout/lang/toArray') const kindOf = require('mout/lang/kindOf') const Command = require('./command') @@ -77,7 +77,7 @@ class Seeli extends Command { let cmd = parsed.argv.remain.shift() // did the try to use the help command directly? const help = !!parsed.help - if (help || cmd === 'help' || cmd == null) { + if (help || cmd === 'help' || cmd === null || cmd === undefined) { if (!this.has('help')) { console.error('unknown command %s', cmd) console.error('know commands: %s ', Array.from(this.names).join(', ')) @@ -85,7 +85,10 @@ class Seeli extends Command { return } // allow for abbreviated commands - cmd = (cmd === 'help' || cmd == null) ? parsed.argv.remain.shift() : cmd + cmd = (cmd === 'help' || cmd === null || cmd === undefined) + ? parsed.argv.remain.shift() + : cmd + return this.get('help') .run(cmd) .then(onComplete.bind(this)) diff --git a/lib/usage/from.js b/lib/usage/from.js index a43ce08..dfaeec9 100644 --- a/lib/usage/from.js +++ b/lib/usage/from.js @@ -15,10 +15,10 @@ **/ const os = require('os') -const cliui = require('cliui') const util = require('util') -const chalk = require('chalk') -const width = require('string-width') +const cliui = require('cliui') +const {default: chalk} = require('chalk') +const {default: width} = require('string-width') const toArray = require('mout/lang/toArray') const isUndef = require('mout/lang/isUndefined') const typeOf = require('./type-of') @@ -61,7 +61,7 @@ function actions(command) { return acts } -function options(command, plain) { +function options(command) { const ui = cliui() const required_with_ui = cliui() const required_without_ui = cliui() diff --git a/lib/usage/list.js b/lib/usage/list.js index eab3b70..6266df3 100644 --- a/lib/usage/list.js +++ b/lib/usage/list.js @@ -11,7 +11,7 @@ * @requires seeli/lib/conf **/ const os = require('os') -const chalk = require('chalk') +const {default: chalk} = require('chalk') const conf = require('../conf') const colorize = require('../colorize') const usage = chalk.white.bold diff --git a/package.json b/package.json index 3b0b472..d803a4f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "no-cond-assign": 0 }, "parserOptions": { - "ecmaVersion": 2020, + "ecmaVersion": 2022, "sourceType": "script" }, "ignorePatterns": [ @@ -79,41 +79,40 @@ ], "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=22.0.0" }, "bugs": { "url": "https://github.com/esatterwhite/node-seeli/issues" }, "homepage": "https://github.com/esatterwhite/node-seeli", "dependencies": { - "abbrev": "^1.1.1", - "chalk": "^4.1.2", - "cliui": "^7.0.4", + "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", - "inquirer": "^7.1.0", + "enquirer": "^2.4.1", "mout": "^1.2.2", - "nopt": "^7.0.0", - "ora": "^5.4.1", - "pkg-up": "^3.1.0", - "string-width": "^4.2.0", + "nopt": "^9.0.0", + "ora": "^9.3.0", + "package-up": "^5.0.0", + "string-width": "^8.2.0", "strip-ansi": "^6.0.0" }, "devDependencies": { "@codedependant/release-config-npm": "^1.0.3", "@semantic-release/exec": "^5.0.0", "@vuepress/plugin-back-to-top": "^1.5.4", - "cli-spinners": "^2.6.0", - "eslint": "^7.31.0", - "eslint-config-codedependant": "^2.1.6", + "eslint": "^10.1.0", + "eslint-config-logdna": "^8.0.1", "semantic-release": "^17.4.2", - "tap": "^16.3.9", + "tap": "^21.6.2", "vuepress": "^1.8.2" }, "tap": { - "jsx": false, - "ts": false, "browser": false, + "show-full-coverage": true, "functions": 97, "lines": 97, "branches": 84, @@ -121,19 +120,14 @@ "coverage-report": [ "text", "text-summary", + "lcov", "json", + "json-summary", "html" ], + "output-file": ".tap-out", "files": [ "test/**/*.js" - ], - "nyc-arg": [ - "--exclude=test/", - "--exclude=examples/", - "--exclude=gh-pages/", - "--exclude=docs/", - "--exclude=release.config.js", - "--all" ] } } diff --git a/test/command.js b/test/command.js index 15939d1..9b7fed5 100644 --- a/test/command.js +++ b/test/command.js @@ -237,8 +237,8 @@ test('command', async (t) => { }) // internal argv parsing - t.test('~argv', async (tt) => { - test('should accept an array of arguments', async (t) => { + t.test('~argv', async (t) => { + t.test('should accept an array of arguments', async (t) => { const ArgCommand = new Command({ args: ['--no-color'] }) @@ -322,8 +322,6 @@ test('command', async (t) => { t.equal(NumberCommand.argv.num, 1) }) - - t.test('should accept multiple value flags', async (t) => { const MultiCommand = new Command({ flags: { @@ -414,8 +412,8 @@ test('command', async (t) => { }) }) - t.test('#run', async (tt) => { - test('should emit events for marked flags', (t) => { + t.test('#run', async (t) => { + t.test('should emit events for marked flags', (t) => { t.plan(3) const EventCommand = new Command({ args: ['--one', '--no-two'] @@ -463,8 +461,8 @@ test('command', async (t) => { t.equal(out, 'static call', 'static run function output') }) - t.test('Subclassing', async (tt) => { - test('should allow for subclassing', async (t) => { + t.test('Subclassing', async (t) => { + t.test('should allow for subclassing', async (t) => { const defaults = { description: 'This is a subclass' } @@ -493,8 +491,8 @@ test('command', async (t) => { }) }) - t.test('Aliasing', async (tt) => { - test('from string', async (t) => { + t.test('Aliasing', async (t) => { + t.test('from string', async (t) => { t.afterEach((cb) => { cli.reset() cb() @@ -526,8 +524,8 @@ test('command', async (t) => { }) }) - t.test('Directive parsing', async (tt) => { - test('should pass the first non-flag argument to run', async (t) => { + t.test('Directive parsing', async (t) => { + t.test('should pass the first non-flag argument to run', async (t) => { const DirectiveCommand = new Command({ flags: { test: { @@ -566,7 +564,7 @@ test('command', async (t) => { , 'default': true } } - , run: async (cmd, data) => { + , run: async () => { return '' } }) @@ -583,16 +581,23 @@ 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({ - run: async function() { - const promise = this.prompt({ + enquirer: mockEnquirer + , run: async function() { + const value = await this.prompt({ type: 'input' , name: 'option' , message: 'do you want this option' }) - promise.ui.rl.emit('line', 'yes') - return promise + return value } }) @@ -612,8 +617,23 @@ 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'] @@ -633,18 +653,11 @@ test('command', async (t) => { } }) - cmd.ask = async function(flag_name) { + cmd.ask = async function() { // only used for `fake` because it's an array value return ['yes'] } - const prompt = cmd.prompt - cmd.prompt = function(arg) { - const promise = prompt.call(cmd, arg) - promise.ui.activePrompt.done('') - promise.ui.activePrompt.close() - return promise - } const answers = await cmd.run() t.match(answers, { fake: ['yes'] @@ -653,8 +666,24 @@ 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: { @@ -673,15 +702,6 @@ test('command', async (t) => { } }) - const prompt = cmd.prompt - - cmd.prompt = function(arg) { - const promise = prompt.call(cmd, arg) - promise.ui.activePrompt.done('yes') - promise.ui.activePrompt.close() - return promise - } - const answers = await cmd.run() t.match(answers, { fake: 'yes' @@ -690,8 +710,23 @@ 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: { @@ -710,15 +745,6 @@ test('command', async (t) => { } }) - const prompt = cmd.prompt - - cmd.prompt = function(arg) { - const promise = prompt.call(cmd, arg) - promise.ui.activePrompt.done('yes') - promise.ui.activePrompt.close() - return promise - } - t.rejects(cmd.run(), { code: 'EMUTEXCLUSIVE' , message: '`other` is mutually exclusive with fake. Erroneously set: fake' @@ -726,8 +752,23 @@ 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: { @@ -745,32 +786,28 @@ test('command', async (t) => { return data } }) - - const prompt = cmd.prompt - - cmd.prompt = function(arg) { - const promise = prompt.call(cmd, arg) - if (arg.name === 'fake') { - promise.ui.activePrompt.done('') - } else { - promise.ui.activePrompt.done('yes') - } - promise.ui.activePrompt.close() - return promise - } - t.rejects(cmd.run(), { code: 'EMUTINCLUSIVE' , message: '`other` is mutually inclusive with fake. Not set: fake' }, 'correct error message') }) - 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: { @@ -797,14 +834,6 @@ test('command', async (t) => { } }) - const prompt = cmd.prompt - - cmd.prompt = function(arg) { - const promise = prompt.call(cmd, arg) - promise.ui.activePrompt.done('my_string') - promise.ui.activePrompt.close() - return promise - } 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') @@ -871,15 +900,15 @@ test('command', async (t) => { t.match(out, [{ type: 'confirm' , message: 'one: boolean flag' - , when: Function + , skip: Function , validate: undefined - , filter: undefined + , result: undefined }, { type: 'number' , message: /no description/ig , when: undefined , validate: undefined - , filter: Function + , result: Function }]) }) diff --git a/test/flag-to-prompt.js b/test/flag-to-prompt.js index 09880ce..bdb35af 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: 'checkbox' + , type: 'multiselect' , message: 'foobar: hello world' , choices: ['one'] - , when: Function - , filter: Function - , transformer: Function + , skip: Function + , result: Function + , format: Function }) }) }).catch(threw) diff --git a/test/flag-type.js b/test/flag-type.js index d2a2f06..6d66429 100644 --- a/test/flag-type.js +++ b/test/flag-type.js @@ -15,8 +15,8 @@ test('flagType', async (t) => { , [{type: url}, 'input', 'url === input'] , [{type: [Number, Array]}, 'number', '[Number, Array] === number'] , [{type: String, mask: true}, 'password', 'mask=true === password'] - , [{type: String, choices: []}, 'list', 'choices === list'] - , [{type: String, choices: [], multi: true}, 'checkbox', 'choices + multi === checkbox'] + , [{type: String, choices: []}, 'select', 'choices === select'] + , [{type: String, choices: [], multi: true}, 'multiselect', 'choices + multi === multiselect'] , [{type: Function}, 'input', 'unexpected type === input'] ] diff --git a/test/invert-when.js b/test/invert-when.js new file mode 100644 index 0000000..79f841a --- /dev/null +++ b/test/invert-when.js @@ -0,0 +1,77 @@ +'use strict' + +const test = require('tap').test +const invertWhen = require('../lib/command/invert-when') + +function trueFn() { + return true +} + +function falseFn() { + return false +} + +async function asyncTrueFn() { + return true +} + +async function asyncFalseFn() { + return false +} + +function fnWithArgs(arg1, arg2) { + return arg1 && arg2 +} + +async function asyncFnWithArgs(arg1, arg2) { + return arg1 && arg2 +} + +test('invertWhen', async (t) => { + t.test('should return undefined when fn is explicitly undefined or null', async (t) => { + t.equal(invertWhen(undefined), undefined) + t.equal(invertWhen(null), undefined) + }) + + t.test('should return a function that inverts boolean values', async (t) => { + const invertedTrue = invertWhen(true) + const invertedFalse = invertWhen(false) + + // The function should return a function that when called, returns the inverted value + t.equal(invertedTrue(), false) + t.equal(invertedFalse(), true) + }) + + t.test('should invert function results', async (t) => { + const invertedTrue = invertWhen(trueFn) + const invertedFalse = invertWhen(falseFn) + + t.equal(invertedTrue(), false) + t.equal(invertedFalse(), true) + }) + + t.test('should invert async function results', async (t) => { + const invertedAsyncTrue = invertWhen(asyncTrueFn) + const invertedAsyncFalse = invertWhen(asyncFalseFn) + + t.equal(await invertedAsyncTrue(), false) + t.equal(await invertedAsyncFalse(), true) + }) + + t.test('should handle function with arguments', async (t) => { + const invertedFn = invertWhen(fnWithArgs) + + t.equal(invertedFn(true, true), false) + t.equal(invertedFn(true, false), true) + t.equal(invertedFn(false, false), true) + }) + + t.test('should handle async function with arguments', async (t) => { + const invertedAsyncFn = invertWhen(asyncFnWithArgs) + + t.equal(await invertedAsyncFn(true, true), false) + t.equal(await invertedAsyncFn(true, false), true) + t.equal(await invertedAsyncFn(false, false), true) + }) +}) + diff --git a/test/lang.js b/test/lang.js index 21bb0c6..079a965 100644 --- a/test/lang.js +++ b/test/lang.js @@ -10,7 +10,7 @@ const test = tap.test test('object', async (t) => { t.test('#set', async (tt) => { const a = object.set({}, 'a:b:c:d', 1) - tt.deepEqual(a, { + tt.same(a, { a: { b: { c: { @@ -21,7 +21,7 @@ test('object', async (t) => { }) const b = object.set({}, 'a,b,c,d', 2, ',') - tt.deepEqual(b, { + tt.same(b, { a: { b: { c: { @@ -36,9 +36,9 @@ test('object', async (t) => { const one = {a: {b: [1]}, c: path, d: url, x: {y: {z: 1}}} const two = {x: {y: {f: 3}}} const out = object.merge(one, two) - t.deepEqual(out.c, path, 'path module intact') - t.deepEqual(out.d, url, 'url module intact') - t.deepEqual(out, { + t.same(out.c, path, 'path module intact') + t.same(out.d, url, 'url module intact') + t.same(out, { a: {b: [1]} , c: path , d: url diff --git a/test/registry.js b/test/registry.js index 5b415e0..c12aee0 100644 --- a/test/registry.js +++ b/test/registry.js @@ -24,7 +24,7 @@ test('registry', async (t) => { registry.register(two) t.equal(registry.get('two'), two, 'registered property') - t.deepEqual(registry.list(), ['two'], 'registered commands') + t.same(registry.list(), ['two'], 'registered commands') t.equal(registry.get('tow'), two, 'registered alias') }) @@ -34,7 +34,7 @@ test('registry', async (t) => { registry.register('one', two) t.equal(registry.get('one'), two, 'registered property') - t.deepEqual(registry.list(), ['one'], 'registered commands') + t.same(registry.list(), ['one'], 'registered commands') t.equal(registry.get('tow'), two, 'registered alias') }) @@ -44,7 +44,7 @@ test('registry', async (t) => { t.equal(registry.get('one'), one, 'registered property') registry.unregister(one.options.name) - t.deepEqual(registry.list(), [], 'registered commands') + t.same(registry.list(), [], 'registered commands') t.notOk(registry.get('one')) t.doesNotThrow(() => { registry.unregister() diff --git a/test/seeli.js b/test/seeli.js index e0de7e1..57ba13b 100644 --- a/test/seeli.js +++ b/test/seeli.js @@ -3,7 +3,7 @@ const os = require('os') const path = require('path') const {test} = require('tap') -const chalk = require('chalk') +const {default: chalk} = require('chalk') const cli = require('../') const config = require('../lib/conf.js') const Seeli = require('../lib/seeli.js') @@ -15,7 +15,7 @@ test('cli', async (t) => { const seeli = new Seeli() t.notOk(seeli.config('foobar'), 'initial value not set') seeli.config('foobar', 1) - t.strictEqual(seeli.config('foobar'), 1, 'config value set') + t.equal(seeli.config('foobar'), 1, 'config value set') }) t.test('statics', async (t) => { @@ -23,26 +23,26 @@ test('cli', async (t) => { Seeli.set('foo', 'bar') Seeli.set({one: true}) const result = Seeli.get('one') - t.strictEqual(result, true, 'config value set') + t.equal(result, true, 'config value set') }) t.test('static get Command', async (t) => { - t.strictEqual(Seeli.Command, Command, 'returns base command class') + t.equal(Seeli.Command, Command, 'returns base command class') }) t.test('instance get Command', async (t) => { - t.strictEqual(new Seeli().Command, Command, 'returns base command class') + t.equal(new Seeli().Command, Command, 'returns base command class') }) t.test('colorize', async (t) => { const configured = config.get('color') - t.strictEqual( + t.equal( Seeli.colorize('hello') , chalk[configured]('hello') , 'no arguments returns configured color' ) - t.strictEqual( + t.equal( Seeli.colorize('hello', 'bold') , chalk.bold('hello') , 'second argument controls color output' @@ -123,7 +123,7 @@ test('cli', async (t) => { t.test('should allow commands to be registered by name', async (t) => { cli.use('test', TestCommand) - t.notEqual(cli.list.indexOf('test'), -1) + t.notSame(cli.list.indexOf('test'), -1) }) t.test('#list', async (t) => { @@ -132,8 +132,8 @@ test('cli', async (t) => { }) t.test('should only list top level commands', async (t) => { - t.notEqual(cli.list.indexOf('test'), -1) - t.notEqual(cli.list.indexOf('help'), -1) + t.notSame(cli.list.indexOf('test'), -1) + t.notSame(cli.list.indexOf('help'), -1) t.equal(cli.list.indexOf('h'), -1) t.equal(cli.list.indexOf('he'), -1) t.equal(cli.list.indexOf('hel'), -1) @@ -186,7 +186,7 @@ test('cli', async (t) => { path.join(FIXTURE_DIR, 'plugin-a.fixture') ]) const seeli = new Seeli() - t.strictEqual(seeli.config('plugin_a_fixture'), true, 'plugin path loaded') + t.equal(seeli.config('plugin_a_fixture'), true, 'plugin path loaded') }) t.test('require path loader', async (t) => { @@ -196,7 +196,7 @@ test('cli', async (t) => { ] }) - t.strictEqual(seeli.config('plugin_b_fixture'), false, 'plugin path loaded') + t.equal(seeli.config('plugin_b_fixture'), false, 'plugin path loaded') }) t.test('inline function loader', async (t) => { @@ -213,8 +213,8 @@ test('cli', async (t) => { plugins: [inline] }) - t.strictEqual(seeli.config('inline_plugin'), 1, 'inline plugin loaded') - t.deepEqual(seeli.list(), ['manual'], 'registered command list') + t.equal(seeli.config('inline_plugin'), 1, 'inline plugin loaded') + t.same(seeli.list(), ['manual'], 'registered command list') }) t.test('explicit call', async (t) => { @@ -230,8 +230,8 @@ test('cli', async (t) => { const seeli = new Seeli() seeli.plugin(outline) - t.strictEqual(seeli.config('outline_plugin'), true, 'inline plugin loaded') - t.deepEqual(seeli.list(), ['explicit'], 'registered command list') + t.equal(seeli.config('outline_plugin'), true, 'inline plugin loaded') + t.same(seeli.list(), ['explicit'], 'registered command list') }) t.test('invalid plugin type', async (t) => {