diff --git a/.eslintrc b/.eslintrc index a6fa2c0dc..bbbcc9f44 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,8 +4,6 @@ "oclif-typescript" ], "rules": { - "unicorn/no-abusive-eslint-disable": "off", - "unicorn/prefer-spread": "off", "unicorn/prefer-module": "off", "unicorn/prefer-node-protocol": "off", "unicorn/import-style": "off", @@ -16,6 +14,6 @@ "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/ban-ts-ignore": "off", "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/explicit-module-boundary-types": "off" + "no-useless-constructor": "off" } } diff --git a/MIGRATION.md b/MIGRATION.md index a9feba1ea..4f292203c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,181 +1,207 @@ -Migrating to @oclif/core +Migrating to @oclif/core@V2 ============== -Migrating to `@oclif/core` from the old oclif libraries (`@oclif/config`, `@oclif/command`, `@oclif/error`, `@oclif/parser`) is relatively straight forward. +## Breaking Changes -- [Migrating to @oclif/core](#migrating-to-oclifcore) - - [Update Imports](#update-imports) - - [Update your bin scripts](#update-your-bin-scripts) - - [Add `main` to your package.json](#add-main-to-your-packagejson) - - [Restore `-h`, `-v`, and `version`](#restore--h--v-and-version) - - [Configure the `topicSeparator`](#configure-the-topicseparator) - - [Update `this.parse` to `await this.parse`](#update-thisparse-to-await-thisparse) - - [Update `default` property on flag definitions](#update-default-property-on-flag-definitions) - - [Replace cli-ux library with `CliUx`](#replace-cli-ux-library-with-cliux) +### Command Args -## Update Imports +We updated the `Command.args` to more closely resemble flags -Replace imports from the old libraries with `@oclif/core`. For example, +**Before** ```typescript -import Help from '@oclif/plugin-help'; -import {Topic} from '@oclif/config'; -import {Command, Flags} from '@oclif/command' -``` +import { Command } from '@oclif/core' -With this import: +export default MyCommand extends Command { + static args = [{name: arg1, description: 'an argument', required: true}] -```typescript -import {Command, Flags, Topic, Help} from '@oclif/core'; + public async run(): Promise { + const {args} = await this.parse(MyCommand) // args is useless {[name: string]: any} + } +} ``` -## Update your bin scripts - -`@oclif/core` now supports separate bin scripts for production and development. +**After** -You can copy these new bin scripts directly from our [example repository](https://github.com/oclif/hello-world/tree/main/bin). - -## Add `main` to your package.json +```typescript +import { Command, Args } from '@oclif/core' -We recommend that all oclif plugins specify the `main` field in their package.json so that we can begin working on supporting Yarn v2. +export default MyCommand extends Command { + static args = { + arg1: Args.string({description: 'an argument', required: true}) + } -```json -{ - "main": "lib/index.js" + public async run(): Promise { + const {args} = await this.parse(MyCommand) // args is { arg1: string } + } } ``` -All plugins will be required to have this field in the next major version of `@oclif/core`. +These are the available Args: +- string +- integer +- boolean +- url +- file +- directory +- custom -## Restore `-h`, `-v`, and `version` +### Interfaces -`@oclif/config` automatically added `-h` as a short flag for `--help`, `-v` as a short flag for `--version`, and `version` as an alias for `--version`. +- Removed `Interfaces.Command` since they were not usable for tests. These are replaced by types that are available under the `Command` namespace -`@oclif/core` removes these so you can now use those flags for whatever you want! However, we've added a way to restore that functionality if you want to keep it. +``` +Interfaces.Command => Command.Cached +Interfaces.Command.Class => Command.Class +Interfaces.Command.Loadable => Command.Lodable +``` -Simply add the `additionalHelpFlags` and `additionalVersionFlags` properties to the oclif section of your package.json: +- Removed the following interfaces from the export. Exporting all of these made it difficult to make non-breaking changes when modifying types and/or fixing compilation bugs. We are open to PRs to reintroduce these to the export if they are needed for your project + - Arg + - ArgInput + - ArgToken + - CLIParseErrorOptions + - CompletableFlag + - CompletableOptionFlag + - Completion + - CompletionContext + - Default + - DefaultContext + - Definition + - EnumFlagOptions + - FlagBase + - FlagInput + - FlagOutput + - FlagToken + - FlagUsageOptions + - Input + - List + - ListItem + - Metadata + - OptionalArg + - OptionFlagProps + - OutputArgs + - OutputFlags + - ParseFn + - ParserArg + - ParserInput + - ParserOutput + - ParsingToken + - RequiredArg + +### CliUx + +We flattened `CliUx.ux` into `ux` for ease of use + +**Before** -```json -{ - "oclif": { - "additionalHelpFlags": ["-h"], - "additionalVersionFlags": ["-v"] - } -} -``` +```typescript +import {CliUx} from '@oclif/core' -To get the `version` command, install `@oclif/plugin-version` into your CLI: - -```json -{ - "dependencies": { - "@oclif/plugin-version": "^1" - }, - "oclif": { - "plugins": [ - "@oclif/plugin-version" - ] - } -} +CliUx.ux.log('Hello World') ``` -## Configure the `topicSeparator` +**After** -By default, the `topicSeparator` is set to a colon (`:`) to maintain backwards compatibility with existing CLIs. If you prefer, you can now set it to a space. +```typescript +import {ux} from '@oclif/core' -For colons: -```json -{ - "oclif": { - "topicSeparator": ":" - } -} +ux.log('Hello World') ``` -For spaces: -```json -{ - "oclif": { - "topicSeparator": " " - } -} -``` +#### CliUx.ux.open -**NOTE: Using colons always works, even if you set the `topicSeparator` to spaces.** This means that you can enable spaces in your CLI without introducing a breaking change to your users. +We removed the `open` method since it was a direct import/export of the [`open`](https://www.npmjs.com/package/open) package. If you need this functionality, then you should import `open` yourself. -## Update `this.parse` to `await this.parse` +### Flags -The `parse` method on `Command` is now asynchronous (more [here](https://oclif.io/blog/#async-command-parsing)). So you'll now need to `await` any calls to `this.parse`: +- Flags.custom replaces Flags.build, Flags.enum, and Flags.option +- Removed builtin `color` flag +- Renamed `globalFlags` to `baseFlags` + - `globalFlags` was a misleading name because the flags added there weren't actually global to the entire CLI. Instead, they were just flags that would be inherited by any command that extended the command class they were defined in. -`const { args, flags } = this.parse(MyCommand)` => `const { args, flags } = await this.parse(MyCommand)` +### Flag and Arg Parsing -## Update `default` property on flag definitions +- In v1, any input that didn't match a flag definition was assumed to be an argument. This meant that misspelled flags, e.g. `--hekp` were parsed as arguments, instead of throwing an error. In order to handle this, oclif now assumes that anything that starts with a hyphen must be a flag and will throw an error if no corresponding flag definition is found. **In other words, your command can no longer accept arguments that begin with a hyphen** (fixes https://github.com/oclif/core/issues/526) +- v1 allowed you to return an array from a flag's `parse`. This was added to support backwards compatibility for flags that separated values by commas (e.g. `my-flag=val1,val2`). However, this was problematic because it didn't allow the `parse` to manipulate the individual values. If you need this functionality, you can now set a `delimiter` option on your flags. By doing so, oclif will split the string on the delimiter before parsing. -The `default` property on flag definitions is now asynchronous. So you'll now need to await those. +## ESM/CJS Friendliness -Example: +Writing plugins with ESM has always been possible, but it requires [a handful of modifications](https://oclif.io/docs/esm) for it to work, especially in the bin scripts. In v2 we've introduced an `execute` method that the bin scripts can use to avoid having to make changes for ESM of CJS. +**CJS `bin/dev` before** ```typescript -import {Command, Flags} from '@oclif/core' -import {readFile} from 'fs/promises' +#!/usr/bin/env node -function getTeam(): Promise { - return readFile('team.txt', 'utf-8') -} +const oclif = require('@oclif/core') -export const team = Flags.build({ - char: 't', - description: 'team to use', - default: () => getTeam(), -}) +const path = require('path') +const project = path.join(__dirname, '..', 'tsconfig.json') -export class MyCLI extends Command { - static flags = { - team: team(), - } +// In dev mode -> use ts-node and dev plugins +process.env.NODE_ENV = 'development' - async run() { - const {flags} = this.parse(MyCLI) - if (flags.team) console.log(`--team is ${flags.team}`) - } -} -``` +require('ts-node').register({project}) -## Replace cli-ux library with `CliUx` +// In dev mode, always show stack traces +oclif.settings.debug = true; -The [`cli-ux` library](https://github.com/oclif/cli-ux) has also been moved into `@oclif/core` in order to break a complex circular dependency between the two projects. -All the exports that were available from `cli-ux` are now available under the `CliUx` namespace, with the exception of the `cli` export which was identical to the `ux` export. +// Start the CLI +oclif.run().then(oclif.flush).catch(oclif.Errors.handle) +``` -Old: +**CJS `bin/dev.js` after** +```typescript +#!/usr/bin/env node +// eslint-disable-next-line node/shebang +(async () => { + const oclif = await import('@oclif/core') + await oclif.execute({type: 'cjs', development: true, dir: __dirname}) +})() +``` +**ESM `bin/dev.js` before** ```typescript -import { cli } from 'cli-ux` +#!/usr/bin/env ts-node -cli.log('hello world') -cli.action.start('doing things') -cli.action.stop() -``` +/* eslint-disable node/shebang */ -New: +import oclif from '@oclif/core' +import path from 'node:path' +import url from 'node:url' +// eslint-disable-next-line node/no-unpublished-import +import {register} from 'ts-node' -```typescript -import { CliUx } from '@oclif/core` +// In dev mode -> use ts-node and dev plugins +process.env.NODE_ENV = 'development' -CliUx.ux.log('hello world') -CliUx.ux.action.start('doing things') -CliUx.ux.action.stop() -``` +register({ + project: path.join(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'tsconfig.json'), +}) -## Single command CLIs +// In dev mode, always show stack traces +oclif.settings.debug = true -Single command CLIs now are configured in a different way. To ensure your migrated CLI work as before, you have to add the following to your `oclif` configuration in the `package.json`: +// Start the CLI +oclif +.run(process.argv.slice(2), import.meta.url) +.then(oclif.flush) +.catch(oclif.Errors.handle) +``` -```json -"oclif": { - "default": ".", - "commands": "./lib" -} +**ESM `bin/dev.js` after** +```typescript +#!/usr/bin/env node +// eslint-disable-next-line node/shebang +(async () => { + const oclif = await import('@oclif/core') + await oclif.execute({type: 'esm', dir: import.meta.url}) +})() ``` -Where `./lib` points to the folder in which your `tsconfig.json` is configured to output to (if you are using TypeScript), and your single command CLI entrypoint `index.(ts|js)` is located. +Note that ESM and CJS plugins still require different settings in the tsconfig.json - you will still need to make those modifications yourself. + +## Other Changes +- Removed dependency on `@oclif/screen` +- Replaced `@oclif/linewrap` with `wordwrap` diff --git a/README.md b/README.md index 8e00918b6..d6ef3b5d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ base library for oclif CLIs [![Version](https://img.shields.io/npm/v/@oclif/core.svg)](https://npmjs.org/package/@oclif/core) -[![CircleCI](https://circleci.com/gh/oclif/core/tree/main.svg?style=svg)](https://circleci.com/gh/oclif/core/tree/main) [![Downloads/week](https://img.shields.io/npm/dw/@oclif/core.svg)](https://npmjs.org/package/@oclif/core) [![License](https://img.shields.io/npm/l/@oclif/core.svg)](https://github.com/oclif/core/blob/main/package.json) @@ -12,11 +11,12 @@ base library for oclif CLIs Migrating ===== -If you're migrating from the old oclif libraries (`@oclif/config`, `@oclif/command`, `@oclif/error`, `@oclif/parser`), see the [migration guide](./MIGRATION.md). +See the [migration guide](./MIGRATION.md) for an overview of breaking changes that occurred between v1 and v2. -The `@oclif/core` module now also includes the `cli-ux` module. Merging `cli-ux` into `@oclif/core` resolves a circular dependency between the two modules. -See the [cli-ux README](./src/cli-ux/README.md) for instructions on how to replace the `cli-ux` module with `@oclif/core`. -The [cli-ux README](./src/cli-ux/README.md) also contains detailed usage examples. +CLI UX +===== + +The [ux README](./src/cli-ux/README.md) contains detailed usage examples of using the `ux` export. Usage ===== diff --git a/package.json b/package.json index c64317295..e1a99a53c 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,6 @@ "author": "Salesforce", "bugs": "https://github.com/oclif/core/issues", "dependencies": { - "@oclif/linewrap": "^1.0.0", - "@oclif/screen": "^3.0.4", "ansi-escapes": "^4.3.2", "ansi-styles": "^4.3.0", "cardinal": "^2.1.1", @@ -32,6 +30,7 @@ "supports-hyperlinks": "^2.2.0", "tslib": "^2.4.1", "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" }, "devDependencies": { @@ -57,6 +56,7 @@ "@types/shelljs": "^0.8.11", "@types/strip-ansi": "^5.2.1", "@types/supports-color": "^8.1.1", + "@types/wordwrap": "^1.0.1", "@types/wrap-ansi": "^3.0.0", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", @@ -109,9 +109,9 @@ "lint": "eslint . --ext .ts --config .eslintrc", "posttest": "yarn lint", "prepack": "yarn run build", - "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test": "mocha \"test/**/*.test.ts\"", "test:e2e": "mocha \"test/**/*.e2e.ts\" --timeout 1200000", "pretest": "yarn build --noEmit && tsc -p test --noEmit --skipLibCheck" }, "types": "lib/index.d.ts" -} \ No newline at end of file +} diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 000000000..9522ec751 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,84 @@ +import {URL} from 'url' +import {Arg, ArgDefinition} from './interfaces/parser' +import {Command} from './command' +import {dirExists, fileExists, isNotFalsy} from './util' + +/** + * Create a custom arg. + * + * @example + * type Id = string + * type IdOpts = { startsWith: string; length: number }; + * + * export const myArg = custom({ + * parse: async (input, opts) => { + * if (input.startsWith(opts.startsWith) && input.length === opts.length) { + * return input + * } + * + * throw new Error('Invalid id') + * }, + * }) + */ +export function custom>(defaults: Partial>): ArgDefinition +export function custom>(defaults: Partial>): ArgDefinition { + return (options: any = {}) => { + return { + parse: async (i: string, _context: Command, _opts: P) => i, + ...defaults, + ...options, + input: [] as string[], + type: 'option', + } + } +} + +export const boolean = custom({ + parse: async b => Boolean(b) && isNotFalsy(b), +}) + +export const integer = custom({ + parse: async (input, _, opts) => { + if (!/^-?\d+$/.test(input)) + throw new Error(`Expected an integer but received: ${input}`) + const num = Number.parseInt(input, 10) + if (opts.min !== undefined && num < opts.min) + throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`) + if (opts.max !== undefined && num > opts.max) + throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`) + return num + }, +}) + +export const directory = custom({ + parse: async (input, _, opts) => { + if (opts.exists) return dirExists(input) + + return input + }, +}) + +export const file = custom({ + parse: async (input, _, opts) => { + if (opts.exists) return fileExists(input) + + return input + }, +}) + +/** + * Initializes a string as a URL. Throws an error + * if the string is not a valid URL. + */ +export const url = custom({ + parse: async input => { + try { + return new URL(input) + } catch { + throw new Error(`Expected a valid url but received: ${input}`) + } + }, +}) + +const stringArg = custom({}) +export {stringArg as string} diff --git a/src/cli-ux/README.md b/src/cli-ux/README.md index c2c362e04..95193dae2 100644 --- a/src/cli-ux/README.md +++ b/src/cli-ux/README.md @@ -1,16 +1,4 @@ -# How to migrate from the `cli-ux` module and use the `ux` components now contained in `@oclif/core` - -We've retained the capabilities of `cli-ux` in `@oclif/core`, but we've reorganized the code to expose the exported members via a namespace. -We've removed the exported member `cli`, because it's equivalent to the exported member `ux`. -Updating your project to use cli IO utilities should be straight forward. - -1. Remove the `cli-ux` dependency. -1. Change all imports that reference `cli-ux` to `@oclif/core`. -1. Add the namespace member `CliUx` to your `@oclif/core` import. -1. Preface previous `cli-ux` members with the namespace `CliUx`. -1. Replace all references to member `cli` with `ux`. - -cli-ux +CLI UX ====== cli IO utilities @@ -20,105 +8,97 @@ cli IO utilities The following example assumes you've installed `@oclif/core` to your project with `npm install @oclif/core` or `yarn add @oclif/core` and have it required in your script (TypeScript example): ```typescript -import {CliUx} from '@oclif/core' -CliUx.ux.prompt('What is your name?') +import {ux} from '@oclif/core' +ux.prompt('What is your name?') ``` JavaScript: ```javascript -const {CliUx} = require('@oclif/core') +const {ux} = require('@oclif/core') -CliUx.ux.prompt('What is your name?') +ux.prompt('What is your name?') ``` -# CliUx.ux.prompt() +# ux.prompt() Prompt for user input. ```typescript // just prompt for input -await CliUx.ux.prompt('What is your name?') +await ux.prompt('What is your name?') // mask input after enter is pressed -await CliUx.ux.prompt('What is your two-factor token?', {type: 'mask'}) +await ux.prompt('What is your two-factor token?', {type: 'mask'}) // mask input on keypress (before enter is pressed) -await CliUx.ux.prompt('What is your password?', {type: 'hide'}) +await ux.prompt('What is your password?', {type: 'hide'}) // yes/no confirmation -await CliUx.ux.confirm('Continue?') +await ux.confirm('Continue?') // "press any key to continue" -await CliUx.ux.anykey() +await ux.anykey() ``` ![prompt demo](assets/prompt.gif) -# CliUx.ux.url(text, uri) +# ux.url(text, uri) Create a hyperlink (if supported in the terminal) ```typescript -await CliUx.ux.url('sometext', 'https://google.com') +await ux.url('sometext', 'https://google.com') // shows sometext as a hyperlink in supported terminals // shows https://google.com in unsupported terminals ``` ![url demo](assets/url.gif) -# CliUx.ux.open - -Open a url in the browser - -```typescript -await CliUx.ux.open('https://oclif.io') -``` - -# CliUx.ux.action +# ux.action Shows a spinner ```typescript // start the spinner -CliUx.ux.action.start('starting a process') +ux.action.start('starting a process') // show on stdout instead of stderr -CliUx.ux.action.start('starting a process', 'initializing', {stdout: true}) +ux.action.start('starting a process', 'initializing', {stdout: true}) // stop the spinner -CliUx.ux.action.stop() // shows 'starting a process... done' -CliUx.ux.action.stop('custom message') // shows 'starting a process... custom message' +ux.action.stop() // shows 'starting a process... done' +ux.action.stop('custom message') // shows 'starting a process... custom message' ``` This degrades gracefully when not connected to a TTY. It queues up any writes to stdout/stderr so they are displayed above the spinner. ![action demo](assets/action.gif) -# CliUx.ux.annotation +# ux.annotation Shows an iterm annotation ```typescript -CliUx.ux.annotation('sometext', 'annotated with this text') +ux.annotation('sometext', 'annotated with this text') ``` ![annotation demo](assets/annotation.png) -# CliUx.ux.wait +# ux.wait Waits for 1 second or given milliseconds ```typescript -await CliUx.ux.wait() -await CliUx.ux.wait(3000) +await ux.wait() +await ux.wait(3000) ``` -# CliUx.ux.table +# ux.table Displays tabular data ```typescript -CliUx.ux.table(data, columns, options) +ux.table(data, columns, options) ``` Where: @@ -127,7 +107,7 @@ Where: - `columns`: [Table.Columns](./src/styled/table.ts) - `options`: [Table.Options](./src/styled/table.ts) -`CliUx.ux.table.flags()` returns an object containing all the table flags to include in your command. +`ux.table.flags()` returns an object containing all the table flags to include in your command. ```typescript { @@ -141,12 +121,12 @@ Where: } ``` -Passing `{only: ['columns']}` or `{except: ['columns']}` as an argument into `CliUx.ux.table.flags()` allows or blocks, respectively, those flags from the returned object. +Passing `{only: ['columns']}` or `{except: ['columns']}` as an argument into `ux.table.flags()` allows or blocks, respectively, those flags from the returned object. `Table.Columns` defines the table columns and their display options. ```typescript -const columns: CliUx.Table.Columns = { +const columns: ux.Table.Columns = { // where `.name` is a property of a data object name: {}, // "Name" inferred as the column header id: { @@ -161,7 +141,7 @@ const columns: CliUx.Table.Columns = { `Table.Options` defines the table options, most of which are the parsed flags from the user for display customization, all of which are optional. ```typescript -const options: CliUx.Table.Options = { +const options: ux.Table.Options = { printLine: myLogger, // custom logger columns: flags.columns, sort: flags.sort, @@ -176,19 +156,19 @@ const options: CliUx.Table.Options = { Example class: ```typescript -import {Command, CliUx} from '@oclif/core' +import {Command, ux} from '@oclif/core' import axios from 'axios' export default class Users extends Command { static flags = { - ...CliUx.ux.table.flags() + ...ux.table.flags() } async run() { const {flags} = this.parse(Users) const {data: users} = await axios.get('https://jsonplaceholder.typicode.com/users') - CliUx.ux.table(users, { + ux.table(users, { name: { minWidth: 7, }, @@ -268,16 +248,16 @@ Clementine Bauch Romaguera-Jacobson Glenna Reichert Yost and Sons ``` -# CliUx.ux.tree +# ux.tree Generate a tree and display it ```typescript -let tree = CliUx.ux.tree() +let tree = ux.tree() tree.insert('foo') tree.insert('bar') -let subtree = CliUx.ux.tree() +let subtree = ux.tree() subtree.insert('qux') tree.nodes.bar.insert('baz', subtree) @@ -292,15 +272,15 @@ Outputs: └─ qux ``` -# CliUx.ux.progress +# ux.progress Generate a customizable progress bar and display it ```typescript -const simpleBar = CliUx.ux.progress() +const simpleBar = ux.progress() simpleBar.start() -const customBar = CliUx.ux.progress({ +const customBar = ux.progress({ format: 'PROGRESS | {bar} | {value}/{total} Files', barCompleteChar: '\u2588', barIncompleteChar: '\u2591', diff --git a/src/cli-ux/action/base.ts b/src/cli-ux/action/base.ts index 522df8e07..83c83da8d 100644 --- a/src/cli-ux/action/base.ts +++ b/src/cli-ux/action/base.ts @@ -25,7 +25,7 @@ export class ActionBase { stderr: process.stderr.write, } - public start(action: string, status?: string, opts: Options = {}) { + public start(action: string, status?: string, opts: Options = {}): void { this.std = opts.stdout ? 'stdout' : 'stderr' const task = {action, status, active: Boolean(this.task && this.task.active)} this.task = task @@ -35,7 +35,7 @@ export class ActionBase { this._stdout(true) } - public stop(msg = 'done') { + public stop(msg = 'done'): void { const task = this.task if (!task) { return @@ -109,7 +109,7 @@ export class ActionBase { return ret } - public pause(fn: () => any, icon?: string) { + public pause(fn: () => any, icon?: string): Promise { const task = this.task const active = task && task.active if (task && active) { @@ -126,26 +126,26 @@ export class ActionBase { return ret } - protected _start() { + protected _start(): void { throw new Error('not implemented') } - protected _stop(_: string) { + protected _stop(_: string): void { throw new Error('not implemented') } - protected _resume() { + protected _resume(): void { if (this.task) this.start(this.task.action, this.task.status) } - protected _pause(_?: string) { + protected _pause(_?: string): void { throw new Error('not implemented') } - protected _updateStatus(_: string | undefined, __?: string) {} + protected _updateStatus(_: string | undefined, __?: string): void {} // mock out stdout/stderr so it doesn't screw up the rendering - protected _stdout(toggle: boolean) { + protected _stdout(toggle: boolean): void { try { const outputs: ['stdout', 'stderr'] = ['stdout', 'stderr'] if (toggle) { @@ -173,7 +173,7 @@ export class ActionBase { } // flush mocked stdout/stderr - protected _flushStdout() { + protected _flushStdout(): void { try { let output = '' let std: 'stdout' | 'stderr' | undefined @@ -195,7 +195,7 @@ export class ActionBase { } // write to the real stdout/stderr - protected _write(std: 'stdout' | 'stderr', s: string | string[]) { + protected _write(std: 'stdout' | 'stderr', s: string | string[]): void { this.stdmockOrigs[std].apply(process[std], castArray(s) as [string]) } } diff --git a/src/cli-ux/action/pride-spinner.ts b/src/cli-ux/action/pride-spinner.ts index 28731da96..a4bf142ec 100644 --- a/src/cli-ux/action/pride-spinner.ts +++ b/src/cli-ux/action/pride-spinner.ts @@ -1,5 +1,3 @@ -// tslint:disable restrict-plus-operands - import * as chalk from 'chalk' import * as supportsColor from 'supports-color' diff --git a/src/cli-ux/action/simple.ts b/src/cli-ux/action/simple.ts index 48722ae32..c576011df 100644 --- a/src/cli-ux/action/simple.ts +++ b/src/cli-ux/action/simple.ts @@ -3,20 +3,20 @@ import {ActionBase, ActionType} from './base' export default class SimpleAction extends ActionBase { public type: ActionType = 'simple' - protected _start() { + protected _start(): void { const task = this.task if (!task) return this._render(task.action, task.status) } - protected _pause(icon?: string) { + protected _pause(icon?: string): void { if (icon) this._updateStatus(icon) else this._flush() } - protected _resume() {} + protected _resume(): void {} - protected _updateStatus(status: string, prevStatus?: string, newline = false) { + protected _updateStatus(status: string, prevStatus?: string, newline = false): void { const task = this.task if (!task) return if (task.active && !prevStatus) this._write(this.std, ` ${status}`) @@ -24,7 +24,7 @@ export default class SimpleAction extends ActionBase { if (newline || !prevStatus) this._flush() } - protected _stop(status: string) { + protected _stop(status: string): void { const task = this.task if (!task) return this._updateStatus(status, task.status, true) diff --git a/src/cli-ux/action/spinner.ts b/src/cli-ux/action/spinner.ts index 936d602c2..3b205dede 100644 --- a/src/cli-ux/action/spinner.ts +++ b/src/cli-ux/action/spinner.ts @@ -1,18 +1,16 @@ -// tslint:disable restrict-plus-operands - import * as chalk from 'chalk' import * as supportsColor from 'supports-color' - -import deps from '../deps' - +const stripAnsi = require('strip-ansi') +const ansiStyles = require('ansi-styles') +const ansiEscapes = require('ansi-escapes') +import {errtermwidth} from '../../screen' +import spinners from './spinners' import {ActionBase, ActionType} from './base' -/* eslint-disable-next-line node/no-missing-require */ -const spinners = require('./spinners') function color(s: string): string { if (!supportsColor) return s const has256 = supportsColor.stdout ? supportsColor.stdout.has256 : (process.env.TERM || '').includes('256') - return has256 ? `\u001B[38;5;104m${s}${deps.ansiStyles.reset.open}` : chalk.magenta(s) + return has256 ? `\u001B[38;5;104m${s}${ansiStyles.reset.open}` : chalk.magenta(s) } export default class SpinnerAction extends ActionBase { @@ -30,7 +28,7 @@ export default class SpinnerAction extends ActionBase { this.frameIndex = 0 } - protected _start() { + protected _start(): void { this._reset() if (this.spinner) clearInterval(this.spinner) this._render() @@ -43,14 +41,14 @@ export default class SpinnerAction extends ActionBase { interval.unref() } - protected _stop(status: string) { + protected _stop(status: string): void { if (this.task) this.task.status = status if (this.spinner) clearInterval(this.spinner) this._render() this.output = undefined } - protected _pause(icon?: string) { + protected _pause(icon?: string): void { if (this.spinner) clearInterval(this.spinner) this._reset() if (icon) this._render(` ${icon}`) @@ -77,15 +75,13 @@ export default class SpinnerAction extends ActionBase { private _reset() { if (!this.output) return const lines = this._lines(this.output) - this._write(this.std, deps.ansiEscapes.cursorLeft + deps.ansiEscapes.cursorUp(lines) + deps.ansiEscapes.eraseDown) + this._write(this.std, ansiEscapes.cursorLeft + ansiEscapes.cursorUp(lines) + ansiEscapes.eraseDown) this.output = undefined } private _lines(s: string): number { - return deps - .stripAnsi(s) - .split('\n') - .map(l => Math.ceil(l.length / deps.screen.errtermwidth)) + return (stripAnsi(s).split('\n') as any[]) + .map(l => Math.ceil(l.length / errtermwidth)) .reduce((c, i) => c + i, 0) } } diff --git a/src/cli-ux/action/spinners.ts b/src/cli-ux/action/spinners.ts index e953a876d..d38cc75f7 100644 --- a/src/cli-ux/action/spinners.ts +++ b/src/cli-ux/action/spinners.ts @@ -1,4 +1,4 @@ -module.exports = { +export default { hexagon: { interval: 400, frames: ['⬡', '⬢'], diff --git a/src/cli-ux/config.ts b/src/cli-ux/config.ts index e3cb40f13..9b567b28b 100644 --- a/src/cli-ux/config.ts +++ b/src/cli-ux/config.ts @@ -1,8 +1,12 @@ import * as semver from 'semver' - +import {PJSON} from '../interfaces/pjson' +import {requireJson} from '../util' +import spinner from './action/spinner' +import simple from './action/spinner' +import pride from './action/pride-spinner' import {ActionBase} from './action/base' -const version = semver.parse(require('../../package.json').version)! +const version = semver.parse(requireJson(__dirname, '..', '..', 'package.json').version)! export type Levels = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' @@ -22,10 +26,8 @@ const actionType = ( 'spinner' ) || 'simple' -/* eslint-disable node/no-missing-require */ -const Action = actionType === 'spinner' ? require('./action/spinner').default : require('./action/simple').default -const PrideAction = actionType === 'spinner' ? require('./action/pride-spinner').default : require('./action/simple').default -/* eslint-enable node/no-missing-require */ +const Action = actionType === 'spinner' ? spinner : simple +const PrideAction = actionType === 'spinner' ? pride : simple export class Config { outputLevel: Levels = 'info' @@ -50,7 +52,7 @@ export class Config { return globals.context || {} } - set context(v: any) { + set context(v: unknown) { globals.context = v } } diff --git a/src/cli-ux/deps.ts b/src/cli-ux/deps.ts deleted file mode 100644 index 329486e84..000000000 --- a/src/cli-ux/deps.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable node/no-missing-require */ -export default { - get stripAnsi(): (string: string) => string { - return require('strip-ansi') - }, - get ansiStyles(): typeof import('ansi-styles') { - return require('ansi-styles') - }, - get ansiEscapes(): any { - return require('ansi-escapes') - }, - get passwordPrompt(): any { - return require('password-prompt') - }, - get screen(): typeof import('@oclif/screen') { - return require('@oclif/screen') - }, - get open(): typeof import('./open').default { - return require('./open').default - }, - get prompt(): typeof import('./prompt') { - return require('./prompt') - }, - get styledObject(): typeof import('./styled/object').default { - return require('./styled/object').default - }, - get styledHeader(): typeof import('./styled/header').default { - return require('./styled/header').default - }, - get styledJSON(): typeof import('./styled/json').default { - return require('./styled/json').default - }, - get table(): typeof import('./styled/table').table { - return require('./styled/table').table - }, - get tree(): typeof import('./styled/tree').default { - return require('./styled/tree').default - }, - get wait(): typeof import('./wait').default { - return require('./wait').default - }, - get progress(): typeof import ('./styled/progress').default { - return require('./styled/progress').default - }, -} diff --git a/src/cli-ux/global.d.ts b/src/cli-ux/global.d.ts index 9b45c3a36..701002e3a 100644 --- a/src/cli-ux/global.d.ts +++ b/src/cli-ux/global.d.ts @@ -1,5 +1,3 @@ -// tslint:disable - declare namespace NodeJS { interface Global { 'cli-ux': any; diff --git a/src/cli-ux/index.ts b/src/cli-ux/index.ts index 0b91ebb45..86027c234 100644 --- a/src/cli-ux/index.ts +++ b/src/cli-ux/index.ts @@ -3,10 +3,12 @@ import * as util from 'util' import {ActionBase} from './action/base' import {config, Config} from './config' -import deps from './deps' import {ExitError} from './exit' import {IPromptOptions} from './prompt' -import * as Table from './styled/table' +import * as styled from './styled' +import {Table} from './styled' +import * as uxPrompt from './prompt' +import uxWait from './wait' const hyperlinker = require('hyperlinker') @@ -21,7 +23,7 @@ function timeout(p: Promise, ms: number) { return Promise.race([p, wait(ms, true).then(() => ux.error('timed out'))]) } -async function flush() { +async function _flush() { const p = new Promise(resolve => { process.stdout.once('drain', () => resolve(null)) }) @@ -34,82 +36,78 @@ async function flush() { return p } -export const ux = { +const ux = { config, warn: Errors.warn, error: Errors.error, exit: Errors.exit, - get prompt() { - return deps.prompt.prompt + get prompt(): typeof uxPrompt.prompt { + return uxPrompt.prompt }, /** * "press anykey to continue" */ - get anykey() { - return deps.prompt.anykey + get anykey(): typeof uxPrompt.anykey { + return uxPrompt.anykey }, - get confirm() { - return deps.prompt.confirm + get confirm(): typeof uxPrompt.confirm { + return uxPrompt.confirm }, - get action() { + get action(): ActionBase { return config.action }, - get prideAction() { + get prideAction(): ActionBase { return config.prideAction }, - styledObject(obj: any, keys?: string[]) { - ux.info(deps.styledObject(obj, keys)) + styledObject(obj: any, keys?: string[]): void { + ux.info(styled.styledObject(obj, keys)) }, - get styledHeader() { - return deps.styledHeader + get styledHeader(): typeof styled.styledHeader { + return styled.styledHeader }, - get styledJSON() { - return deps.styledJSON + get styledJSON(): typeof styled.styledJSON { + return styled.styledJSON }, - get table() { - return deps.table + get table(): typeof styled.Table.table { + return styled.Table.table }, - get tree() { - return deps.tree + get tree(): typeof styled.tree { + return styled.tree }, - get open() { - return deps.open + get wait(): typeof uxWait { + return uxWait }, - get wait() { - return deps.wait - }, - get progress() { - return deps.progress + get progress(): typeof styled.progress { + return styled.progress }, - async done() { + async done(): Promise { config.action.stop() - // await flushStdout() }, - trace(format: string, ...args: string[]) { + trace(format: string, ...args: string[]): void { if (this.config.outputLevel === 'trace') { process.stdout.write(util.format(format, ...args) + '\n') } }, - debug(format: string, ...args: string[]) { + debug(format: string, ...args: string[]): void { if (['trace', 'debug'].includes(this.config.outputLevel)) { process.stdout.write(util.format(format, ...args) + '\n') } }, - info(format: string, ...args: string[]) { + info(format: string, ...args: string[]): void { process.stdout.write(util.format(format, ...args) + '\n') }, - log(format?: string, ...args: string[]) { + log(format?: string, ...args: string[]): void { this.info(format || '', ...args) }, - url(text: string, uri: string, params = {}) { + url(text: string, uri: string, params = {}): void { const supports = require('supports-hyperlinks') if (supports.stdout) { this.log(hyperlinker(text, uri, params)) @@ -118,7 +116,7 @@ export const ux = { } }, - annotation(text: string, annotation: string) { + annotation(text: string, annotation: string): void { const supports = require('supports-hyperlinks') if (supports.stdout) { // \u001b]8;;https://google.com\u0007sometext\u001b]8;;\u0007 @@ -128,25 +126,71 @@ export const ux = { } }, - async flush(ms = 10_000) { - await timeout(flush(), ms) + async flush(ms = 10_000): Promise { + await timeout(_flush(), ms) }, } +const action = ux.action +const annotation = ux.annotation.bind(ux) +const anykey = ux.anykey.bind(ux) +const confirm = ux.confirm.bind(ux) +const debug = ux.debug.bind(ux) +const done = ux.done.bind(ux) +const error = ux.error.bind(ux) +const exit = ux.exit.bind(ux) +const flush = ux.flush.bind(ux) +const info = ux.info.bind(ux) +const log = ux.log.bind(ux) +const prideAction = ux.prideAction +const progress = ux.progress.bind(ux) +const prompt = ux.prompt.bind(ux) +const styledHeader = ux.styledHeader.bind(ux) +const styledJSON = ux.styledJSON.bind(ux) +const styledObject = ux.styledObject.bind(ux) +const table = ux.table +const trace = ux.trace.bind(ux) +const tree = ux.tree.bind(ux) +const url = ux.url.bind(ux) +const wait = ux.wait.bind(ux) +const warn = ux.warn.bind(ux) + export { - config, + action, ActionBase, + annotation, + anykey, + config, Config, + confirm, + debug, + done, + error, + exit, ExitError, + flush, + info, IPromptOptions, + log, + prideAction, + progress, + prompt, + styledHeader, + styledJSON, + styledObject, + table, Table, + trace, + tree, + url, + wait, + warn, } const cliuxProcessExitHandler = async () => { try { await ux.done() } catch (error) { - // tslint:disable no-console console.error(error) process.exitCode = 1 } diff --git a/src/cli-ux/list.ts b/src/cli-ux/list.ts index b88830f74..4a0306b36 100644 --- a/src/cli-ux/list.ts +++ b/src/cli-ux/list.ts @@ -1,11 +1,9 @@ -// tslint:disable - +import {stdtermwidth} from '../screen' import {maxBy} from '../util' -import deps from './deps' +const wordwrap = require('wordwrap') function linewrap(length: number, s: string): string { - const lw = require('@oclif/linewrap') - return lw(length, deps.screen.stdtermwidth, { + return wordwrap(length, stdtermwidth, { skipScheme: 'ansi-color', })(s).trim() } diff --git a/src/cli-ux/open.ts b/src/cli-ux/open.ts deleted file mode 100644 index b035fd07b..000000000 --- a/src/cli-ux/open.ts +++ /dev/null @@ -1,87 +0,0 @@ -// this code is largely taken from opn -import * as childProcess from 'child_process' -const isWsl = require('is-wsl') - -export namespace open { - export type Options = { - // wait: boolean - app?: string | string[]; - } -} - -export default function open(target: string, opts: open.Options = {}) { - // opts = {wait: true, ...opts} - - let cmd - let appArgs: string[] = [] - let args: string[] = [] - const cpOpts: childProcess.SpawnOptions = {} - - if (Array.isArray(opts.app)) { - appArgs = opts.app.slice(1) - opts.app = opts.app[0] - } - - if (process.platform === 'darwin') { - cmd = 'open' - - // if (opts.wait) { - // args.push('-W') - // } - - if (opts.app) { - args.push('-a', opts.app) - } - } else if (process.platform === 'win32' || isWsl) { - cmd = 'cmd' + (isWsl ? '.exe' : '') - args.push('/c', 'start', '""', '/b') - target = target.replace(/&/g, '^&') - - // if (opts.wait) { - // args.push('/wait') - // } - - if (opts.app) { - args.push(opts.app) - } - - if (appArgs.length > 0) { - args = [...args, ...appArgs] - } - } else { - cmd = opts.app ? opts.app : 'xdg-open' - if (appArgs.length > 0) { - args = [...args, ...appArgs] - } - - // if (!opts.wait) { - // `xdg-open` will block the process unless - // stdio is ignored and it's detached from the parent - // even if it's unref'd - cpOpts.stdio = 'ignore' - cpOpts.detached = true - // } - } - - args.push(target) - - if (process.platform === 'darwin' && appArgs.length > 0) { - args.push('--args') - args = [...args, ...appArgs] - } - - const cp = childProcess.spawn(cmd, args, cpOpts) - - return new Promise((resolve, reject) => { - cp.once('error', reject) - - cp.once('close', code => { - if (Number.isInteger(code) && code! > 0) { - reject(new Error('Exited with code ' + code)) - return - } - - resolve(cp) - }) - }) -} diff --git a/src/cli-ux/prompt.ts b/src/cli-ux/prompt.ts index de66a3fd5..943466fad 100644 --- a/src/cli-ux/prompt.ts +++ b/src/cli-ux/prompt.ts @@ -1,8 +1,9 @@ import * as Errors from '../errors' -import * as chalk from 'chalk' - import config from './config' -import deps from './deps' + +import * as chalk from 'chalk' +const ansiEscapes = require('ansi-escapes') +const passwordPrompt = require('password-prompt') export interface IPromptOptions { prompt?: string; @@ -76,11 +77,11 @@ async function single(options: IPromptConfig): Promise { } function replacePrompt(prompt: string) { - process.stderr.write(deps.ansiEscapes.cursorHide + deps.ansiEscapes.cursorUp(1) + deps.ansiEscapes.cursorLeft + prompt + - deps.ansiEscapes.cursorDown(1) + deps.ansiEscapes.cursorLeft + deps.ansiEscapes.cursorShow) + process.stderr.write(ansiEscapes.cursorHide + ansiEscapes.cursorUp(1) + ansiEscapes.cursorLeft + prompt + + ansiEscapes.cursorDown(1) + ansiEscapes.cursorLeft + ansiEscapes.cursorShow) } -function _prompt(name: string, inputOptions: Partial = {}): Promise { +async function _prompt(name: string, inputOptions: Partial = {}): Promise { const prompt = getPrompt(name, inputOptions.type, inputOptions.default) const options: IPromptConfig = { isTTY: Boolean(process.env.TERM !== 'dumb' && process.stdin.isTTY), @@ -97,7 +98,7 @@ function _prompt(name: string, inputOptions: Partial = {}): Prom case 'single': return single(options) case 'mask': - return deps.passwordPrompt(options.prompt, { + return passwordPrompt(options.prompt, { method: options.type, required: options.required, default: options.default, @@ -106,7 +107,7 @@ function _prompt(name: string, inputOptions: Partial = {}): Prom return value }) case 'hide': - return deps.passwordPrompt(options.prompt, { + return passwordPrompt(options.prompt, { method: options.type, required: options.required, default: options.default, @@ -122,7 +123,7 @@ function _prompt(name: string, inputOptions: Partial = {}): Prom * @param options - @see IPromptOptions * @returns Promise */ -export function prompt(name: string, options: IPromptOptions = {}): Promise { +export async function prompt(name: string, options: IPromptOptions = {}): Promise { return config.action.pauseAsync(() => { return _prompt(name, options) }, chalk.cyan('?')) diff --git a/src/cli-ux/styled/header.ts b/src/cli-ux/styled/header.ts index a8b266c14..b284277e3 100644 --- a/src/cli-ux/styled/header.ts +++ b/src/cli-ux/styled/header.ts @@ -1,6 +1,6 @@ import * as chalk from 'chalk' -import {CliUx} from '../../index' +import {ux} from '../../index' -export default function styledHeader(header: string) { - CliUx.ux.info(chalk.dim('=== ') + chalk.bold(header) + '\n') +export default function styledHeader(header: string): void { + ux.info(chalk.dim('=== ') + chalk.bold(header) + '\n') } diff --git a/src/cli-ux/styled/index.ts b/src/cli-ux/styled/index.ts new file mode 100644 index 000000000..f7a9c3745 --- /dev/null +++ b/src/cli-ux/styled/index.ts @@ -0,0 +1,15 @@ +import styledHeader from './header' +import styledJSON from './json' +import styledObject from './object' +import * as Table from './table' +import tree from './tree' +import progress from './progress' + +export { + styledHeader, + styledJSON, + styledObject, + Table, + tree, + progress, +} diff --git a/src/cli-ux/styled/json.ts b/src/cli-ux/styled/json.ts index bc8109ab8..6da73b23c 100644 --- a/src/cli-ux/styled/json.ts +++ b/src/cli-ux/styled/json.ts @@ -1,17 +1,15 @@ -// tslint:disable restrict-plus-operands - import * as chalk from 'chalk' -import {CliUx} from '../../index' +import {ux} from '../../index' -export default function styledJSON(obj: any) { +export default function styledJSON(obj: unknown): void { const json = JSON.stringify(obj, null, 2) if (!chalk.level) { - CliUx.ux.info(json) + ux.info(json) return } const cardinal = require('cardinal') const theme = require('cardinal/themes/jq') - CliUx.ux.info(cardinal.highlight(json, {json: true, theme})) + ux.info(cardinal.highlight(json, {json: true, theme})) } diff --git a/src/cli-ux/styled/object.ts b/src/cli-ux/styled/object.ts index 87b827141..fd872ef79 100644 --- a/src/cli-ux/styled/object.ts +++ b/src/cli-ux/styled/object.ts @@ -1,8 +1,7 @@ -// tslint:disable - import * as chalk from 'chalk' import * as util from 'util' +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export default function styledObject(obj: any, keys?: string[]): string { const output: string[] = [] const keyLengths = Object.keys(obj).map(key => key.toString().length) diff --git a/src/cli-ux/styled/progress.ts b/src/cli-ux/styled/progress.ts index 4184c81c6..26b0086fe 100644 --- a/src/cli-ux/styled/progress.ts +++ b/src/cli-ux/styled/progress.ts @@ -1,12 +1,7 @@ // 3pp import * as cliProgress from 'cli-progress' -export default function progress(options?: any): any { - // if no options passed, create empty options - if (!options) { - options = {} - } - +export default function progress(options: cliProgress.Options = {}): cliProgress.SingleBar { // set noTTYOutput for options options.noTTYOutput = Boolean(process.env.TERM === 'dumb' || !process.stdin.isTTY) diff --git a/src/cli-ux/styled/table.ts b/src/cli-ux/styled/table.ts index 2722c3761..cb794ecac 100644 --- a/src/cli-ux/styled/table.ts +++ b/src/cli-ux/styled/table.ts @@ -138,7 +138,6 @@ class Table> { } private resolveColumnsToObjectArray() { - // tslint:disable-next-line:no-this-assignment const {data, columns} = this return data.map((d: any) => { // eslint-disable-next-line unicorn/prefer-object-from-entries @@ -160,7 +159,6 @@ class Table> { } private outputCSV() { - // tslint:disable-next-line:no-this-assignment const {data, columns, options} = this if (!options['no-header']) { @@ -174,7 +172,6 @@ class Table> { } private outputTable() { - // tslint:disable-next-line:no-this-assignment const {data, options} = this // column truncation // @@ -298,7 +295,7 @@ class Table> { } } -export function table>(data: T[], columns: table.Columns, options: table.Options = {}) { +export function table>(data: T[], columns: table.Columns, options: table.Options = {}): void { new Table(data, columns, options).display() } @@ -334,6 +331,7 @@ export namespace table { export function flags(): IFlags export function flags(opts: { except: Z | Z[] }): ExcludeFlags export function flags(opts: { only: K | K[] }): IncludeFlags + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function flags(opts?: any): any { if (opts) { const f = {} diff --git a/src/cli-ux/styled/tree.ts b/src/cli-ux/styled/tree.ts index d14e98494..75b2dbc4e 100644 --- a/src/cli-ux/styled/tree.ts +++ b/src/cli-ux/styled/tree.ts @@ -19,8 +19,7 @@ export class Tree { } } - // tslint:disable-next-line:no-console - display(logger: any = console.log) { + display(logger: any = console.log): void { const addNodes = function (nodes: any) { const tree: { [key: string]: any } = {} for (const p of Object.keys(nodes)) { @@ -35,6 +34,6 @@ export class Tree { } } -export default function tree() { +export default function tree(): Tree { return new Tree() } diff --git a/src/cli-ux/wait.ts b/src/cli-ux/wait.ts index 769321a42..3d5560c4b 100644 --- a/src/cli-ux/wait.ts +++ b/src/cli-ux/wait.ts @@ -1,5 +1,4 @@ -// tslint:disable no-string-based-set-timeout -export default (ms = 1000) => { +export default (ms = 1000): Promise => { return new Promise(resolve => { setTimeout(resolve, ms) }) diff --git a/src/command.ts b/src/command.ts index f054a507c..f28f30dc7 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,17 +1,34 @@ import {fileURLToPath} from 'url' - +import * as chalk from 'chalk' import {format, inspect} from 'util' -import {CliUx, toConfiguredId} from './index' +import * as ux from './cli-ux' import {Config} from './config' -import * as Interfaces from './interfaces' import * as Errors from './errors' import {PrettyPrintableError} from './errors' import * as Parser from './parser' -import * as Flags from './flags' -import {Deprecation} from './interfaces/parser' -import {formatCommandDeprecationWarning, formatFlagDeprecationWarning, normalizeArgv} from './help/util' - -const pjson = require('../package.json') +import { + BooleanFlagProps, + CompletableFlag, + Deprecation, + Arg as IArg, + ArgInput, + FlagInput, + FlagOutput, + Input, + ArgProps, + OptionFlagProps, + ParserOutput, + ArgOutput, +} from './interfaces/parser' +import {formatCommandDeprecationWarning, formatFlagDeprecationWarning, toConfiguredId, normalizeArgv} from './help/util' +import {Plugin} from './interfaces/plugin' +import {LoadOptions} from './interfaces/config' +import {CommandError} from './interfaces/errors' +import {boolean} from './flags' +import {requireJson} from './util' +import {PJSON} from './interfaces' + +const pjson = requireJson(__dirname, '..', 'package.json') /** * swallows stdout epipe errors @@ -24,7 +41,7 @@ process.stdout.on('error', (err: any) => { }) const jsonFlag = { - json: Flags.boolean({ + json: boolean({ description: 'Format output as json.', helpGroup: 'GLOBAL', }), @@ -35,32 +52,32 @@ const jsonFlag = { * in your project. */ -export default abstract class Command { - static _base = `${pjson.name}@${pjson.version}` +export abstract class Command { + private static readonly _base = `${pjson.name}@${pjson.version}` /** A command ID, used mostly in error or verbose reporting. */ - static id: string + public static id: string /** * The tweet-sized description for your class, used in a parent-commands * sub-command listing and as the header for the command help. */ - static summary?: string; + public static summary?: string; /** * A full description of how to use the command. * * If no summary, the first line of the description will be used as the summary. */ - static description: string | undefined + public static description: string | undefined /** Hide the command from help */ - static hidden: boolean + public static hidden: boolean /** Mark the command as a given state (e.g. beta or deprecated) in help */ - static state?: 'beta' | 'deprecated' | string; + public static state?: 'beta' | 'deprecated' | string; - static deprecationOptions?: Deprecation; + public static deprecationOptions?: Deprecation; /** * Emit deprecation warning when a command alias is used @@ -70,22 +87,24 @@ export default abstract class Command { /** * An override string (or strings) for the default usage documentation. */ - static usage: string | string[] | undefined + public static usage: string | string[] | undefined - static help: string | undefined + public static help: string | undefined /** An array of aliases for this command. */ - static aliases: string[] = [] + public static aliases: string[] = [] /** When set to false, allows a variable amount of arguments */ - static strict = true + public static strict = true - static parse = true + /** An order-dependent object of arguments for the command */ + public static args: ArgInput = {} - /** An order-dependent array of arguments for the command */ - static args?: Interfaces.ArgInput + public static plugin: Plugin | undefined - static plugin: Interfaces.Plugin | undefined + public static readonly pluginName?: string; + public static readonly pluginType?: string; + public static readonly pluginAlias?: string; /** * An array of examples to show at the end of the command's help. @@ -101,35 +120,36 @@ export default abstract class Command { * $ <%= config.bin => command flags * ``` */ - static examples: Interfaces.Example[] + public static examples: Command.Example[] - static parserOptions = {} + public static hasDynamicHelp = false - static _enableJsonFlag = false + protected static _enableJsonFlag = false - static get enableJsonFlag(): boolean { + public static get enableJsonFlag(): boolean { return this._enableJsonFlag } - static set enableJsonFlag(value: boolean) { + public static set enableJsonFlag(value: boolean) { this._enableJsonFlag = value if (value === true) { - this.globalFlags = jsonFlag + this.baseFlags = jsonFlag } else { - delete this.globalFlags?.json + delete this.baseFlags?.json this.flags = {} // force the flags setter to run delete this.flags?.json } } - // eslint-disable-next-line valid-jsdoc /** * instantiate and run the command - * @param {Interfaces.Command.Class} this Class + * + * @param {Command.Class} this - the command class * @param {string[]} argv argv - * @param {Interfaces.LoadOptions} opts options + * @param {LoadOptions} opts options + * @returns {Promise} result */ - static run: Interfaces.Command.Class['run'] = async function (this: Interfaces.Command.Class, argv?: string[], opts?) { + public static async run(this: new(argv: string[], config: Config) => T, argv?: string[], opts?: LoadOptions): Promise { if (!argv) argv = process.argv.slice(2) // Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path. @@ -137,39 +157,38 @@ export default abstract class Command { opts = fileURLToPath(opts) } - // to-do: update in node-14 to module.main - const config = await Config.load(opts || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) + const config = await Config.load(opts || require.main?.filename || __dirname) const cmd = new this(argv, config) - return cmd._run(argv) + return cmd._run() } - protected static _globalFlags: Interfaces.FlagInput + protected static _baseFlags: FlagInput - static get globalFlags(): Interfaces.FlagInput { - return this._globalFlags + static get baseFlags(): FlagInput { + return this._baseFlags } - static set globalFlags(flags: Interfaces.FlagInput) { - this._globalFlags = Object.assign({}, this.globalFlags, flags) + static set baseFlags(flags: FlagInput) { + this._baseFlags = Object.assign({}, this.baseFlags, flags) this.flags = {} // force the flags setter to run } /** A hash of flags for the command */ - protected static _flags: Interfaces.FlagInput + protected static _flags: FlagInput - static get flags(): Interfaces.FlagInput { + public static get flags(): FlagInput { return this._flags } - static set flags(flags: Interfaces.FlagInput) { - this._flags = Object.assign({}, this._flags ?? {}, this.globalFlags, flags) + public static set flags(flags: FlagInput) { + this._flags = Object.assign({}, this._flags ?? {}, this.baseFlags, flags) } - id: string | undefined + public id: string | undefined protected debug: (...args: any[]) => void - constructor(public argv: string[], public config: Config) { + public constructor(public argv: string[], public config: Config) { this.id = this.ctor.id try { this.debug = require('debug')(this.id ? `${this.config.bin}:${this.id}` : this.config.bin) @@ -178,11 +197,11 @@ export default abstract class Command { } } - get ctor(): typeof Command { + protected get ctor(): typeof Command { return this.constructor as typeof Command } - async _run(): Promise { + protected async _run(): Promise { let err: Error | undefined let result try { @@ -198,37 +217,37 @@ export default abstract class Command { } if (result && this.jsonEnabled()) { - CliUx.ux.styledJSON(this.toSuccessJson(result)) + ux.styledJSON(this.toSuccessJson(result)) } return result } - exit(code = 0): void { + public exit(code = 0): void { return Errors.exit(code) } - warn(input: string | Error): string | Error { + public warn(input: string | Error): string | Error { if (!this.jsonEnabled()) Errors.warn(input) return input } - error(input: string | Error, options: {code?: string; exit: false} & PrettyPrintableError): void + public error(input: string | Error, options: {code?: string; exit: false} & PrettyPrintableError): void - error(input: string | Error, options?: {code?: string; exit?: number} & PrettyPrintableError): never + public error(input: string | Error, options?: {code?: string; exit?: number} & PrettyPrintableError): never - error(input: string | Error, options: {code?: string; exit?: number | false} & PrettyPrintableError = {}): void { + public error(input: string | Error, options: {code?: string; exit?: number | false} & PrettyPrintableError = {}): void { return Errors.error(input, options as any) } - log(message = '', ...args: any[]): void { + public log(message = '', ...args: any[]): void { if (!this.jsonEnabled()) { message = typeof message === 'string' ? message : inspect(message) process.stdout.write(format(message, ...args) + '\n') } } - logToStderr(message = '', ...args: any[]): void { + public logToStderr(message = '', ...args: any[]): void { if (!this.jsonEnabled()) { message = typeof message === 'string' ? message : inspect(message) process.stderr.write(format(message, ...args) + '\n') @@ -242,7 +261,7 @@ export default abstract class Command { /** * actual command run code goes here */ - abstract run(): PromiseLike + public abstract run(): PromiseLike protected async init(): Promise { this.debug('init version: %s argv: %o', this.ctor._base, this.argv) @@ -254,7 +273,7 @@ export default abstract class Command { this.warnIfCommandDeprecated() } - protected warnIfFlagDeprecated(flags: Record) { + protected warnIfFlagDeprecated(flags: Record): void { for (const flag of Object.keys(flags)) { const deprecated = this.ctor.flags[flag]?.deprecated if (deprecated) { @@ -264,7 +283,7 @@ export default abstract class Command { const deprecateAliases = this.ctor.flags[flag]?.deprecateAliases const aliases = (this.ctor.flags[flag]?.aliases ?? []).map(a => a.length === 1 ? `-${a}` : `--${a}`) if (deprecateAliases && aliases.length > 0) { - const foundAliases = this.argv.filter(a => aliases.includes(a)) + const foundAliases = aliases.filter(alias => this.argv.some(a => a.startsWith(alias))) for (const alias of foundAliases) { this.warn(formatFlagDeprecationWarning(alias, {to: this.ctor.flags[flag]?.name})) } @@ -287,27 +306,26 @@ export default abstract class Command { } } - protected async parse(options?: Interfaces.Input, argv = this.argv): Promise> { - if (!options) options = this.constructor as any + protected async parse(options?: Input, argv = this.argv): Promise> { + if (!options) options = this.ctor as Input const opts = {context: this, ...options} // the spread operator doesn't work with getters so we have to manually add it here opts.flags = options?.flags opts.args = options?.args - const results = await Parser.parse(argv, opts) + const results = await Parser.parse(argv, opts) this.warnIfFlagDeprecated(results.flags ?? {}) return results } - protected async catch(err: Interfaces.CommandError): Promise { + protected async catch(err: CommandError): Promise { process.exitCode = process.exitCode ?? err.exitCode ?? 1 if (this.jsonEnabled()) { - CliUx.ux.styledJSON(this.toErrorJson(err)) + ux.styledJSON(this.toErrorJson(err)) } else { if (!err.message) throw err try { - const chalk = require('chalk') - CliUx.ux.action.stop(chalk.bold.red('!')) + ux.action.stop(chalk.bold.red('!')) } catch {} throw err @@ -331,3 +349,54 @@ export default abstract class Command { return {error: err} } } + +export namespace Command { + export type Class = typeof Command & { + id: string; + run(argv?: string[], config?: LoadOptions): PromiseLike; + } + + export interface Loadable extends Cached { + load(): Promise + } + + export type Cached = { + [key: string]: unknown; + id: string; + hidden: boolean; + state?: 'beta' | 'deprecated' | string; + deprecationOptions?: Deprecation; + aliases: string[]; + summary?: string; + description?: string; + usage?: string | string[]; + examples?: Example[]; + strict?: boolean; + type?: string; + pluginName?: string; + pluginType?: string; + pluginAlias?: string; + flags: {[name: string]: Flag.Cached}; + args: {[name: string]: Arg.Cached}; + hasDynamicHelp?: boolean; + } + + export type Flag = CompletableFlag + + export namespace Flag { + export type Cached = Omit & (BooleanFlagProps | OptionFlagProps) + export type Any = Flag | Cached + } + + export type Arg = IArg + + export namespace Arg { + export type Cached = Omit & ArgProps + export type Any = Arg | Cached + } + + export type Example = string | { + description: string; + command: string; + } +} diff --git a/src/config/config.ts b/src/config/config.ts index eeb6cc1a8..d8abf7064 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -7,17 +7,19 @@ import {format} from 'util' import {Options, Plugin as IPlugin} from '../interfaces/plugin' import {Config as IConfig, ArchTypes, PlatformTypes, LoadOptions} from '../interfaces/config' -import {ArgInput, Command, CompletableOptionFlag, Hook, Hooks, PJSON, Topic} from '../interfaces' +import {Hook, Hooks, PJSON, Topic} from '../interfaces' import * as Plugin from './plugin' import {Debug, compact, loadJSON, collectUsableIds, getCommandIdPermutations} from './util' -import {isProd} from '../util' +import {isProd, requireJson} from '../util' import ModuleLoader from '../module-loader' import {getHelpFlagAdditions} from '../help/util' +import {Command} from '../command' +import {CompletableOptionFlag, Arg, ArgInput} from '../interfaces/parser' // eslint-disable-next-line new-cap const debug = Debug() -const _pjson = require('../../package.json') +const _pjson = requireJson(__dirname, '..', '..', 'package.json') function channelFromVersion(version: string) { const m = version.match(/[^-]+(?:-([^.]+))?/) @@ -26,7 +28,7 @@ function channelFromVersion(version: string) { const WSL = require('is-wsl') -function isConfig(o: any): o is IConfig { +function isConfig(o: any): o is Config { return o && Boolean(o._base) } @@ -62,57 +64,33 @@ class Permutations extends Map> { } export class Config implements IConfig { - _base = `${_pjson.name}@${_pjson.version}` - - name!: string - - version!: string - - channel!: string - - root!: string - - arch!: ArchTypes - - bin!: string - - cacheDir!: string - - configDir!: string - - dataDir!: string - - dirname!: string - - errlog!: string - - home!: string - - platform!: PlatformTypes - - shell!: string - - windows!: boolean - - userAgent!: string - - debug = 0 - - npmRegistry?: string - - pjson!: PJSON.CLI - - userPJSON?: PJSON.User - - plugins: IPlugin[] = [] - - binPath?: string - - valid!: boolean - - topicSeparator: ':' | ' ' = ':' - - flexibleTaxonomy!: boolean + private _base = `${_pjson.name}@${_pjson.version}` + + public arch!: ArchTypes + public bin!: string + public binPath?: string + public cacheDir!: string + public channel!: string + public configDir!: string + public dataDir!: string + public debug = 0 + public dirname!: string + public errlog!: string + public flexibleTaxonomy!: boolean + public home!: string + public name!: string + public npmRegistry?: string + public pjson!: PJSON.CLI + public platform!: PlatformTypes + public plugins: IPlugin[] = [] + public root!: string + public shell!: string + public topicSeparator: ':' | ' ' = ':' + public userAgent!: string + public userPJSON?: PJSON.User + public valid!: boolean + public version!: string + public windows!: boolean protected warned = false @@ -126,10 +104,9 @@ export class Config implements IConfig { private _commandIDs!: string[] - // eslint-disable-next-line no-useless-constructor constructor(public options: Options) {} - static async load(opts: LoadOptions = (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) { + static async load(opts: LoadOptions = module.filename || __dirname): Promise { // Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path. if (typeof opts === 'string' && opts.startsWith('file://')) { opts = fileURLToPath(opts) @@ -137,13 +114,14 @@ export class Config implements IConfig { if (typeof opts === 'string') opts = {root: opts} if (isConfig(opts)) return opts + const config = new Config(opts) await config.load() return config } // eslint-disable-next-line complexity - async load() { + public async load(): Promise { const plugin = new Plugin.Plugin({root: this.options.root}) await plugin.load() this.plugins.push(plugin) @@ -212,13 +190,13 @@ export class Config implements IConfig { debug('config done') } - async loadCorePlugins() { + public async loadCorePlugins(): Promise { if (this.pjson.oclif.plugins) { await this.loadPlugins(this.root, 'core', this.pjson.oclif.plugins) } } - async loadDevPlugins() { + public async loadDevPlugins(): Promise { if (this.options.devPlugins !== false) { // do not load oclif.devPlugins in production if (this.isProd) return @@ -231,7 +209,7 @@ export class Config implements IConfig { } } - async loadUserPlugins() { + public async loadUserPlugins(): Promise { if (this.options.userPlugins !== false) { try { const userPJSONPath = path.join(this.dataDir, 'package.json') @@ -248,7 +226,7 @@ export class Config implements IConfig { } } - async runHook(event: T, opts: Hooks[T]['options'], timeout?: number): Promise> { + public async runHook(event: T, opts: Hooks[T]['options'], timeout?: number): Promise> { debug('start %s hook', event) const search = (m: any): Hook => { if (typeof m === 'function') return m @@ -322,10 +300,9 @@ export class Config implements IConfig { return final } - // eslint-disable-next-line default-param-last - async runCommand(id: string, argv: string[] = [], cachedCommand?: Command.Loadable): Promise { + public async runCommand(id: string, argv: string[] = [], cachedCommand: Command.Loadable | null = null): Promise { debug('runCommand %s %o', id, argv) - const c = cachedCommand || this.findCommand(id) + const c = cachedCommand ?? this.findCommand(id) if (!c) { const matches = this.flexibleTaxonomy ? this.findMatches(id, argv) : [] const hookResult = this.flexibleTaxonomy && matches.length > 0 ? @@ -346,43 +323,44 @@ export class Config implements IConfig { const command = await c.load() await this.runHook('prerun', {Command: command, argv}) + const result = (await command.run(argv, this)) as T - await this.runHook('postrun', {Command: command, result: result, argv}) + await this.runHook('postrun', {Command: command, result, argv}) return result } - scopedEnvVar(k: string) { + public scopedEnvVar(k: string): string | undefined { return process.env[this.scopedEnvVarKey(k)] } - scopedEnvVarTrue(k: string): boolean { + public scopedEnvVarTrue(k: string): boolean { const v = process.env[this.scopedEnvVarKey(k)] return v === '1' || v === 'true' } - scopedEnvVarKey(k: string) { + public scopedEnvVarKey(k: string): string { return [this.bin, k] .map(p => p.replace(/@/g, '').replace(/[/-]/g, '_')) .join('_') .toUpperCase() } - findCommand(id: string, opts: { must: true }): Command.Loadable + public findCommand(id: string, opts: { must: true }): Command.Loadable - findCommand(id: string, opts?: { must: boolean }): Command.Loadable | undefined + public findCommand(id: string, opts?: { must: boolean }): Command.Loadable | undefined - findCommand(id: string, opts: { must?: boolean } = {}): Command.Loadable | undefined { + public findCommand(id: string, opts: { must?: boolean } = {}): Command.Loadable | undefined { const lookupId = this.getCmdLookupId(id) const command = this._commands.get(lookupId) if (opts.must && !command) error(`command ${lookupId} not found`) return command } - findTopic(id: string, opts: { must: true }): Topic + public findTopic(id: string, opts: { must: true }): Topic - findTopic(id: string, opts?: { must: boolean }): Topic | undefined + public findTopic(id: string, opts?: { must: boolean }): Topic | undefined - findTopic(name: string, opts: { must?: boolean } = {}) { + public findTopic(name: string, opts: { must?: boolean } = {}): Topic | undefined { const lookupId = this.getTopicLookupId(name) const topic = this._topics.get(lookupId) if (topic) return topic @@ -402,7 +380,7 @@ export class Config implements IConfig { * @param argv string[] process.argv containing the flags and arguments provided by the user * @returns string[] */ - findMatches(partialCmdId: string, argv: string[]): Command.Loadable[] { + public findMatches(partialCmdId: string, argv: string[]): Command.Loadable[] { const flags = argv.filter(arg => !getHelpFlagAdditions(this).includes(arg) && arg.startsWith('-')).map(a => a.replace(/-/g, '')) const possibleMatches = [...this.commandPermutations.get(partialCmdId)].map(k => this._commands.get(k)!) @@ -422,7 +400,7 @@ export class Config implements IConfig { * Returns an array of all commands. If flexible taxonomy is enabled then all permutations will be appended to the array. * @returns Command.Loadable[] */ - getAllCommands(): Command.Loadable[] { + public getAllCommands(): Command.Loadable[] { const commands = [...this._commands.values()] const validPermutations = [...this.commandPermutations.getAllValid()] for (const permutation of validPermutations) { @@ -439,32 +417,32 @@ export class Config implements IConfig { * Returns an array of all command ids. If flexible taxonomy is enabled then all permutations will be appended to the array. * @returns string[] */ - getAllCommandIDs(): string[] { + public getAllCommandIDs(): string[] { return this.getAllCommands().map(c => c.id) } - get commands(): Command.Loadable[] { + public get commands(): Command.Loadable[] { return [...this._commands.values()] } - get commandIDs(): string[] { + public get commandIDs(): string[] { if (this._commandIDs) return this._commandIDs this._commandIDs = this.commands.map(c => c.id) return this._commandIDs } - get topics(): Topic[] { + public get topics(): Topic[] { return [...this._topics.values()] } - s3Key(type: keyof PJSON.S3.Templates, ext?: '.tar.gz' | '.tar.xz' | IConfig.s3Key.Options, options: IConfig.s3Key.Options = {}) { + public s3Key(type: keyof PJSON.S3.Templates, ext?: '.tar.gz' | '.tar.xz' | IConfig.s3Key.Options, options: IConfig.s3Key.Options = {}): string { if (typeof ext === 'object') options = ext else if (ext) options.ext = ext const template = this.pjson.oclif.update.s3.templates[options.platform ? 'target' : 'vanilla'][type] ?? '' return ejs.render(template, {...this as any, ...options}) } - s3Url(key: string) { + public s3Url(key: string): string { const host = this.pjson.oclif.update.s3.host if (!host) throw new Error('no s3 host is set') const url = new URL(host) @@ -479,15 +457,15 @@ export class Config implements IConfig { return path.join(base, this.dirname) } - protected windowsHome() { + protected windowsHome(): string | undefined { return this.windowsHomedriveHome() || this.windowsUserprofileHome() } - protected windowsHomedriveHome() { + protected windowsHomedriveHome(): string | undefined { return (process.env.HOMEDRIVE && process.env.HOMEPATH && path.join(process.env.HOMEDRIVE!, process.env.HOMEPATH!)) } - protected windowsUserprofileHome() { + protected windowsUserprofileHome(): string | undefined { return process.env.USERPROFILE } @@ -519,7 +497,7 @@ export class Config implements IConfig { return 0 } - protected async loadPlugins(root: string, type: string, plugins: (string | { root?: string; name?: string; tag?: string })[], parent?: Plugin.Plugin) { + protected async loadPlugins(root: string, type: string, plugins: (string | { root?: string; name?: string; tag?: string })[], parent?: Plugin.Plugin): Promise { if (!plugins || plugins.length === 0) return debug('loading plugins', plugins) await Promise.all((plugins || []).map(async plugin => { @@ -550,7 +528,7 @@ export class Config implements IConfig { })) } - protected warn(err: string | Error | { name: string; detail: string }, scope?: string) { + protected warn(err: string | Error | { name: string; detail: string }, scope?: string): void { if (this.warned) return if (typeof err === 'string') { @@ -588,7 +566,7 @@ export class Config implements IConfig { process.emitWarning(JSON.stringify(err)) } - protected get isProd() { + protected get isProd(): boolean { return isProd() } @@ -714,7 +692,7 @@ export class Config implements IConfig { } // when no manifest exists, the default is calculated. This may throw, so we need to catch it -const defaultToCached = async (flag: CompletableOptionFlag) => { +const defaultFlagToCached = async (flag: CompletableOptionFlag) => { // Prefer the helpDefaultValue function (returns a friendly string for complex types) if (typeof flag.defaultHelp === 'function') { try { @@ -734,8 +712,28 @@ const defaultToCached = async (flag: CompletableOptionFlag) => { } } -export async function toCached(c: Command.Class, plugin?: IPlugin): Promise { - const flags = {} as {[k: string]: Command.Flag} +const defaultArgToCached = async (arg: Arg) => { + // Prefer the helpDefaultValue function (returns a friendly string for complex types) + if (typeof arg.defaultHelp === 'function') { + try { + return await arg.defaultHelp() + } catch { + return + } + } + + // if not specified, try the default function + if (typeof arg.default === 'function') { + try { + return await arg.default({options: {}, flags: {}}) + } catch {} + } else { + return arg.default + } +} + +export async function toCached(c: Command.Class, plugin?: IPlugin): Promise { + const flags = {} as {[k: string]: Command.Flag.Cached} for (const [name, flag] of Object.entries(c.flags || {})) { if (flag.type === 'boolean') { @@ -756,6 +754,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise ({ - name: a.name, - description: a.description, - required: a.required, - options: a.options, - default: typeof a.default === 'function' ? await a.default({}) : a.default, - hidden: a.hidden, - })) - - const args = await Promise.all(argsPromise) + // v1 commands have args as an array, so we need to normalize it to an object for backwards compatibility + const normalized = (Array.isArray(c.args) ? (c.args ?? []).reduce((x, y) => { + return {...x, [y.name]: y} + }, {} as ArgInput) : c.args ?? {}) as ArgInput + + const args = {} as {[k: string]: Command.Arg.Cached} + for (const [name, arg] of Object.entries(normalized)) { + args[name] = { + name, + description: arg.description, + required: arg.required, + options: arg.options, + default: await defaultArgToCached(arg), + hidden: arg.hidden, + } + } const stdProperties = { id: c.id, @@ -818,11 +822,10 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise ![...stdKeys, ...ignoreCommandProperties].includes(property)) - const additionalProperties: any = {} + const additionalProperties: Record = {} for (const key of keysToAdd) { additionalProperties[key] = (c as any)[key] } diff --git a/src/config/index.ts b/src/config/index.ts index 0436fce83..f83d0d268 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,8 +1,3 @@ -try { - // eslint-disable-next-line node/no-missing-require - require('fs-extra-debug') -} catch {} - export {Config, toCached} from './config' export {Plugin} from './plugin' export {tsPath} from './ts-node' diff --git a/src/config/plugin.ts b/src/config/plugin.ts index 9e69b002b..5285b2b71 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -1,10 +1,9 @@ -import {error} from '../errors' -import * as Globby from 'globby' +import {CLIError, error} from '../errors' +import * as globby from 'globby' import * as path from 'path' import {inspect} from 'util' import {Plugin as IPlugin, PluginOptions} from '../interfaces/plugin' -import {Command} from '../interfaces/command' import {toCached} from './config' import {Debug} from './util' import {Manifest} from '../interfaces/manifest' @@ -12,21 +11,22 @@ import {PJSON} from '../interfaces/pjson' import {Topic} from '../interfaces/topic' import {tsPath} from './ts-node' import {compact, exists, resolvePackage, flatMap, loadJSON, mapValues} from './util' -import {isProd} from '../util' +import {isProd, requireJson} from '../util' import ModuleLoader from '../module-loader' +import {Command} from '../command' -const _pjson = require('../../package.json') +const _pjson = requireJson(__dirname, '..', '..', 'package.json') function topicsToArray(input: any, base?: string): Topic[] { if (!input) return [] base = base ? `${base}:` : '' if (Array.isArray(input)) { - return input.concat(flatMap(input, t => topicsToArray(t.subtopics, `${base}${t.name}`))) + return [...input, ...flatMap(input, t => topicsToArray(t.subtopics, `${base}${t.name}`))] } return flatMap(Object.keys(input), k => { input[k].name = k - return [{...input[k], name: `${base}${k}`}].concat(topicsToArray(input[k].subtopics, `${base}${input[k].name}`)) + return [{...input[k], name: `${base}${k}`}, ...topicsToArray(input[k].subtopics, `${base}${input[k].name}`)] }) } @@ -129,10 +129,9 @@ export class Plugin implements IPlugin { protected warned = false - // eslint-disable-next-line no-useless-constructor constructor(public options: PluginOptions) {} - async load() { + public async load(): Promise { this.type = this.options.type || 'core' this.tag = this.options.tag const root = await findRoot(this.options.name, this.options.root) @@ -163,24 +162,16 @@ export class Plugin implements IPlugin { .sort((a, b) => a.id.localeCompare(b.id)) } - get topics(): Topic[] { + public get topics(): Topic[] { return topicsToArray(this.pjson.oclif.topics || {}) } - get commandsDir() { + public get commandsDir(): string | undefined { return tsPath(this.root, this.pjson.oclif.commands) } - get commandIDs() { + public get commandIDs(): string[] { if (!this.commandsDir) return [] - let globby: typeof Globby - try { - const globbyPath = require.resolve('globby', {paths: [this.root, __dirname]}) - globby = require(globbyPath) - } catch (error: any) { - this.warn(error, 'not loading commands, globby not found') - return [] - } this._debug(`loading IDs from ${this.commandsDir}`) const patterns = [ @@ -232,6 +223,7 @@ export class Plugin implements IPlugin { const cmd = await fetch() if (!cmd && opts.must) error(`command ${id} not found`) + return cmd } @@ -271,15 +263,15 @@ export class Plugin implements IPlugin { else throw this.addErrorScope(error, scope) } }))) - .filter((f): f is [string, Command] => Boolean(f)) + .filter((f): f is [string, Command.Cached] => Boolean(f)) .reduce((commands, [id, c]) => { commands[id] = c return commands - }, {} as {[k: string]: Command}), + }, {} as {[k: string]: Command.Cached}), } } - protected warn(err: any, scope?: string) { + protected warn(err: string | Error | CLIError, scope?: string): void { if (this.warned) return if (typeof err === 'string') err = new Error(err) process.emitWarning(this.addErrorScope(err, scope)) diff --git a/src/config/util.ts b/src/config/util.ts index 5051b06c9..e09aa51c3 100644 --- a/src/config/util.ts +++ b/src/config/util.ts @@ -3,7 +3,7 @@ import * as fs from 'fs' const debug = require('debug') export function flatMap(arr: T[], fn: (i: T) => U[]): U[] { - return arr.reduce((arr, i) => arr.concat(fn(i)), [] as U[]) + return arr.reduce((arr, i) => [...arr, ...fn(i)], [] as U[]) } export function mapValues, TResult>(obj: {[P in keyof T]: T[P]}, fn: (i: T[keyof T], k: keyof T) => TResult): {[P in keyof T]: TResult} { @@ -75,7 +75,7 @@ export function getPermutations(arr: string[]): Array { for (let j = 0, len2 = partial.length; j <= len2; j++) { const start = partial.slice(0, j) const end = partial.slice(j) - const merged = start.concat(first, end) + const merged = [...start, first, ...end] output.push(merged) } diff --git a/src/errors/errors/cli.ts b/src/errors/errors/cli.ts index 6cf49dd41..67df36c92 100644 --- a/src/errors/errors/cli.ts +++ b/src/errors/errors/cli.ts @@ -52,7 +52,7 @@ export class CLIError extends Error implements OclifError { return output } - get bang() { + get bang(): string | undefined { try { return chalk.red(process.platform === 'win32' ? '»' : '›') } catch {} @@ -66,7 +66,7 @@ export namespace CLIError { this.name = 'Warning' } - get bang() { + get bang(): string | undefined { try { return chalk.yellow(process.platform === 'win32' ? '»' : '›') } catch {} diff --git a/src/errors/errors/pretty-print.ts b/src/errors/errors/pretty-print.ts index c0d7ee751..1f29a1275 100644 --- a/src/errors/errors/pretty-print.ts +++ b/src/errors/errors/pretty-print.ts @@ -30,7 +30,7 @@ const formatSuggestions = (suggestions?: string[]): string | undefined => { return `${label}\n${indent(multiple, 2)}` } -export default function prettyPrint(error: Error & PrettyPrintableError & CLIErrorDisplayOptions) { +export default function prettyPrint(error: Error & PrettyPrintableError & CLIErrorDisplayOptions): string | undefined { if (config.debug) { return error.stack } diff --git a/src/errors/handle.ts b/src/errors/handle.ts index e11670931..08df662f5 100644 --- a/src/errors/handle.ts +++ b/src/errors/handle.ts @@ -7,7 +7,7 @@ import clean = require('clean-stack') import {CLIError} from './errors/cli' import {OclifError, PrettyPrintableError} from '../interfaces' -export const handle = (err: Error & Partial & Partial) => { +export const handle = (err: Error & Partial & Partial): void => { try { if (!err) err = new CLIError('no error?') if (err.message === 'SIGINT') process.exit(1) diff --git a/src/errors/index.ts b/src/errors/index.ts index 2fa85ae6b..e3810907d 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,5 +1,3 @@ -// tslint:disable no-console - export {handle} from './handle' export {ExitError} from './errors/exit' export {ModuleLoadError} from './errors/module-load' @@ -21,7 +19,7 @@ export function exit(code = 0): never { export function error(input: string | Error, options: {exit: false} & PrettyPrintableError): void export function error(input: string | Error, options?: {exit?: number} & PrettyPrintableError): never -export function error(input: string | Error, options: {exit?: number | false} & PrettyPrintableError = {}) { +export function error(input: string | Error, options: {exit?: number | false} & PrettyPrintableError = {}): void { let err: Error & OclifError if (typeof input === 'string') { @@ -41,7 +39,7 @@ export function error(input: string | Error, options: {exit?: number | false} & } else throw err } -export function warn(input: string | Error) { +export function warn(input: string | Error): void { let err: Error & OclifError if (typeof input === 'string') { diff --git a/src/errors/logger.ts b/src/errors/logger.ts index 0d7eff744..cff03cfb7 100644 --- a/src/errors/logger.ts +++ b/src/errors/logger.ts @@ -1,6 +1,6 @@ -import * as FS from 'fs-extra' +import * as fs from 'fs-extra' import * as path from 'path' -import StripAnsi = require('strip-ansi') +import stripAnsi = require('strip-ansi') const timestamp = () => new Date().toISOString() let timer: any @@ -19,25 +19,21 @@ export class Logger { protected buffer: string[] = [] - // eslint-disable-next-line no-useless-constructor constructor(public file: string) {} - log(msg: string) { - const stripAnsi: typeof StripAnsi = require('strip-ansi') + log(msg: string): void { msg = stripAnsi(chomp(msg)) const lines = msg.split('\n').map(l => `${timestamp()} ${l}`.trimEnd()) this.buffer.push(...lines) - // tslint:disable-next-line no-console this.flush(50).catch(console.error) } - async flush(waitForMs = 0) { + async flush(waitForMs = 0): Promise { await wait(waitForMs) this.flushing = this.flushing.then(async () => { if (this.buffer.length === 0) return const mylines = this.buffer this.buffer = [] - const fs: typeof FS = require('fs-extra') await fs.mkdirp(path.dirname(this.file)) await fs.appendFile(this.file, mylines.join('\n') + '\n') }) diff --git a/src/flags.ts b/src/flags.ts index dc94effe8..a266d7377 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -1,42 +1,119 @@ -import {OptionFlag, BooleanFlag, EnumFlagOptions, Default} from './interfaces' -import {custom, boolean} from './parser' -import Command from './command' +import {URL} from 'url' import {Help} from './help' -export {boolean, integer, url, directory, file, string, build, option, custom} from './parser' - -export function _enum(opts: EnumFlagOptions & {multiple: true} & ({required: true} | { default: Default })): OptionFlag -export function _enum(opts: EnumFlagOptions & {multiple: true}): OptionFlag -export function _enum(opts: EnumFlagOptions & ({required: true} | { default: Default })): OptionFlag -export function _enum(opts: EnumFlagOptions): OptionFlag -export function _enum(opts: EnumFlagOptions): OptionFlag | OptionFlag | OptionFlag | OptionFlag { - return custom>({ - async parse(input) { - if (!opts.options.includes(input)) throw new Error(`Expected --${this.name}=${input} to be one of: ${opts.options.join(', ')}`) - return input as unknown as T - }, - helpValue: `(${opts.options.join('|')})`, - ...opts, - })() +import {BooleanFlag} from './interfaces' +import {FlagDefinition, OptionFlagDefaults, FlagParser} from './interfaces/parser' +import {dirExists, fileExists} from './util' + +/** + * Create a custom flag. + * + * @example + * type Id = string + * type IdOpts = { startsWith: string; length: number }; + * + * export const myFlag = custom({ + * parse: async (input, opts) => { + * if (input.startsWith(opts.startsWith) && input.length === opts.length) { + * return input + * } + * + * throw new Error('Invalid id') + * }, + * }) + */ +export function custom>( + defaults: {parse: FlagParser, multiple: true} & Partial>, +): FlagDefinition +export function custom>( + defaults: {parse: FlagParser} & Partial>, +): FlagDefinition +export function custom>(defaults: Partial>): FlagDefinition +export function custom>(defaults: Partial>): FlagDefinition { + return (options: any = {}) => { + return { + parse: async (input, _ctx, _opts) => input, + ...defaults, + ...options, + input: [] as string[], + multiple: Boolean(options.multiple === undefined ? defaults.multiple : options.multiple), + type: 'option', + } + } } -export {_enum as enum} +export function boolean( + options: Partial> = {}, +): BooleanFlag { + return { + parse: async (b, _) => b, + ...options, + allowNo: Boolean(options.allowNo), + type: 'boolean', + } as BooleanFlag +} + +export const integer = custom({ + parse: async (input, _, opts) => { + if (!/^-?\d+$/.test(input)) + throw new Error(`Expected an integer but received: ${input}`) + const num = Number.parseInt(input, 10) + if (opts.min !== undefined && num < opts.min) + throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`) + if (opts.max !== undefined && num > opts.max) + throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`) + return num + }, +}) + +export const directory = custom({ + parse: async (input, _, opts) => { + if (opts.exists) return dirExists(input) -export const version = (opts: Partial> = {}) => { + return input + }, +}) + +export const file = custom({ + parse: async (input, _, opts) => { + if (opts.exists) return fileExists(input) + + return input + }, +}) + +/** + * Initializes a string as a URL. Throws an error + * if the string is not a valid URL. + */ +export const url = custom({ + parse: async input => { + try { + return new URL(input) + } catch { + throw new Error(`Expected a valid url but received: ${input}`) + } + }, +}) + +const stringFlag = custom({}) +export {stringFlag as string} + +export const version = (opts: Partial> = {}): BooleanFlag => { return boolean({ description: 'Show CLI version.', ...opts, - parse: async (_: any, cmd: Command) => { - cmd.log(cmd.config.userAgent) - cmd.exit(0) + parse: async (_: any, ctx) => { + ctx.log(ctx.config.userAgent) + ctx.exit(0) }, }) } -export const help = (opts: Partial> = {}) => { +export const help = (opts: Partial> = {}): BooleanFlag => { return boolean({ description: 'Show CLI help.', ...opts, - parse: async (_: any, cmd: Command) => { + parse: async (_, cmd) => { new Help(cmd.config).showHelp(cmd.id ? [cmd.id, ...cmd.argv] : cmd.argv) cmd.exit(0) }, diff --git a/src/help/command.ts b/src/help/command.ts index 787b49e9b..851ca9962 100644 --- a/src/help/command.ts +++ b/src/help/command.ts @@ -1,11 +1,11 @@ -import * as Chalk from 'chalk' +import * as chalk from 'chalk' import stripAnsi = require('strip-ansi') import {castArray, compact, sortBy} from '../util' import * as Interfaces from '../interfaces' -import {Example} from '../interfaces/command' import {HelpFormatter, HelpSection, HelpSectionRenderer} from './formatter' import {DocOpts} from './docopts' +import {Command} from '../command' // Don't use os.EOL because we need to ensure that a string // written on any platform, that may use \r\n or \n, will be @@ -14,19 +14,19 @@ const POSSIBLE_LINE_FEED = /\r\n|\n/ const { underline, -} = Chalk +} = chalk let { dim, -} = Chalk +} = chalk if (process.env.ConEmuANSI === 'ON') { // eslint-disable-next-line unicorn/consistent-destructuring - dim = Chalk.gray + dim = chalk.gray } export class CommandHelp extends HelpFormatter { constructor( - public command: Interfaces.Command, + public command: Command.Class | Command.Loadable | Command.Cached, public config: Interfaces.Config, public opts: Interfaces.HelpOptions) { super(config, opts) @@ -41,7 +41,7 @@ export class CommandHelp extends HelpFormatter { return v }), f => [!f.char, f.char, f.name]) - const args = (cmd.args || []).filter(a => !a.hidden) + const args = Object.values(cmd.args ?? {}).filter(a => !a.hidden) const output = compact(this.sections().map(({header, generate}) => { const body = generate({cmd, flags, args}, header) // Generate can return a list of sections @@ -54,9 +54,9 @@ export class CommandHelp extends HelpFormatter { return output } - protected groupFlags(flags: Interfaces.Command.Flag[]) { - const mainFlags: Interfaces.Command.Flag[] = [] - const flagGroups: { [index: string]: Interfaces.Command.Flag[] } = {} + protected groupFlags(flags: Array): {mainFlags: Array; flagGroups: {[name: string]: Array}} { + const mainFlags: Array = [] + const flagGroups: { [index: string]: Array } = {} for (const flag of flags) { const group = flag.helpGroup @@ -148,7 +148,7 @@ export class CommandHelp extends HelpFormatter { return compact([ this.command.id, - this.command.args.filter(a => !a.hidden).map(a => this.arg(a)).join(' '), + Object.values(this.command.args ?? {})?.filter(a => !a.hidden).map(a => this.arg(a)).join(' '), ]).join(' ') } @@ -177,7 +177,7 @@ export class CommandHelp extends HelpFormatter { return body } - protected examples(examples: Example[] | undefined | string): string | undefined { + protected examples(examples: Command.Example[] | undefined | string): string | undefined { if (!examples || examples.length === 0) return const formatIfCommand = (example: string): string => { @@ -228,7 +228,7 @@ export class CommandHelp extends HelpFormatter { return body } - protected args(args: Interfaces.Command['args']): [string, string | undefined][] | undefined { + protected args(args: Command.Arg.Any[]): [string, string | undefined][] | undefined { if (args.filter(a => a.description).length === 0) return return args.map(a => { @@ -240,13 +240,13 @@ export class CommandHelp extends HelpFormatter { }) } - protected arg(arg: Interfaces.Command['args'][0]): string { + protected arg(arg: Command.Arg.Any): string { const name = arg.name.toUpperCase() if (arg.required) return `${name}` return `[${name}]` } - protected flagHelpLabel(flag: Interfaces.Command.Flag, showOptions = false) { + protected flagHelpLabel(flag: Command.Flag.Any, showOptions = false): string { let label = flag.helpLabel if (!label) { @@ -277,7 +277,7 @@ export class CommandHelp extends HelpFormatter { return label } - protected flags(flags: Interfaces.Command.Flag[]): [string, string | undefined][] | undefined { + protected flags(flags: Array): [string, string | undefined][] | undefined { if (flags.length === 0) return return flags.map(flag => { @@ -298,7 +298,7 @@ export class CommandHelp extends HelpFormatter { }) } - protected flagsDescriptions(flags: Interfaces.Command.Flag[]): string | undefined { + protected flagsDescriptions(flags: Array): string | undefined { const flagsWithExtendedDescriptions = flags.filter(flag => flag.summary && flag.description) if (flagsWithExtendedDescriptions.length === 0) return diff --git a/src/help/docopts.ts b/src/help/docopts.ts index 9e25ef8ca..dc6aa8b56 100644 --- a/src/help/docopts.ts +++ b/src/help/docopts.ts @@ -1,8 +1,4 @@ -import {Interfaces} from '..' - -type Flag = Interfaces.Command.Flag -type Flags = Flag[] - +import {Command} from '../command' /** * DocOpts - See http://docopt.org/. * @@ -60,11 +56,11 @@ type Flags = Flag[] * */ export class DocOpts { - private flagMap: {[index: string]: Flag} + private flagMap: {[index: string]: Command.Flag.Any} - private flagList: Flags + private flagList: Command.Flag.Any[] - public constructor(private cmd: Interfaces.Command) { + public constructor(private cmd: Command.Class | Command.Loadable | Command.Cached) { // Create a new map with references to the flags that we can manipulate. this.flagMap = {} this.flagList = Object.entries(cmd.flags || {}) @@ -75,14 +71,14 @@ export class DocOpts { }) } - public static generate(cmd: Interfaces.Command): string { + public static generate(cmd: Command.Class | Command.Loadable | Command.Cached): string { return new DocOpts(cmd).toString() } public toString(): string { const opts = this.cmd.id === '.' || this.cmd.id === '' ? [] : ['<%= command.id %>'] if (this.cmd.args) { - const a = this.cmd.args?.map(arg => `[${arg.name.toUpperCase()}]`) || [] + const a = Object.values(this.cmd.args ?? {}).map(arg => `[${arg.name.toUpperCase()}]`) || [] opts.push(...a) } @@ -167,8 +163,7 @@ export class DocOpts { delete this.flagMap[flagName] } - // eslint-disable-next-line default-param-last - private generateElements(elementMap: {[index: string]: string} = {}, flagGroups: Flags): string[] { + private generateElements(elementMap: {[index: string]: string} = {}, flagGroups: Command.Flag.Any[] = []): string[] { const elementStrs = [] for (const flag of flagGroups) { let type = '' diff --git a/src/help/formatter.ts b/src/help/formatter.ts index 7400981f2..4b61a569f 100644 --- a/src/help/formatter.ts +++ b/src/help/formatter.ts @@ -1,6 +1,7 @@ import * as Chalk from 'chalk' import indent = require('indent-string') import stripAnsi = require('strip-ansi') +import {Command} from '../command' import * as Interfaces from '../interfaces' import {stdtermwidth} from '../screen' @@ -16,7 +17,7 @@ const { export type HelpSectionKeyValueTable = {name: string; description: string}[] export type HelpSection = {header: string; body: string | HelpSectionKeyValueTable | [string, string | undefined][] | undefined} | undefined; -export type HelpSectionRenderer = (data: {cmd: Interfaces.Command; flags: Interfaces.Command.Flag[]; args: Interfaces.Command.Arg[]}, header: string) => HelpSection | HelpSection[] | string | undefined; +export type HelpSectionRenderer = (data: {cmd: Command.Class | Command.Loadable | Command.Cached; flags: Command.Flag.Any[]; args: Command.Arg.Any[]}, header: string) => HelpSection | HelpSection[] | string | undefined; export class HelpFormatter { indentSpacing = 2 diff --git a/src/help/index.ts b/src/help/index.ts index 569113cc9..0e78cdf31 100644 --- a/src/help/index.ts +++ b/src/help/index.ts @@ -8,6 +8,7 @@ import {compact, sortBy, uniqBy} from '../util' import {formatCommandDeprecationWarning, getHelpFlagAdditions, standardizeIDFromArgv, toConfiguredId} from './util' import {HelpFormatter} from './formatter' import {toCached} from '../config/config' +import {Command} from '../command' export {CommandHelp} from './command' export {standardizeIDFromArgv, loadHelpClass, getHelpFlagAdditions, normalizeArgv} from './util' @@ -39,7 +40,7 @@ export abstract class HelpBase extends HelpFormatter { * @param command * @param topics */ - public abstract showCommandHelp(command: Interfaces.Command, topics: Interfaces.Topic[]): Promise; + public abstract showCommandHelp(command: Command.Class, topics: Interfaces.Topic[]): Promise; } export class Help extends HelpBase { @@ -59,7 +60,7 @@ export class Help extends HelpBase { }) } - protected get sortedCommands() { + protected get sortedCommands(): Command.Loadable[] { let commands = this.config.commands commands = commands.filter(c => this.opts.all || !c.hidden) @@ -69,7 +70,7 @@ export class Help extends HelpBase { return commands } - protected get sortedTopics() { + protected get sortedTopics(): Interfaces.Topic[] { let topics = this._topics topics = topics.filter(t => this.opts.all || !t.hidden) topics = sortBy(topics, t => t.name) @@ -82,7 +83,7 @@ export class Help extends HelpBase { super(config, opts) } - public async showHelp(argv: string[]) { + public async showHelp(argv: string[]): Promise { const originalArgv = argv.slice(1) argv = argv.filter(arg => !getHelpFlagAdditions(this.config).includes(arg)) @@ -130,7 +131,7 @@ export class Help extends HelpBase { error(`Command ${subject} not found.`) } - public async showCommandHelp(command: Interfaces.Command) { + public async showCommandHelp(command: Command.Class | Command.Loadable | Command.Cached): Promise { const name = command.id const depth = name.split(':').length @@ -163,7 +164,7 @@ export class Help extends HelpBase { if (subCommands.length > 0) { const aliases:string[] = [] - const uniqueSubCommands:Interfaces.Command.Loadable[] = subCommands.filter(p => { + const uniqueSubCommands: Command.Loadable[] = subCommands.filter(p => { aliases.push(...p.aliases) return !aliases.includes(p.id) }) @@ -172,7 +173,7 @@ export class Help extends HelpBase { } } - protected async showRootHelp() { + protected async showRootHelp(): Promise { let rootTopics = this.sortedTopics let rootCommands = this.sortedCommands @@ -205,7 +206,7 @@ export class Help extends HelpBase { } } - protected async showTopicHelp(topic: Interfaces.Topic) { + protected async showTopicHelp(topic: Interfaces.Topic): Promise { const name = topic.name const depth = name.split(':').length @@ -233,7 +234,7 @@ export class Help extends HelpBase { return help.root() } - protected formatCommand(command: Interfaces.Command): string { + protected formatCommand(command: Command.Class | Command.Loadable | Command.Cached): string { if (this.config.topicSeparator !== ':') { command.id = command.id.replace(/:/g, this.config.topicSeparator) command.aliases = command.aliases && command.aliases.map(a => a.replace(/:/g, this.config.topicSeparator)) @@ -243,11 +244,11 @@ export class Help extends HelpBase { return help.generate() } - protected getCommandHelpClass(command: Interfaces.Command) { + protected getCommandHelpClass(command: Command.Class | Command.Loadable | Command.Cached): CommandHelp { return new this.CommandHelpClass(command, this.config, this.opts) } - protected formatCommands(commands: Interfaces.Command[]): string { + protected formatCommands(commands: Array): string { if (commands.length === 0) return '' const body = this.renderList(commands.map(c => { @@ -265,13 +266,13 @@ export class Help extends HelpBase { return this.section('COMMANDS', body) } - protected summary(c: Interfaces.Command): string | undefined { + protected summary(c: Command.Class | Command.Loadable | Command.Cached): string | undefined { if (c.summary) return this.render(c.summary.split('\n')[0]) return c.description && this.render(c.description).split('\n')[0] } - protected description(c: Interfaces.Command): string { + protected description(c: Command.Class | Command.Loadable | Command.Cached): string { const description = this.render(c.description || '') if (c.summary) { return description @@ -311,16 +312,11 @@ export class Help extends HelpBase { return this.section('TOPICS', body) } - /** - * @deprecated used for readme generation - * @param {object} command The command to generate readme help for - * @return {string} the readme help string for the given command - */ - protected command(command: Interfaces.Command) { + protected command(command: Command.Class): string { return this.formatCommand(command) } - protected log(...args: string[]) { + protected log(...args: string[]): void { console.log(...args) } } diff --git a/src/help/util.ts b/src/help/util.ts index e84357d61..9988e9b1c 100644 --- a/src/help/util.ts +++ b/src/help/util.ts @@ -1,9 +1,8 @@ import * as ejs from 'ejs' -import {Config as IConfig, HelpOptions} from '../interfaces' +import {Config as IConfig, HelpOptions, Deprecation} from '../interfaces' import {Help, HelpBase} from '.' import ModuleLoader from '../module-loader' import {collectUsableIds} from '../config/util' -import {Deprecation} from '../interfaces/parser' interface HelpBaseDerived { new(config: IConfig, opts?: Partial): HelpBase; @@ -29,7 +28,7 @@ export async function loadHelpClass(config: IConfig): Promise { return Help } -export function template(context: any): (t: string) => string { +export function template(context: ejs.Data): (t: string) => string { function render(t: string): string { return ejs.render(t, context) } @@ -53,7 +52,7 @@ function collateSpacedCmdIDFromArgs(argv: string[], config: IConfig): string[] { const id = finalizeId() if (!id) return false const cmd = config.findCommand(id) - return Boolean(cmd && (cmd.strict === false || cmd.args?.length > 0)) + return Boolean(cmd && (cmd.strict === false || Object.keys(cmd.args ?? {}).length > 0)) } for (const arg of argv) { diff --git a/src/index.ts b/src/index.ts index 812189989..579cd86b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,25 @@ -import * as path from 'path' import * as semver from 'semver' -import Command from './command' -import {run} from './main' +import {Command} from './command' +import {run, execute} from './main' import {Config, Plugin, tsPath, toCached} from './config' import * as Interfaces from './interfaces' import * as Errors from './errors' import * as Flags from './flags' +import * as Args from './args' import {CommandHelp, HelpBase, Help, loadHelpClass} from './help' import {toStandardizedId, toConfiguredId} from './help/util' import * as Parser from './parser' import {Hook} from './interfaces/hooks' import {settings, Settings} from './settings' import {HelpSection, HelpSectionRenderer, HelpSectionKeyValueTable} from './help/formatter' -import * as cliUx from './cli-ux' +import * as ux from './cli-ux' +import {requireJson} from './util' -const flush = cliUx.ux.flush +const flush = ux.flush export { + Args, Command, CommandHelp, Config, @@ -41,7 +43,8 @@ export { settings, Settings, flush, - cliUx as CliUx, + ux, + execute, } function checkCWD() { @@ -55,8 +58,7 @@ function checkCWD() { } function checkNodeVersion() { - const root = path.join(__dirname, '..') - const pjson = require(path.join(root, 'package.json')) + const pjson = requireJson(__dirname, '..', 'package.json') if (!semver.satisfies(process.versions.node, pjson.engines.node)) { process.stderr.write(`WARNING\nWARNING Node version must be ${pjson.engines.node} to use this CLI\nWARNING Current node version: ${process.versions.node}\nWARNING\n`) } diff --git a/src/interfaces/args.ts b/src/interfaces/args.ts new file mode 100644 index 000000000..43db7b00f --- /dev/null +++ b/src/interfaces/args.ts @@ -0,0 +1,23 @@ +import {ArgInput} from './parser' + +/** + * Infer the args that are returned by Command.parse. This is useful for when you want to assign the args as a class property. + * + * @example + * export type StatusArgs = Interfaces.InferredArgs + * + * export default class Status { + * static args = { + * force: Args.boolean({char: 'f', description: 'a flag'}), + * } + * + * public args!: StatusArgs + * + * public async run(): Promise { + * const result = await this.parse(Status) + * this.args = result.args + * return result.args + * } + * } + */ +export type InferredArgs = T extends ArgInput ? F : unknown diff --git a/src/interfaces/command.ts b/src/interfaces/command.ts deleted file mode 100644 index 52446b936..000000000 --- a/src/interfaces/command.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {Config, LoadOptions} from './config' -import {ArgInput, BooleanFlagProps, Deprecation, FlagInput, OptionFlagProps} from './parser' -import {Plugin as IPlugin} from './plugin' - -export type Example = string | { - description: string; - command: string; -} - -export interface CommandProps { - /** A command ID, used mostly in error or verbose reporting. */ - id: string; - - /** Hide the command from help */ - hidden: boolean; - - /** Mark the command as a given state (e.g. beta or deprecated) in help */ - state?: 'beta' | 'deprecated' | string; - - /** - * Provide details to the deprecation warning if state === 'deprecated' - */ - deprecationOptions?: Deprecation; - - /** - * Emit a deprecation warning when a command alias is used. - */ - deprecateAliases?: boolean - - /** An array of aliases for this command. */ - aliases: string[]; - - /** - * The tweet-sized description for your class, used in a parent-commands - * sub-command listing and as the header for the command help. - */ - summary?: string; - - /** - * A full description of how to use the command. - * - * If no summary, the first line of the description will be used as the summary. - */ - description?: string; - - /** - * An override string (or strings) for the default usage documentation. - */ - usage?: string | string[]; - - /** - * An array of examples to show at the end of the command's help. - * - * IF only a string is provide, it will try to look for a line that starts - * with the cmd.bin as the example command and the rest as the description. - * If found, the command will be formatted appropriately. - * - * ``` - * EXAMPLES: - * A description of a particular use case. - * - * $ <%= config.bin => command flags - * ``` - */ - examples?: Example[]; -} - -export interface Command extends CommandProps { - type?: string; - pluginName?: string; - pluginType?: string; - pluginAlias?: string; - flags: {[name: string]: Command.Flag}; - args: Command.Arg[]; - strict: boolean; - hasDynamicHelp?: boolean; -} - -export namespace Command { - export interface Arg { - name: string; - description?: string; - required?: boolean; - hidden?: boolean; - default?: string; - options?: string[]; - } - - export type Flag = Flag.Boolean | Flag.Option - - export namespace Flag { - // We can't use "export OptionFlagProps as Option" in the namespace export. - // eslint-disable-next-line @typescript-eslint/no-empty-interface - export interface Boolean extends BooleanFlagProps {} - export interface Option extends OptionFlagProps { - default?: string; - defaultHelp?: () => Promise - } - } - - export interface Base extends CommandProps { - _base: string; - } - - export interface Class extends Base { - plugin?: IPlugin; - flags?: FlagInput; - args?: ArgInput; - strict: boolean; - hasDynamicHelp?: boolean; - - new(argv: string[], config: Config): Instance; - run(argv?: string[], config?: LoadOptions): PromiseLike; - } - - export interface Instance { - _run(argv: string[]): Promise; - } - - export interface Loadable extends Command { - load(): Promise; - } - - /** - * @deprecated use Command.Loadable instead. - */ - export interface Plugin extends Command { - load(): Promise; - } -} diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index 1cd6f3a7f..eb211311b 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -1,8 +1,8 @@ import {PJSON} from './pjson' import {Hooks, Hook} from './hooks' -import {Command} from './command' import {Plugin, Options} from './plugin' import {Topic} from './topic' +import {Command} from '../command' export type LoadOptions = Options | string | Config | undefined export type PlatformTypes = 'darwin' | 'linux' | 'win32' | 'aix' | 'freebsd' | 'openbsd' | 'sunos' | 'wsl' @@ -94,7 +94,6 @@ export interface Config { readonly topics: Topic[]; readonly commandIDs: string[]; - runCommand(id: string, argv?: string[]): Promise; runCommand(id: string, argv?: string[], cachedCommand?: Command.Loadable): Promise; runHook(event: T, opts: Hooks[T]['options'], timeout?: number): Promise>; getAllCommandIDs(): string[] diff --git a/src/interfaces/flags.ts b/src/interfaces/flags.ts index 5608e64d1..e8005e858 100644 --- a/src/interfaces/flags.ts +++ b/src/interfaces/flags.ts @@ -4,12 +4,12 @@ import {FlagInput} from './parser' * Infer the flags that are returned by Command.parse. This is useful for when you want to assign the flags as a class property. * * @example - * export type StatusFlags = Interfaces.InferredFlags + * export type StatusFlags = Interfaces.InferredFlags * * export abstract class BaseCommand extends Command { * static enableJsonFlag = true * - * static globalFlags = { + * static baseFlags = { * config: Flags.string({ * description: 'specify config file', * }), diff --git a/src/interfaces/hooks.ts b/src/interfaces/hooks.ts index 59e1ea653..516f5a12f 100644 --- a/src/interfaces/hooks.ts +++ b/src/interfaces/hooks.ts @@ -1,4 +1,4 @@ -import {Command} from './command' +import {Command} from '../command' import {Config} from './config' import {Plugin} from './plugin' diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 0f0e8b686..d46108789 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,22 +1,14 @@ export {AlphabetLowercase, AlphabetUppercase} from './alphabet' export {Config, ArchTypes, PlatformTypes, LoadOptions} from './config' -export {Command, Example} from './command' export {OclifError, PrettyPrintableError, CommandError} from './errors' export {HelpOptions} from './help' export {Hook, Hooks} from './hooks' export {Manifest} from './manifest' export {S3Manifest} from './s3-manifest' -export { - ParserArg, Arg, ParseFn, ParserOutput, ParserInput, ArgToken, - OptionalArg, FlagOutput, OutputArgs, OutputFlags, FlagUsageOptions, - CLIParseErrorOptions, ArgInput, RequiredArg, Metadata, ParsingToken, - FlagToken, List, ListItem, BooleanFlag, Flag, FlagBase, OptionFlag, - Input, EnumFlagOptions, DefaultContext, Default, Definition, - CompletableOptionFlag, Completion, CompletionContext, FlagInput, - CompletableFlag, OptionFlagProps, -} from './parser' +export {BooleanFlag, Flag, OptionFlag, Deprecation} from './parser' export {PJSON} from './pjson' export {Plugin, PluginOptions, Options} from './plugin' export {Topic} from './topic' export {TSConfig} from './ts-config' export {InferredFlags} from './flags' +export {InferredArgs} from './args' diff --git a/src/interfaces/manifest.ts b/src/interfaces/manifest.ts index f857a23b9..7790f0488 100644 --- a/src/interfaces/manifest.ts +++ b/src/interfaces/manifest.ts @@ -1,6 +1,6 @@ -import {Command} from './command' +import {Command} from '../command' export interface Manifest { version: string; - commands: {[id: string]: Command}; + commands: {[id: string]: Command.Cached}; } diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts index 797de0f91..04a857b50 100644 --- a/src/interfaces/parser.ts +++ b/src/interfaces/parser.ts @@ -1,71 +1,41 @@ +import {Command} from '../command' import {AlphabetLowercase, AlphabetUppercase} from './alphabet' import {Config} from './config' -export type ParseFn = (input: string) => Promise +export type FlagOutput = { [name: string]: any } +export type ArgOutput = { [name: string]: any } -export interface Arg { - name: string; - description?: string; - required?: boolean; - hidden?: boolean; - parse?: ParseFn; - default?: T | (() => T); - options?: string[]; - ignoreStdin?: boolean; -} - -export interface ArgBase { - name?: string; - description?: string; - hidden?: boolean; - parse: ParseFn; - default?: T | (() => Promise); - input?: string; - options?: string[]; - ignoreStdin?: boolean; -} - -export type RequiredArg = ArgBase & { - required: true; - value: T; -} - -export type OptionalArg = ArgBase & { - required: false; - value?: T; -} - -export type ParserArg = RequiredArg | OptionalArg - -export interface FlagOutput { [name: string]: any } -export type ArgInput = Arg[] - -export interface CLIParseErrorOptions { +export type CLIParseErrorOptions = { parse: { input?: ParserInput; output?: ParserOutput; }; } -export type OutputArgs = { [name: string]: any } +export type OutputArgs = { [P in keyof T]: any } export type OutputFlags = { [P in keyof T]: any } -export type ParserOutput = any, GFlags extends OutputFlags = any, TArgs extends OutputArgs = any> = { - // Add in global flags so that they show up in the types - // This is necessary because there's no easy way to optionally return - // the individual flags based on wether they're enabled or not - flags: TFlags & GFlags & { json: boolean | undefined }; +export type ParserOutput< + TFlags extends OutputFlags = any, + BFlags extends OutputFlags = any, + TArgs extends OutputFlags = any +> = { + // Add in the --json flag so that it shows up in the types. + // This is necessary because there's no way to optionally add the json flag based + // on wether enableJsonFlag is set in the command. + flags: TFlags & BFlags & { json: boolean | undefined }; args: TArgs; - argv: string[]; + argv: unknown[]; raw: ParsingToken[]; metadata: Metadata; + nonExistentFlags: string[]; } -export type ArgToken = { type: 'arg'; input: string } +export type ArgToken = { type: 'arg'; arg: string; input: string } export type FlagToken = { type: 'flag'; flag: string; input: string } export type ParsingToken = ArgToken | FlagToken -export interface FlagUsageOptions { displayRequired?: boolean } +export type FlagUsageOptions = { displayRequired?: boolean } export type Metadata = { flags: { [key: string]: MetadataFlag }; @@ -78,13 +48,18 @@ type MetadataFlag = { export type ListItem = [string, string | undefined] export type List = ListItem[] -export type DefaultContext = { - options: P & OptionFlag; +export type CustomOptions = Record + +export type DefaultContext = { + options: T; flags: Record; } -export type Default> = T | ((context: DefaultContext) => Promise) -export type DefaultHelp> = T | ((context: DefaultContext) => Promise) +export type FlagDefault = T | ((context: DefaultContext & P>) => Promise) +export type FlagDefaultHelp = T | ((context: DefaultContext & P>) => Promise) + +export type ArgDefault = T | ((context: DefaultContext>) => Promise) +export type ArgDefaultHelp = T | ((context: DefaultContext>) => Promise) export type FlagRelationship = string | {name: string; when: (flags: Record) => Promise}; export type Relationship = { @@ -161,6 +136,33 @@ export type FlagProps = { * Emit deprecation warning when a flag alias is provided */ deprecateAliases?: boolean + /** + * Delimiter to separate the values for a multiple value flag. + * Only respected if multiple is set to true. Default behavior is to + * separate on spaces. + */ + delimiter?: ',', +} + +export type ArgProps = { + name: string; + /** + * A description of flag usage. If summary is provided, the description + * is assumed to be a longer description and will be shown in a separate + * section within help. + */ + description?: string; + /** + * If true, the flag will not be shown in the help. + */ + hidden?: boolean; + /** + * If true, the flag will be required. + */ + required?: boolean; + + options?: string[]; + ignoreStdin?: boolean; } export type BooleanFlagProps = FlagProps & { @@ -175,72 +177,76 @@ export type OptionFlagProps = FlagProps & { multiple?: boolean; } -export type FlagParser = (input: I, context: any, opts: P & OptionFlag) => Promise +export type FlagParser = (input: I, context: Command, opts: P & OptionFlag) => Promise + +export type ArgParser = (input: string, context: Command, opts: P & Arg) => Promise + +export type Arg = ArgProps & { + options?: T[]; + defaultHelp?: ArgDefaultHelp; + input: string[]; + default?: ArgDefault; + parse: ArgParser; +} -export type FlagBase = FlagProps & { - parse: FlagParser; +export type ArgDefinition = { + (options: P & ({ required: true } | { default: ArgDefault }) & Partial>): Arg; + (options?: P & Partial>): Arg; } -export type BooleanFlag = FlagBase & BooleanFlagProps & { +export type BooleanFlag = FlagProps & BooleanFlagProps & { /** * specifying a default of false is the same as not specifying a default */ - default?: Default; + default?: FlagDefault; + parse: (input: boolean, context: Command, opts: FlagProps & BooleanFlagProps) => Promise } -export type CustomOptionFlag = FlagBase & OptionFlagProps & { - defaultHelp?: DefaultHelp; +export type OptionFlagDefaults = FlagProps & OptionFlagProps & { + parse: FlagParser> + defaultHelp?: FlagDefaultHelp; input: string[]; - default?: M extends true ? Default : Default; + default?: M extends true ? FlagDefault : FlagDefault; } -export type OptionFlag = FlagBase & OptionFlagProps & { - defaultHelp?: DefaultHelp; +export type OptionFlag = FlagProps & OptionFlagProps & { + parse: FlagParser> + defaultHelp?: FlagDefaultHelp; input: string[]; } & ({ - default?: Default; + default?: FlagDefault; multiple: false; } | { - default?: Default; + default?: FlagDefault; multiple: true; }) -export type Definition> = { +export type FlagDefinition = { ( - options: P & { multiple: true } & ({ required: true } | { default: Default }) & Partial> + options: P & { multiple: true } & ({ required: true } | { default: FlagDefault }) & Partial> ): OptionFlag; - (options: P & { multiple: true } & Partial>): OptionFlag; - (options: P & ({ required: true } | { default: Default }) & Partial>): OptionFlag; - (options?: P & Partial>): OptionFlag; + (options: P & { multiple: true } & Partial>): OptionFlag; + (options: P & ({ required: true } | { default: FlagDefault }) & Partial>): OptionFlag; + (options?: P & Partial>): OptionFlag; } -export type EnumFlagOptions = Partial> & { - options: T[]; -} & ({ - default?: Default; - multiple?: false; -} | { - default?: Default; - multiple: true; -}) - export type Flag = BooleanFlag | OptionFlag -export type Input = { +export type Input = { flags?: FlagInput; - globalFlags?: FlagInput; - args?: ArgInput; + baseFlags?: FlagInput; + args?: ArgInput; strict?: boolean; - context?: any; + context?: Command; '--'?: boolean; } -export interface ParserInput { +export type ParserInput = { argv: string[]; flags: FlagInput; - args: ParserArg[]; + args: ArgInput; strict: boolean; - context: any; + context: Command | undefined; '--'?: boolean; } @@ -265,3 +271,5 @@ export type CompletableOptionFlag = OptionFlag & { export type CompletableFlag = BooleanFlag | CompletableOptionFlag export type FlagInput = { [P in keyof T]: CompletableFlag } + +export type ArgInput = { [P in keyof T]: Arg } diff --git a/src/interfaces/plugin.ts b/src/interfaces/plugin.ts index 836eb63f6..fa2dcd833 100644 --- a/src/interfaces/plugin.ts +++ b/src/interfaces/plugin.ts @@ -1,4 +1,4 @@ -import {Command} from './command' +import {Command} from '../command' import {PJSON} from './pjson' import {Topic} from './topic' diff --git a/src/main.ts b/src/main.ts index c3e058509..74dd80d30 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,11 @@ import * as Interfaces from './interfaces' import {URL} from 'url' import {Config} from './config' import {getHelpFlagAdditions, loadHelpClass, normalizeArgv} from './help' +import {settings} from './settings' +import {Errors, flush} from '.' +import {join, dirname} from 'path' const log = (message = '', ...args: any[]) => { - // tslint:disable-next-line strict-type-predicates message = typeof message === 'string' ? message : inspect(message) process.stdout.write(format(message, ...args) + '\n') } @@ -31,15 +33,14 @@ export const versionAddition = (argv: string[], config?: Interfaces.Config): boo return false } -// eslint-disable-next-line default-param-last -export async function run(argv = process.argv.slice(2), options?: Interfaces.LoadOptions) { +export async function run(argv?: string[], options?: Interfaces.LoadOptions): Promise { + argv = argv ?? process.argv.slice(2) // Handle the case when a file URL string or URL is passed in such as 'import.meta.url'; covert to file path. if (options && ((typeof options === 'string' && options.startsWith('file://')) || options instanceof URL)) { options = fileURLToPath(options) } - // return Main.run(argv, options) - const config = await Config.load(options || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) as Config + const config = await Config.load(options ?? require.main?.filename ?? __dirname) let [id, ...argvSlice] = normalizeArgv(config, argv) // run init hook @@ -76,3 +77,76 @@ export async function run(argv = process.argv.slice(2), options?: Interfaces.Loa if (config.pjson.oclif.default === '.' && id === '.' && argv[0] === '.') argvSlice = ['.', ...argvSlice] await config.runCommand(id, argvSlice, cmd) } + +function getTsConfigPath(dir: string, type: 'esm' | 'cjs'): string { + return type === 'cjs' ? join(dir, '..', 'tsconfig.json') : join(dirname(fileURLToPath(dir)), '..', 'tsconfig.json') +} + +/** + * Load and run oclif CLI + * + * @param options - options to load the CLI + * @returns Promise + * + * @example For ESM dev.js + * ``` + * #!/usr/bin/env ts-node + * // eslint-disable-next-line node/shebang + * (async () => { + * const oclif = await import('@oclif/core') + * await oclif.execute({type: 'esm', development: true, dir: import.meta.url}) + * })() + * ``` + * + * @example For ESM run.js + * ``` + * #!/usr/bin/env node + * // eslint-disable-next-line node/shebang + * (async () => { + * const oclif = await import('@oclif/core') + * await oclif.execute({type: 'esm', dir: import.meta.url}) + * })() + * ``` + * + * @example For CJS dev.js + * ``` + * #!/usr/bin/env node + * // eslint-disable-next-line node/shebang + * (async () => { + * const oclif = await import('@oclif/core') + * await oclif.execute({type: 'cjs', development: true, dir: __dirname}) + * })() + * ``` + * + * @example For CJS run.js + * ``` + * #!/usr/bin/env node + * // eslint-disable-next-line node/shebang + * (async () => { + * const oclif = await import('@oclif/core') + * await oclif.execute({type: 'cjs', dir: import.meta.url}) + * })() + * ``` + */ +export async function execute( + options: { + type: 'cjs' | 'esm'; + dir: string; + args?: string[]; + loadOptions?: Interfaces.LoadOptions; + development?: boolean; + }, +): Promise { + if (options.development) { + // In dev mode -> use ts-node and dev plugins + process.env.NODE_ENV = 'development' + require('ts-node').register({ + project: getTsConfigPath(options.dir, options.type), + }) + settings.debug = true + } + + await run(options.args ?? process.argv.slice(2), options.loadOptions ?? options.dir) + .then(async () => flush()) + .catch(Errors.handle) +} diff --git a/src/parser/args.ts b/src/parser/args.ts deleted file mode 100644 index 96d85a25a..000000000 --- a/src/parser/args.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {ParserArg, Arg, ParseFn} from '../interfaces' - -export function newArg(arg: Arg & { Parse: ParseFn }): ParserArg -export function newArg(arg: Arg): ParserArg -export function newArg(arg: Arg): any { - return { - parse: (i: string) => i, - ...arg, - required: Boolean(arg.required), - } -} diff --git a/src/parser/deps.ts b/src/parser/deps.ts deleted file mode 100644 index 862da4cc1..000000000 --- a/src/parser/deps.ts +++ /dev/null @@ -1,15 +0,0 @@ -export default () => { - const cache: {[k: string]: any} = {} - return { - add(this: T, name: K, fn: () => U): T & {[P in K]: U} { - Object.defineProperty(this, name, { - enumerable: true, - get: () => { - cache[name] = cache[name] || fn() - return cache[name] - }, - }) - return this as any - }, - } -} diff --git a/src/parser/errors.ts b/src/parser/errors.ts index b0c952f39..976001c79 100644 --- a/src/parser/errors.ts +++ b/src/parser/errors.ts @@ -1,22 +1,14 @@ import {CLIError} from '../errors' -import Deps from './deps' -import * as Help from './help' -import * as List from './list' +import {flagUsages} from './help' +import {renderList} from '../cli-ux/list' import * as chalk from 'chalk' -import {ParserArg, CLIParseErrorOptions, OptionFlag, Flag} from '../interfaces' +import {OptionFlag, Flag} from '../interfaces' import {uniq} from '../config/util' +import {Arg, ArgInput, CLIParseErrorOptions} from '../interfaces/parser' export {CLIError} from '../errors' -// eslint-disable-next-line new-cap -const m = Deps() -// eslint-disable-next-line node/no-missing-require -.add('help', () => require('./help') as typeof Help) -// eslint-disable-next-line node/no-missing-require -.add('list', () => require('./list') as typeof List) -.add('chalk', () => require('chalk') as typeof chalk) - export type Validation = { name: string; status: 'success' | 'failed'; @@ -35,13 +27,13 @@ export class CLIParseError extends CLIError { } export class InvalidArgsSpecError extends CLIParseError { - public args: ParserArg[] + public args: ArgInput - constructor({args, parse}: CLIParseErrorOptions & { args: ParserArg[] }) { + constructor({args, parse}: CLIParseErrorOptions & { args: ArgInput }) { let message = 'Invalid argument spec' - const namedArgs = args.filter(a => a.name) + const namedArgs = Object.values(args).filter(a => a.name) if (namedArgs.length > 0) { - const list = m.list.renderList(namedArgs.map(a => [`${a.name} (${a.required ? 'required' : 'optional'})`, a.description] as [string, string])) + const list = renderList(namedArgs.map(a => [`${a.name} (${a.required ? 'required' : 'optional'})`, a.description] as [string, string])) message += `:\n${list}` } @@ -51,13 +43,13 @@ export class InvalidArgsSpecError extends CLIParseError { } export class RequiredArgsError extends CLIParseError { - public args: ParserArg[] + public args: Arg[] - constructor({args, parse}: CLIParseErrorOptions & { args: ParserArg[] }) { + constructor({args, parse}: CLIParseErrorOptions & { args: Arg[] }) { let message = `Missing ${args.length} required arg${args.length === 1 ? '' : 's'}` const namedArgs = args.filter(a => a.name) if (namedArgs.length > 0) { - const list = m.list.renderList(namedArgs.map(a => [a.name, a.description] as [string, string])) + const list = renderList(namedArgs.map(a => [a.name, a.description] as [string, string])) message += `:\n${list}` } @@ -70,7 +62,7 @@ export class RequiredFlagError extends CLIParseError { public flag: Flag constructor({flag, parse}: CLIParseErrorOptions & { flag: Flag }) { - const usage = m.list.renderList(m.help.flagUsages([flag], {displayRequired: false})) + const usage = renderList(flagUsages([flag], {displayRequired: false})) const message = `Missing required flag:\n${usage}` super({parse, message}) this.flag = flag @@ -78,15 +70,25 @@ export class RequiredFlagError extends CLIParseError { } export class UnexpectedArgsError extends CLIParseError { - public args: string[] + public args: unknown[] - constructor({parse, args}: CLIParseErrorOptions & { args: string[] }) { + constructor({parse, args}: CLIParseErrorOptions & { args: unknown[] }) { const message = `Unexpected argument${args.length === 1 ? '' : 's'}: ${args.join(', ')}` super({parse, message}) this.args = args } } +export class NonExistentFlagsError extends CLIParseError { + public flags: string[] + + constructor({parse, flags}: CLIParseErrorOptions & { flags: string[] }) { + const message = `Nonexistent flag${flags.length === 1 ? '' : 's'}: ${flags.join(', ')}` + super({parse, message}) + this.flags = flags + } +} + export class FlagInvalidOptionError extends CLIParseError { constructor(flag: OptionFlag, input: string) { const message = `Expected --${flag.name}=${input} to be one of: ${flag.options!.join(', ')}` @@ -95,7 +97,7 @@ export class FlagInvalidOptionError extends CLIParseError { } export class ArgInvalidOptionError extends CLIParseError { - constructor(arg: ParserArg, input: string) { + constructor(arg: Arg, input: string) { const message = `Expected ${input} to be one of: ${arg.options!.join(', ')}` super({parse: {}, message}) } @@ -106,7 +108,7 @@ export class FailedFlagValidationError extends CLIParseError { const reasons = failed.map(r => r.reason) const deduped = uniq(reasons) const errString = deduped.length === 1 ? 'error' : 'errors' - const message = `The following ${errString} occurred:\n ${m.chalk.dim(deduped.join('\n '))}` + const message = `The following ${errString} occurred:\n ${chalk.dim(deduped.join('\n '))}` super({parse, message}) } } diff --git a/src/parser/flags.ts b/src/parser/flags.ts deleted file mode 100644 index 067106145..000000000 --- a/src/parser/flags.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {URL} from 'url' - -import {Definition, OptionFlag, BooleanFlag} from '../interfaces' -import * as fs from 'fs' -import {FlagParser, CustomOptionFlag} from '../interfaces/parser' - -/** - * Create a custom flag. - * - * @example - * type Id = string - * type IdOpts = { startsWith: string; length: number }; - * - * export const myFlag = custom({ - * parse: async (input, opts) => { - * if (input.startsWith(opts.startsWith) && input.length === opts.length) { - * return input - * } - * - * throw new Error('Invalid id') - * }, - * }) - */ -export function custom>( - defaults: {parse: FlagParser, multiple: true} & Partial>, -): Definition -export function custom>( - defaults: {parse: FlagParser} & Partial>, -): Definition -export function custom>(defaults: Partial>): Definition -export function custom>(defaults: Partial>): Definition { - return (options: any = {}) => { - return { - parse: async (i: string, _context: any, _opts: P) => i, - ...defaults, - ...options, - input: [] as string[], - multiple: Boolean(options.multiple === undefined ? defaults.multiple : options.multiple), - type: 'option', - } - } -} - -/** - * @deprecated Use Flags.custom instead. - */ -export function build( - defaults: {parse: OptionFlag['parse']} & Partial>, -): Definition -export function build(defaults: Partial>): Definition -export function build(defaults: Partial>): Definition { - return (options: any = {}) => { - return { - parse: async (i: string, _context: any) => i, - ...defaults, - ...options, - input: [] as string[], - multiple: Boolean(options.multiple === undefined ? defaults.multiple : options.multiple), - type: 'option', - } - } -} - -export function boolean( - options: Partial> = {}, -): BooleanFlag { - return { - parse: async (b, _) => b, - ...options, - allowNo: Boolean(options.allowNo), - type: 'boolean', - } as BooleanFlag -} - -export const integer = custom({ - parse: async (input, _, opts) => { - if (!/^-?\d+$/.test(input)) - throw new Error(`Expected an integer but received: ${input}`) - const num = Number.parseInt(input, 10) - if (opts.min !== undefined && num < opts.min) - throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`) - if (opts.max !== undefined && num > opts.max) - throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`) - return num - }, -}) - -export const directory = custom({ - parse: async (input, _, opts) => { - if (opts.exists) return dirExists(input) - - return input - }, -}) - -export const file = custom({ - parse: async (input, _, opts) => { - if (opts.exists) return fileExists(input) - - return input - }, -}) - -/** - * Initializes a string as a URL. Throws an error - * if the string is not a valid URL. - */ -export const url = custom({ - parse: async input => { - try { - return new URL(input) - } catch { - throw new Error(`Expected a valid url but received: ${input}`) - } - }, -}) - -export function option( - options: {parse: OptionFlag['parse']} & Partial>, -) { - return custom(options)() -} - -const stringFlag = custom({}) -export {stringFlag as string} - -export const defaultFlags = { - color: boolean({allowNo: true}), -} - -const dirExists = async (input: string): Promise => { - if (!fs.existsSync(input)) { - throw new Error(`No directory found at ${input}`) - } - - if (!(await fs.promises.stat(input)).isDirectory()) { - throw new Error(`${input} exists but is not a directory`) - } - - return input -} - -const fileExists = async (input: string): Promise => { - if (!fs.existsSync(input)) { - throw new Error(`No file found at ${input}`) - } - - if (!(await fs.promises.stat(input)).isFile()) { - throw new Error(`${input} exists but is not a file`) - } - - return input -} diff --git a/src/parser/help.ts b/src/parser/help.ts index f2e9a4a9b..9480dc475 100644 --- a/src/parser/help.ts +++ b/src/parser/help.ts @@ -1,14 +1,6 @@ -import * as Chalk from 'chalk' - -import Deps from './deps' -import * as Util from './util' -import {FlagUsageOptions, Flag} from '../interfaces' - -// eslint-disable-next-line new-cap -const m = Deps() -.add('chalk', () => require('chalk') as typeof Chalk) -// eslint-disable-next-line node/no-missing-require -.add('util', () => require('./util') as typeof Util) +import * as chalk from 'chalk' +import {Flag, FlagUsageOptions} from '../interfaces/parser' +import {sortBy} from '../util' export function flagUsage(flag: Flag, options: FlagUsageOptions = {}): [string, string | undefined] { const label = [] @@ -24,14 +16,13 @@ export function flagUsage(flag: Flag, options: FlagUsageOptions = {}): [str let description: string | undefined = flag.summary || flag.description || '' if (options.displayRequired && flag.required) description = `(required) ${description}` - description = description ? m.chalk.dim(description) : undefined + description = description ? chalk.dim(description) : undefined return [` ${label.join(',').trim()}${usage}`, description] as [string, string | undefined] } export function flagUsages(flags: Flag[], options: FlagUsageOptions = {}): [string, string | undefined][] { if (flags.length === 0) return [] - const {sortBy} = m.util return sortBy(flags, f => [f.char ? -1 : 1, f.char, f.name]) .map(f => flagUsage(f, options)) } diff --git a/src/parser/index.ts b/src/parser/index.ts index ec4c95ce2..422af2846 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,34 +1,24 @@ -import * as args from './args' -import Deps from './deps' -import * as flags from './flags' import {Parser} from './parse' -import {FlagInput, Input, ParserOutput, OutputFlags, FlagOutput} from '../interfaces' -import * as Validate from './validate' -export {args} -export {flags} +import {validate} from './validate' +import {ArgInput, FlagInput, Input, OutputArgs, OutputFlags, ParserOutput} from '../interfaces/parser' export {flagUsages} from './help' -// eslint-disable-next-line new-cap -const m = Deps() -// eslint-disable-next-line node/no-missing-require -.add('validate', () => require('./validate').validate as typeof Validate.validate) - -export async function parse, GFlags extends FlagOutput, TArgs extends { [name: string]: string }>(argv: string[], options: Input): Promise> { +export async function parse< + TFlags extends OutputFlags, + BFlags extends OutputFlags, + TArgs extends OutputArgs +>(argv: string[], options: Input): Promise> { const input = { argv, context: options.context, - args: (options.args || []).map((a: any) => args.newArg(a as any)), '--': options['--'], - flags: { - color: flags.defaultFlags.color, - ...options.flags, - } as FlagInput, + flags: (options.flags ?? {}) as FlagInput, + args: (options.args ?? {}) as ArgInput, strict: options.strict !== false, } const parser = new Parser(input) const output = await parser.parse() - await m.validate({input, output}) - return output as ParserOutput + await validate({input, output}) + return output as ParserOutput } -export {boolean, integer, url, directory, file, string, build, option, custom} from './flags' diff --git a/src/parser/list.ts b/src/parser/list.ts deleted file mode 100644 index 6c5b06079..000000000 --- a/src/parser/list.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {stdtermwidth} from '../screen' -import {maxBy} from './util' -import {List} from '../interfaces' - -function linewrap(length: number, s: string): string { - const lw = require('@oclif/linewrap') - return lw(length, stdtermwidth, { - skipScheme: 'ansi-color', - })(s).trim() -} - -export function renderList(items: List): string { - if (items.length === 0) { - return '' - } - - const maxLength = maxBy(items, i => i[0].length)![0].length - const lines = items.map(i => { - let left = i[0] - let right = i[1] - if (!right) { - return left - } - - left = left.padEnd(maxLength) - right = linewrap(maxLength + 2, right) - return `${left} ${right}` - }) - return lines.join('\n') -} diff --git a/src/parser/parse.ts b/src/parser/parse.ts index 0400d6498..41821c17b 100644 --- a/src/parser/parse.ts +++ b/src/parser/parse.ts @@ -1,16 +1,8 @@ -// tslint:disable interface-over-type-literal - -import Deps from './deps' -import * as Errors from './errors' -import * as Util from './util' -import {ParserInput, OutputFlags, ParsingToken, OutputArgs, ArgToken, FlagToken, BooleanFlag, OptionFlag} from '../interfaces' - -// eslint-disable-next-line new-cap -const m = Deps() -// eslint-disable-next-line node/no-missing-require -.add('errors', () => require('./errors') as typeof Errors) -// eslint-disable-next-line node/no-missing-require -.add('util', () => require('./util') as typeof Util) +/* eslint-disable no-await-in-loop */ +import {ArgInvalidOptionError, CLIError, FlagInvalidOptionError} from './errors' +import {ArgToken, BooleanFlag, FlagToken, OptionFlag, OutputArgs, OutputFlags, ParserInput, ParserOutput, ParsingToken} from '../interfaces/parser' +import * as readline from 'readline' +import {isTruthy, pickBy} from '../util' let debug: any try { @@ -20,20 +12,49 @@ try { debug = () => {} } -const readStdin = async () => { - const {stdin} = process - let result - if (stdin.isTTY || stdin.isTTY === undefined) return result - result = '' - stdin.setEncoding('utf8') - for await (const chunk of stdin) { - result += chunk - } - - return result +const readStdin = async (): Promise => { + const {stdin, stdout} = process + debug('stdin.isTTY', stdin.isTTY) + if (stdin.isTTY) return null + + // process.stdin.isTTY is true whenever it's running in a terminal. + // process.stdin.isTTY is undefined when it's running in a pipe, e.g. echo 'foo' | my-cli command + // process.stdin.isTTY is undefined when it's running in a spawned process, even if there's no pipe. + // This means that reading from stdin could hang indefinitely while waiting for a non-existent pipe. + // Because of this, we have to set a timeout to prevent the process from hanging. + return new Promise(resolve => { + let result = '' + const ac = new AbortController() + const signal = ac.signal + const timeout = setTimeout(() => ac.abort(), 100) + + const rl = readline.createInterface({ + input: stdin, + output: stdout, + terminal: false, + }) + + rl.on('line', line => { + result += line + }) + + rl.once('close', () => { + clearTimeout(timeout) + debug('resolved from stdin', result) + resolve(result) + }) + + // @ts-expect-error because the AbortSignal interface is missing addEventListener + signal.addEventListener('abort', () => { + debug('stdin aborted') + clearTimeout(timeout) + rl.close() + resolve(null) + }, {once: true}) + }) } -export class Parser, TArgs extends OutputArgs> { +export class Parser, BFlags extends OutputFlags, TArgs extends OutputArgs> { private readonly argv: string[] private readonly raw: ParsingToken[] = [] @@ -48,7 +69,6 @@ export class Parser constructor(private readonly input: T) { - const {pickBy} = m.util this.context = input.context || {} this.argv = [...input.argv] this._setNames() @@ -60,7 +80,7 @@ export class Parser> { this._debugInput() const findLongFlag = (arg: string) => { @@ -112,7 +132,7 @@ export class Parser 0) { const input = this.argv.shift() as string if (parsingFlags && input.startsWith('-') && input !== '-') { @@ -140,7 +164,20 @@ export class Parser originalArgv.indexOf(a) - originalArgv.indexOf(b)), flags, + args: args as TArgs, raw: this.raw, metadata: this.metaData, + nonExistentFlags, } } - private _args(argv: any[]): TArgs { - const args = {} as any - for (let i = 0; i < this.input.args.length; i++) { - const arg = this.input.args[i] - args[arg.name!] = argv[i] - } - - return args - } - - private async _flags(): Promise { + // eslint-disable-next-line complexity + private async _flags(): Promise { const flags = {} as any this.metaData.flags = {} as any for (const token of this._flagTokens) { const flag = this.input.flags[token.flag] - if (!flag) throw new m.errors.CLIError(`Unexpected flag ${token.flag}`) + if (!flag) throw new CLIError(`Unexpected flag ${token.flag}`) if (flag.type === 'boolean') { if (token.input === `--no-${flag.name}`) { flags[token.flag] = false @@ -191,19 +221,23 @@ export class Parser flag.parse ? flag.parse(v.trim(), this.context, flag) : v.trim())) flags[token.flag] = flags[token.flag] || [] - flags[token.flag].push(...(Array.isArray(value) ? value : [value])) + flags[token.flag].push(...values) } else { - flags[token.flag] = value + const value = flag.parse ? await flag.parse(input, this.context, flag) : input + if (flag.multiple) { + flags[token.flag] = flags[token.flag] || [] + flags[token.flag].push(value) + } else { + flags[token.flag] = value + } } } } @@ -217,18 +251,16 @@ export class Parser, input: string) { if (flag.options && !flag.options.includes(input)) - throw new m.errors.FlagInvalidOptionError(flag, input) + throw new FlagInvalidOptionError(flag, input) } - private async _argv(): Promise { - const args: any[] = [] + private async _args(): Promise<{ argv: unknown[]; args: Record}> { + const argv: unknown[] = [] + const args = {} as Record const tokens = this._argTokens let stdinRead = false - for (let i = 0; i < Math.max(this.input.args.length, tokens.length); i++) { - const token = tokens[i] - const arg = this.input.args[i] - if (token) { - if (arg) { - if (arg.options && !arg.options.includes(token.input)) { - throw new m.errors.ArgInvalidOptionError(arg, token.input) - } - // eslint-disable-next-line no-await-in-loop - args[i] = await arg.parse(token.input) - } else { - args[i] = token.input + for (const [name, arg] of Object.entries(this.input.args)) { + const token = tokens.find(t => t.arg === name) + if (token) { + if (arg.options && !arg.options.includes(token.input)) { + throw new ArgInvalidOptionError(arg, token.input) } + + const parsed = await arg.parse(token.input, this.context, arg) + argv.push(parsed) + args[token.arg] = parsed } else if (!arg.ignoreStdin && !stdinRead) { - // eslint-disable-next-line no-await-in-loop let stdin = await readStdin() if (stdin) { stdin = stdin.trim() - args[i] = stdin + const parsed = await arg.parse(stdin, this.context, arg) + argv.push(parsed) + args[name] = parsed } stdinRead = true } - if (!args[i] && arg?.default !== undefined) { + if (!args[name] && (arg.default || arg.default === false)) { if (typeof arg.default === 'function') { - // eslint-disable-next-line no-await-in-loop const f = await arg.default() - args[i] = f + argv.push(f) + args[name] = f } else { - args[i] = arg.default + argv.push(arg.default) + args[name] = arg.default } } } - return args + for (const token of tokens) { + if (args[token.arg]) continue + argv.push(token.input) + } + + return {argv, args: args} } private _debugOutput(args: any, flags: any, argv: any) { @@ -301,8 +338,9 @@ export class Parser 0) { - debug('available args: %s', this.input.args.map(a => a.name).join(' ')) + const args = Object.keys(this.input.args) + if (args.length > 0) { + debug('available args: %s', args.join(' ')) } if (Object.keys(this.input.flags).length === 0) return @@ -326,5 +364,9 @@ export class Parser>(obj: T, fn: (i: T[keyof T]) => boolean): Partial { - return Object.entries(obj) - .reduce((o, [k, v]) => { - if (fn(v)) o[k] = v - return o - }, {} as any) -} - -export function maxBy(arr: T[], fn: (i: T) => number): T | undefined { - let max: {element: T; i: number} | undefined - for (const cur of arr) { - const i = fn(cur) - if (!max || i > max.i) { - max = {i, element: cur} - } - } - - return max && max.element -} - -type SortTypes = string | number | undefined | boolean - -export function sortBy(arr: T[], fn: (i: T) => SortTypes | SortTypes[]): T[] { - // function castType(t: SortTypes | SortTypes[]): string | number | SortTypes[] { - // if (t === undefined) return 0 - // if (t === false) return 1 - // if (t === true) return -1 - // return t - // } - - function compare(a: SortTypes | SortTypes[], b: SortTypes | SortTypes[]): number { - a = a === undefined ? 0 : a - b = b === undefined ? 0 : b - - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length === 0 && b.length === 0) return 0 - const diff = compare(a[0], b[0]) - if (diff !== 0) return diff - return compare(a.slice(1), b.slice(1)) - } - - if (a < b) return -1 - if (a > b) return 1 - return 0 - } - - return arr.sort((a, b) => compare(fn(a), fn(b))) -} diff --git a/src/parser/validate.ts b/src/parser/validate.ts index d9c242158..b2b109695 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -4,26 +4,30 @@ import { Validation, UnexpectedArgsError, FailedFlagValidationError, + NonExistentFlagsError, } from './errors' -import {ParserArg, ParserInput, ParserOutput, Flag, CompletableFlag} from '../interfaces' -import {FlagRelationship} from '../interfaces/parser' +import {Arg, CompletableFlag, Flag, FlagRelationship, ParserInput, ParserOutput} from '../interfaces/parser' import {uniq} from '../config/util' export async function validate(parse: { input: ParserInput; output: ParserOutput; -}) { +}): Promise { function validateArgs() { - const maxArgs = parse.input.args.length + if (parse.output.nonExistentFlags?.length > 0) { + throw new NonExistentFlagsError({parse, flags: parse.output.nonExistentFlags}) + } + + const maxArgs = Object.keys(parse.input.args).length if (parse.input.strict && parse.output.argv.length > maxArgs) { const extras = parse.output.argv.slice(maxArgs) throw new UnexpectedArgsError({parse, args: extras}) } - const missingRequiredArgs: ParserArg[] = [] + const missingRequiredArgs: Arg[] = [] let hasOptional = false - for (const [index, arg] of parse.input.args.entries()) { + for (const [name, arg] of Object.entries(parse.input.args)) { if (!arg.required) { hasOptional = true } else if (hasOptional) { @@ -32,7 +36,7 @@ export async function validate(parse: { throw new InvalidArgsSpecError({parse, args: parse.input.args}) } - if (arg.required && !parse.output.argv[index] && parse.output.argv[index] as any as number !== 0) { + if (arg.required && !parse.output.args[name] && parse.output.args[name] !== 0) { missingRequiredArgs.push(arg) } } diff --git a/src/util.ts b/src/util.ts index 1479b5cbe..cf9b49811 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,14 @@ +import * as fs from 'fs' +import {join} from 'path' + +export function pickBy>(obj: T, fn: (i: T[keyof T]) => boolean): Partial { + return Object.entries(obj) + .reduce((o, [k, v]) => { + if (fn(v)) o[k] = v + return o + }, {} as any) +} + export function compact(a: (T | undefined)[]): T[] { return a.filter((a): a is T => Boolean(a)) } @@ -36,7 +47,7 @@ export function castArray(input?: T | T[]): T[] { return Array.isArray(input) ? input : [input] } -export function isProd() { +export function isProd(): boolean { return !['development', 'test'].includes(process.env.NODE_ENV ?? '') } @@ -59,3 +70,39 @@ export function sumBy(arr: T[], fn: (i: T) => number): number { export function capitalize(s: string): string { return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '' } + +export const dirExists = async (input: string): Promise => { + if (!fs.existsSync(input)) { + throw new Error(`No directory found at ${input}`) + } + + if (!(await fs.promises.stat(input)).isDirectory()) { + throw new Error(`${input} exists but is not a directory`) + } + + return input +} + +export const fileExists = async (input: string): Promise => { + if (!fs.existsSync(input)) { + throw new Error(`No file found at ${input}`) + } + + if (!(await fs.promises.stat(input)).isFile()) { + throw new Error(`${input} exists but is not a file`) + } + + return input +} + +export function isTruthy(input: string): boolean { + return ['true', 'TRUE', '1', 'yes', 'YES', 'y', 'Y'].includes(input) +} + +export function isNotFalsy(input: string): boolean { + return !['false', 'FALSE', '0', 'no', 'NO', 'n', 'N'].includes(input) +} + +export function requireJson(...pathParts: string[]): T { + return JSON.parse(fs.readFileSync(join(...pathParts), 'utf8')) +} diff --git a/test/cli-ux/export.test.ts b/test/cli-ux/export.test.ts index 8e3038769..4dfb48a20 100644 --- a/test/cli-ux/export.test.ts +++ b/test/cli-ux/export.test.ts @@ -1,24 +1,23 @@ -import {CliUx} from '../../src' +import {ux} from '../../src' import {expect} from 'chai' type MyColumns = Record -const options: CliUx.Table.table.Options = {} -const columns: CliUx.Table.table.Columns = {} -const iPromptOptions: CliUx.IPromptOptions = {} +const options: ux.Table.table.Options = {} +const columns: ux.Table.table.Columns = {} +const iPromptOptions: ux.IPromptOptions = {} describe('cli-ux exports', () => { it('should have exported members on par with old cli-ux module', () => { expect(options).to.be.ok expect(columns).to.be.ok expect(iPromptOptions).to.be.ok - expect(CliUx.Table.table.Flags).to.be.ok - expect(typeof CliUx.Table.table.flags).to.be.equal('function') - expect(typeof CliUx.Table.table).to.be.equal('function') - expect(CliUx.ux).to.be.ok - expect(CliUx.config).to.be.ok - expect(typeof CliUx.Config).to.be.equal('function') - expect(typeof CliUx.ActionBase).to.be.equal('function') - expect(typeof CliUx.ExitError).to.be.equal('function') + expect(ux.Table.table.Flags).to.be.ok + expect(typeof ux.Table.table.flags).to.be.equal('function') + expect(typeof ux.Table.table).to.be.equal('function') + expect(ux.config).to.be.ok + expect(typeof ux.Config).to.be.equal('function') + expect(typeof ux.ActionBase).to.be.equal('function') + expect(typeof ux.ExitError).to.be.equal('function') }) }) diff --git a/test/cli-ux/fancy.ts b/test/cli-ux/fancy.ts index 7718368c2..f006e1bc1 100644 --- a/test/cli-ux/fancy.ts +++ b/test/cli-ux/fancy.ts @@ -2,7 +2,7 @@ import {expect, fancy as base, FancyTypes} from 'fancy-test' import * as fs from 'fs-extra' import * as path from 'path' -import {CliUx} from '../../src' +import {ux} from '../../src' export { expect, @@ -20,5 +20,5 @@ export const fancy = base chalk.level = 0 }) .finally(async () => { - await CliUx.ux.done() + await ux.done() }) diff --git a/test/cli-ux/index.test.ts b/test/cli-ux/index.test.ts index 2488b0778..17c1416b1 100644 --- a/test/cli-ux/index.test.ts +++ b/test/cli-ux/index.test.ts @@ -1,4 +1,4 @@ -import {CliUx} from '../../src' +import {ux} from '../../src' import {expect, fancy} from './fancy' const hyperlinker = require('hyperlinker') @@ -7,7 +7,7 @@ describe('url', () => { fancy .env({FORCE_HYPERLINK: '1'}, {clear: true}) .stdout() - .do(() => CliUx.ux.url('sometext', 'https://google.com')) + .do(() => ux.url('sometext', 'https://google.com')) .it('renders hyperlink', async ({stdout}) => { expect(stdout).to.equal('sometext\n') }) diff --git a/test/cli-ux/prompt.test.ts b/test/cli-ux/prompt.test.ts index 72498d9ad..08426d680 100644 --- a/test/cli-ux/prompt.test.ts +++ b/test/cli-ux/prompt.test.ts @@ -2,7 +2,7 @@ import * as chai from 'chai' const expect = chai.expect -import {CliUx} from '../../src' +import {ux} from '../../src' import {fancy} from './fancy' @@ -11,11 +11,11 @@ describe('prompt', () => { .stdout() .stderr() .end('requires input', async () => { - const promptPromise = CliUx.ux.prompt('Require input?') + const promptPromise = ux.prompt('Require input?') process.stdin.emit('data', '') process.stdin.emit('data', 'answer') const answer = await promptPromise - await CliUx.ux.done() + await ux.done() expect(answer).to.equal('answer') }) @@ -24,9 +24,9 @@ describe('prompt', () => { .stderr() .stdin('y') .end('confirm', async () => { - const promptPromise = CliUx.ux.confirm('yes/no?') + const promptPromise = ux.confirm('yes/no?') const answer = await promptPromise - await CliUx.ux.done() + await ux.done() expect(answer).to.equal(true) }) @@ -35,9 +35,9 @@ describe('prompt', () => { .stderr() .stdin('n') .end('confirm', async () => { - const promptPromise = CliUx.ux.confirm('yes/no?') + const promptPromise = ux.confirm('yes/no?') const answer = await promptPromise - await CliUx.ux.done() + await ux.done() expect(answer).to.equal(false) }) @@ -46,9 +46,9 @@ describe('prompt', () => { .stderr() .stdin('x') .end('gets anykey', async () => { - const promptPromise = CliUx.ux.anykey() + const promptPromise = ux.anykey() const answer = await promptPromise - await CliUx.ux.done() + await ux.done() expect(answer).to.equal('x') }) @@ -56,12 +56,12 @@ describe('prompt', () => { .stdout() .stderr() .end('does not require input', async () => { - const promptPromise = CliUx.ux.prompt('Require input?', { + const promptPromise = ux.prompt('Require input?', { required: false, }) process.stdin.emit('data', '') const answer = await promptPromise - await CliUx.ux.done() + await ux.done() expect(answer).to.equal('') }) @@ -69,7 +69,7 @@ describe('prompt', () => { .stdout() .stderr() .it('timeouts with no input', async () => { - await expect(CliUx.ux.prompt('Require input?', {timeout: 1})) + await expect(ux.prompt('Require input?', {timeout: 1})) .to.eventually.be.rejectedWith('Prompt timeout') }) }) diff --git a/test/cli-ux/styled/header.test.ts b/test/cli-ux/styled/header.test.ts index 9a1a9b095..13989299a 100644 --- a/test/cli-ux/styled/header.test.ts +++ b/test/cli-ux/styled/header.test.ts @@ -1,12 +1,12 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' describe('styled/header', () => { fancy .stdout() .end('shows a styled header', output => { - CliUx.ux.styledHeader('A styled header') + ux.styledHeader('A styled header') expect(output.stdout).to.equal('=== A styled header\n\n') }) }) diff --git a/test/cli-ux/styled/object.test.ts b/test/cli-ux/styled/object.test.ts index 27db22a81..d2dd2aa09 100644 --- a/test/cli-ux/styled/object.test.ts +++ b/test/cli-ux/styled/object.test.ts @@ -1,12 +1,12 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' describe('styled/object', () => { fancy .stdout() .end('shows a table', output => { - CliUx.ux.styledObject([ + ux.styledObject([ {foo: 1, bar: 1}, {foo: 2, bar: 2}, {foo: 3, bar: 3}, diff --git a/test/cli-ux/styled/progress.test.ts b/test/cli-ux/styled/progress.test.ts index c2f09bc17..300f726c0 100644 --- a/test/cli-ux/styled/progress.test.ts +++ b/test/cli-ux/styled/progress.test.ts @@ -1,27 +1,32 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' describe('progress', () => { // single bar fancy .end('single bar has default settings', _ => { - const b1 = CliUx.ux.progress({format: 'Example 1: Progress {bar} | {percentage}%'}) + const b1 = ux.progress({format: 'Example 1: Progress {bar} | {percentage}%'}) + // @ts-expect-error because private member expect(b1.options.format).to.contain('Example 1: Progress') + // @ts-expect-error because private member expect(b1.bars).to.not.have }) // testing no settings passed, default settings created fancy .end('single bar, no bars array', _ => { - const b1 = CliUx.ux.progress({}) + const b1 = ux.progress({}) + // @ts-expect-error because private member expect(b1.options.format).to.contain('progress') + // @ts-expect-error because private member expect(b1.bars).to.not.have + // @ts-expect-error because private member expect(b1.options.noTTYOutput).to.not.be.null }) // testing getProgressBar returns correct type fancy .end('typeof progress bar is object', _ => { - const b1 = CliUx.ux.progress({format: 'Example 1: Progress {bar} | {percentage}%'}) + const b1 = ux.progress({format: 'Example 1: Progress {bar} | {percentage}%'}) expect(typeof (b1)).to.equal('object') }) }) diff --git a/test/cli-ux/styled/table.e2e.ts b/test/cli-ux/styled/table.e2e.ts index a741adf8b..dba2df692 100644 --- a/test/cli-ux/styled/table.e2e.ts +++ b/test/cli-ux/styled/table.e2e.ts @@ -1,5 +1,5 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' describe('styled/table', () => { describe('null/undefined handling', () => { @@ -7,7 +7,7 @@ describe('styled/table', () => { .stdout() .end('omits nulls and undefined by default', output => { const data = [{a: 1, b: '2', c: null, d: undefined}] - CliUx.ux.table(data, {a: {}, b: {}, c: {}, d: {}}) + ux.table(data, {a: {}, b: {}, c: {}, d: {}}) expect(output.stdout).to.include('1') expect(output.stdout).to.include('2') expect(output.stdout).to.not.include('null') @@ -27,7 +27,7 @@ describe('styled/table', () => { value: {header: 'TEST'}, } - CliUx.ux.table(data, tallColumns) + ux.table(data, tallColumns) expect(output.stdout).to.include('ID') }) @@ -39,7 +39,7 @@ describe('styled/table', () => { const data = Array.from({length: bigRows}).fill(row) as Record[] const bigColumns = Object.fromEntries(Array.from({length: columns}).map((_, i) => [`col${i}`, {header: `col${i}`.toUpperCase()}])) - CliUx.ux.table(data, bigColumns) + ux.table(data, bigColumns) expect(output.stdout).to.include('COL1') }) }) diff --git a/test/cli-ux/styled/table.test.ts b/test/cli-ux/styled/table.test.ts index d73344363..3383fb5bd 100644 --- a/test/cli-ux/styled/table.test.ts +++ b/test/cli-ux/styled/table.test.ts @@ -1,6 +1,6 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' /* eslint-disable camelcase */ const apps = [ @@ -71,13 +71,13 @@ const extendedHeader = `ID Name${ws.padEnd(14)}Web url${ws.padEnd(34)}Stack${ws describe('styled/table', () => { fancy .end('export flags and display()', () => { - expect(typeof (CliUx.ux.table.flags())).to.eq('object') - expect(typeof (CliUx.ux.table)).to.eq('function') + expect(typeof (ux.table.flags())).to.eq('object') + expect(typeof (ux.table)).to.eq('function') }) fancy .end('has optional flags', _ => { - const flags = CliUx.ux.table.flags() + const flags = ux.table.flags() expect(flags.columns).to.exist expect(flags.sort).to.exist expect(flags.filter).to.exist @@ -91,7 +91,7 @@ describe('styled/table', () => { fancy .stdout() .end('displays table', output => { - CliUx.ux.table(apps, columns) + ux.table(apps, columns) expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} ─── ─────────────────${ws} 123 supertable-test-1${ws} @@ -102,14 +102,14 @@ describe('styled/table', () => { fancy .stdout() .end('use header value for id', output => { - CliUx.ux.table(apps, columns) + ux.table(apps, columns) expect(output.stdout.slice(1, 3)).to.equal('ID') }) fancy .stdout() .end('shows extended columns/uses get() for value', output => { - CliUx.ux.table(apps, columns, {extended: true}) + ux.table(apps, columns, {extended: true}) expect(output.stdout).to.equal(`${ws}${extendedHeader} ─── ───────────────── ──────────────────────────────────────── ─────────${ws} 123 supertable-test-1 https://supertable-test-1.herokuapp.com/ heroku-16${ws} @@ -121,14 +121,14 @@ describe('styled/table', () => { fancy .stdout() .end('shows extended columns', output => { - CliUx.ux.table(apps, columns, {extended: true}) + ux.table(apps, columns, {extended: true}) expect(output.stdout).to.contain(extendedHeader) }) fancy .stdout() .end('shows title with divider', output => { - CliUx.ux.table(apps, columns, {title: 'testing'}) + ux.table(apps, columns, {title: 'testing'}) expect(output.stdout).to.equal(`testing ======================= | ID Name${ws.padEnd(14)} @@ -140,7 +140,7 @@ describe('styled/table', () => { fancy .stdout() .end('skips header', output => { - CliUx.ux.table(apps, columns, {'no-header': true}) + ux.table(apps, columns, {'no-header': true}) expect(output.stdout).to.equal(` 123 supertable-test-1${ws} 321 supertable-test-2${ws}\n`) }) @@ -148,7 +148,7 @@ describe('styled/table', () => { fancy .stdout() .end('only displays given columns', output => { - CliUx.ux.table(apps, columns, {columns: 'id'}) + ux.table(apps, columns, {columns: 'id'}) expect(output.stdout).to.equal(` ID${ws}${ws} ───${ws} 123${ws} @@ -158,7 +158,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in csv', output => { - CliUx.ux.table(apps, columns, {output: 'csv'}) + ux.table(apps, columns, {output: 'csv'}) expect(output.stdout).to.equal(`ID,Name 123,supertable-test-1 321,supertable-test-2\n`) @@ -167,7 +167,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in csv with escaped values', output => { - CliUx.ux.table([ + ux.table([ { id: '123\n2', name: 'supertable-test-1', @@ -195,7 +195,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in csv without headers', output => { - CliUx.ux.table(apps, columns, {output: 'csv', 'no-header': true}) + ux.table(apps, columns, {output: 'csv', 'no-header': true}) expect(output.stdout).to.equal(`123,supertable-test-1 321,supertable-test-2\n`) }) @@ -203,7 +203,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in csv with alias flag', output => { - CliUx.ux.table(apps, columns, {csv: true}) + ux.table(apps, columns, {csv: true}) expect(output.stdout).to.equal(`ID,Name 123,supertable-test-1 321,supertable-test-2\n`) @@ -212,7 +212,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in json', output => { - CliUx.ux.table(apps, columns, {output: 'json'}) + ux.table(apps, columns, {output: 'json'}) expect(output.stdout).to.equal(`[ { "id": "123", @@ -229,7 +229,7 @@ describe('styled/table', () => { fancy .stdout() .end('outputs in yaml', output => { - CliUx.ux.table(apps, columns, {output: 'yaml'}) + ux.table(apps, columns, {output: 'yaml'}) expect(output.stdout).to.equal(`- id: '123' name: supertable-test-1 - id: '321' @@ -241,7 +241,7 @@ describe('styled/table', () => { fancy .stdout() .end('sorts by property', output => { - CliUx.ux.table(apps, columns, {sort: '-name'}) + ux.table(apps, columns, {sort: '-name'}) expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} ─── ─────────────────${ws} 321 supertable-test-2${ws} @@ -251,7 +251,7 @@ describe('styled/table', () => { fancy .stdout() .end('filters by property & value (partial string match)', output => { - CliUx.ux.table(apps, columns, {filter: 'id=123'}) + ux.table(apps, columns, {filter: 'id=123'}) expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} ─── ─────────────────${ws} 123 supertable-test-1${ws}\n`) @@ -261,7 +261,7 @@ describe('styled/table', () => { .stdout() .end('does not truncate', output => { const three = {...apps[0], id: '0'.repeat(80), name: 'supertable-test-3'} - CliUx.ux.table([...apps, three], columns, {filter: 'id=0', 'no-truncate': true}) + ux.table([...apps, three], columns, {filter: 'id=0', 'no-truncate': true}) expect(output.stdout).to.equal(` ID${ws.padEnd(78)} Name${ws.padEnd(14)} ${''.padEnd(three.id.length, '─')} ─────────────────${ws} ${three.id} supertable-test-3${ws}\n`) @@ -271,14 +271,14 @@ describe('styled/table', () => { describe('#flags', () => { fancy .end('includes only flags', _ => { - const flags = CliUx.ux.table.flags({only: 'columns'}) + const flags = ux.table.flags({only: 'columns'}) expect(flags.columns).to.be.a('object') expect((flags as any).sort).to.be.undefined }) fancy .end('excludes except flags', _ => { - const flags = CliUx.ux.table.flags({except: 'columns'}) + const flags = ux.table.flags({except: 'columns'}) expect((flags as any).columns).to.be.undefined expect(flags.sort).to.be.a('object') }) @@ -288,7 +288,7 @@ describe('styled/table', () => { fancy .stdout() .end('ignores header case', output => { - CliUx.ux.table(apps, columns, {columns: 'iD,Name', filter: 'nAMe=supertable-test', sort: '-ID'}) + ux.table(apps, columns, {columns: 'iD,Name', filter: 'nAMe=supertable-test', sort: '-ID'}) expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} ─── ─────────────────${ws} 321 supertable-test-2${ws} @@ -309,7 +309,7 @@ describe('styled/table', () => { } /* eslint-enable camelcase */ - CliUx.ux.table([...apps, app3 as any], columns, {sort: '-ID'}) + ux.table([...apps, app3 as any], columns, {sort: '-ID'}) expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} ─── ─────────────────${ws} 456 supertable-test${ws.padEnd(3)} diff --git a/test/cli-ux/styled/tree.test.ts b/test/cli-ux/styled/tree.test.ts index c42cd03fb..56ffb0e07 100644 --- a/test/cli-ux/styled/tree.test.ts +++ b/test/cli-ux/styled/tree.test.ts @@ -1,16 +1,16 @@ import {expect, fancy} from 'fancy-test' -import {CliUx} from '../../../src' +import {ux} from '../../../src' describe('styled/tree', () => { fancy .stdout() .end('shows the tree', output => { - const tree = CliUx.ux.tree() + const tree = ux.tree() tree.insert('foo') tree.insert('bar') - const subtree = CliUx.ux.tree() + const subtree = ux.tree() subtree.insert('qux') tree.nodes.bar.insert('baz', subtree) diff --git a/test/command/command.test.ts b/test/command/command.test.ts index f6f211825..c8938a7df 100644 --- a/test/command/command.test.ts +++ b/test/command/command.test.ts @@ -1,7 +1,7 @@ import {expect, fancy} from 'fancy-test' // import path = require('path') -import {Command as Base, Flags} from '../../src' +import {Args, Command as Base, Flags, toCached} from '../../src' // import {TestHelpClassConfig} from './helpers/test-help-in-src/src/test-help-plugin' // const pjson = require('../package.json') @@ -69,85 +69,114 @@ describe('command', () => { .catch(/EEXIT: 0/) .it('exits with 0') - describe('convertToCached', () => { + describe('toCached', () => { fancy - // .skip() - // .do(async () => { - // class C extends Command { - // static title = 'cmd title' - // static type = 'mytype' - // static usage = ['$ usage'] - // static description = 'test command' - // static aliases = ['alias1', 'alias2'] - // static hidden = true - // static flags = { - // flaga: flags.boolean(), - // flagb: flags.string({ - // char: 'b', - // hidden: true, - // required: false, - // description: 'flagb desc', - // options: ['a', 'b'], - // default: () => 'mydefault', - // }), - // } - // static args = [ - // { - // name: 'arg1', - // description: 'arg1 desc', - // required: true, - // hidden: false, - // options: ['af', 'b'], - // default: () => 'myadefault', - // } - // ] - // } - // const c = Config.Command.toCached(C) - // expect(await c.load()).to.have.property('run') - // delete c.load - // expect(c).to.deep.equal({ - // _base: `@oclif/command@${pjson.version}`, - // id: 'foo:bar', - // type: 'mytype', - // hidden: true, - // pluginName: undefined, - // description: 'test command', - // aliases: ['alias1', 'alias2'], - // title: 'cmd title', - // usage: ['$ usage'], - // flags: { - // flaga: { - // char: undefined, - // description: undefined, - // name: 'flaga', - // hidden: undefined, - // required: undefined, - // type: 'boolean', - // }, - // flagb: { - // char: 'b', - // description: 'flagb desc', - // name: 'flagb', - // hidden: true, - // required: false, - // type: 'option', - // helpValue: undefined, - // default: 'mydefault', - // options: ['a', 'b'], - // } - // }, - // args: [ - // { - // description: 'arg1 desc', - // name: 'arg1', - // hidden: false, - // required: true, - // options: ['af', 'b'], - // default: 'myadefault', - // } - // ], - // }) - // }) + .do(async () => { + class C extends Command { + static id = 'foo:bar' + static title = 'cmd title' + static type = 'mytype' + static usage = ['$ usage'] + static description = 'test command' + static aliases = ['alias1', 'alias2'] + static hidden = true + static flags = { + flaga: Flags.boolean(), + flagb: Flags.string({ + char: 'b', + hidden: true, + required: false, + description: 'flagb desc', + options: ['a', 'b'], + default: async () => 'a', + }), + } + + static args = { + arg1: Args.string({ + description: 'arg1 desc', + required: true, + hidden: false, + options: ['af', 'b'], + default: async () => 'a', + }), + } + } + + const c = await toCached(C) + + expect(c).to.deep.equal({ + id: 'foo:bar', + type: 'mytype', + hidden: true, + pluginName: undefined, + pluginAlias: undefined, + pluginType: undefined, + state: undefined, + description: 'test command', + aliases: ['alias1', 'alias2'], + title: 'cmd title', + usage: ['$ usage'], + examples: undefined, + deprecationOptions: undefined, + deprecateAliases: undefined, + summary: undefined, + strict: true, + flags: { + flaga: { + aliases: undefined, + char: undefined, + description: undefined, + dependsOn: undefined, + deprecateAliases: undefined, + deprecated: undefined, + exclusive: undefined, + helpGroup: undefined, + helpLabel: undefined, + summary: undefined, + name: 'flaga', + hidden: undefined, + required: undefined, + relationships: undefined, + allowNo: false, + type: 'boolean', + delimiter: undefined, + }, + flagb: { + aliases: undefined, + char: 'b', + description: 'flagb desc', + dependsOn: undefined, + deprecateAliases: undefined, + deprecated: undefined, + exclusive: undefined, + helpGroup: undefined, + helpLabel: undefined, + summary: undefined, + name: 'flagb', + hidden: true, + required: false, + multiple: false, + relationships: undefined, + type: 'option', + helpValue: undefined, + default: 'a', + options: ['a', 'b'], + delimiter: undefined, + }, + }, + args: { + arg1: { + description: 'arg1 desc', + name: 'arg1', + hidden: false, + required: true, + options: ['af', 'b'], + default: 'a', + }, + }, + }) + }) .it('converts to cached with everything set') fancy diff --git a/test/command/fixtures/typescript/src/commands/foo/bar/fail.ts b/test/command/fixtures/typescript/src/commands/foo/bar/fail.ts index 71cb078bb..4da968f6d 100644 --- a/test/command/fixtures/typescript/src/commands/foo/bar/fail.ts +++ b/test/command/fixtures/typescript/src/commands/foo/bar/fail.ts @@ -1,7 +1,7 @@ export const Command = { description: 'fail description', - run() { + run(): void { console.log('it fails!') throw new Error('random error') }, diff --git a/test/command/fixtures/typescript/src/commands/foo/bar/succeed.ts b/test/command/fixtures/typescript/src/commands/foo/bar/succeed.ts index 8c4bdda1b..03ec832f2 100644 --- a/test/command/fixtures/typescript/src/commands/foo/bar/succeed.ts +++ b/test/command/fixtures/typescript/src/commands/foo/bar/succeed.ts @@ -1,7 +1,7 @@ export const Command = { description: 'succeed description', - run() { + run(): string { console.log('it works!') return 'returned success!' }, diff --git a/test/command/fixtures/typescript/src/commands/foo/baz.ts b/test/command/fixtures/typescript/src/commands/foo/baz.ts index 88d87579a..c611d744e 100644 --- a/test/command/fixtures/typescript/src/commands/foo/baz.ts +++ b/test/command/fixtures/typescript/src/commands/foo/baz.ts @@ -1,7 +1,7 @@ export const Command = { description: 'foo baz description', - run() { + run(): void { console.log('running Baz') }, } diff --git a/test/command/helpers/test-help-in-src/src/test-help-plugin.ts b/test/command/helpers/test-help-in-src/src/test-help-plugin.ts index c807f34d7..721ee7a73 100644 --- a/test/command/helpers/test-help-in-src/src/test-help-plugin.ts +++ b/test/command/helpers/test-help-in-src/src/test-help-plugin.ts @@ -4,6 +4,7 @@ import {Interfaces, HelpBase} from '../../../../../src' export type TestHelpClassConfig = Interfaces.Config & { showCommandHelpSpy?: SinonSpy; showHelpSpy?: SinonSpy } export default class extends HelpBase { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types constructor(config: any, opts: any) { super(config, opts) config.showCommandHelpSpy = this.showCommandHelp diff --git a/test/command/main.test.ts b/test/command/main.test.ts index 3df892297..e9f95403e 100644 --- a/test/command/main.test.ts +++ b/test/command/main.test.ts @@ -1,29 +1,76 @@ -import {expect, fancy} from 'fancy-test' -import path = require('path') +import {expect} from 'chai' +import * as path from 'path' +import {createSandbox, SinonSandbox, SinonStub} from 'sinon' +import stripAnsi = require('strip-ansi') +import {requireJson} from '../../src/util' import {run} from '../../src/main' +import {Interfaces} from '../../src/index' -const root = path.resolve(__dirname, '../../package.json') -const pjson = require(root) +const pjson = requireJson(__dirname, '..', '..', 'package.json') const version = `@oclif/core/${pjson.version} ${process.platform}-${process.arch} node-${process.version}` +class StdStreamsMock { + public stdoutStub!: SinonStub + public stderrStub!: SinonStub + + constructor(private options: {printStdout?: boolean; printStderr?: boolean} = {printStdout: false, printStderr: false}) { + this.init() + } + + init() { + let sandbox: SinonSandbox + + beforeEach(() => { + sandbox = createSandbox() + this.stdoutStub = sandbox.stub(process.stdout, 'write').returns(true) + this.stderrStub = sandbox.stub(process.stderr, 'write').returns(true) + }) + + afterEach(() => { + sandbox.restore() + if (this.options.printStdout) { + for (const args of [...this.stdoutStub.args].slice(0, -1)) { + process.stdout.write(args[0], args[1]) + } + } + + if (this.options.printStderr) { + for (const args of [...this.stderrStub.args].slice(0, -1)) { + process.stdout.write(args[0], args[1]) + } + } + + // The last call is mocha reporting the current test, so print that out + process.stdout.write(this.stdoutStub.lastCall.args[0], this.stdoutStub.lastCall.args[1]) + }) + } + + get stdout(): string { + return this.stdoutStub.args.map(a => stripAnsi(a[0])).join('') + } + + get stderr(): string { + return this.stderrStub.args.map(a => stripAnsi(a[0])).join('') + } +} + describe('main', () => { - fancy - .stdout() - .do(() => run(['plugins'], root)) - .do((output: any) => expect(output.stdout).to.equal('No plugins installed.\n')) - .it('runs plugins') - - fancy - .stdout() - .do(() => run(['--version'], root)) - .do((output: any) => expect(output.stdout).to.equal(version + '\n')) - .it('runs --version') - - fancy - .stdout() - .do(() => run(['--help'], root)) - .do((output: any) => expect(output.stdout).to.equal(`base library for oclif CLIs + const stdoutMock = new StdStreamsMock() + + it('should run plugins', async () => { + await run(['plugins'], path.resolve(__dirname, '../../package.json')) + expect(stdoutMock.stdout).to.equal('No plugins installed.\n') + }) + + it('should run version', async () => { + await run(['--version'], path.resolve(__dirname, '../../package.json')) + expect(stdoutMock.stdout).to.equal(`${version}\n`) + }) + + it('should run help', async () => { + await run(['--help'], path.resolve(__dirname, '../../package.json')) + expect(stdoutMock.stdout).to.equal(`base library for oclif CLIs VERSION ${version} @@ -38,13 +85,12 @@ COMMANDS help Display help for oclif. plugins List installed plugins. -`)) - .it('runs --help') +`) + }) - fancy - .stdout() - .do(() => run(['--help', 'foo'], path.resolve(__dirname, 'fixtures/typescript/package.json'))) - .do((output: any) => expect(output.stdout).to.equal(`foo topic description + it('should show help for topics with spaces', async () => { + await run(['--help', 'foo'], path.resolve(__dirname, 'fixtures/typescript/package.json')) + expect(stdoutMock.stdout).to.equal(`foo topic description USAGE $ oclif foo COMMAND @@ -55,13 +101,12 @@ TOPICS COMMANDS foo baz foo baz description -`)) - .it('runs spaced topic help') +`) + }) - fancy - .stdout() - .do(() => run(['foo', 'bar', '--help'], path.resolve(__dirname, 'fixtures/typescript/package.json'))) - .do((output: any) => expect(output.stdout).to.equal(`foo bar topic description + it('should run spaced topic help v2', async () => { + await run(['foo', 'bar', '--help'], path.resolve(__dirname, 'fixtures/typescript/package.json')) + expect(stdoutMock.stdout).to.equal(`foo bar topic description USAGE $ oclif foo bar COMMAND @@ -70,18 +115,16 @@ COMMANDS foo bar fail fail description foo bar succeed succeed description -`)) - .it('runs spaced topic help v2') +`) + }) - fancy - .stdout() - .do(() => run(['foo', 'baz'], path.resolve(__dirname, 'fixtures/typescript/package.json'))) - .do((output: any) => expect(output.stdout).to.equal('running Baz\n')) - .it('runs foo:baz with space separator') + it('should run foo:baz with space separator', async () => { + await run(['foo', 'baz'], path.resolve(__dirname, 'fixtures/typescript/package.json')) + expect(stdoutMock.stdout).to.equal('running Baz\n') + }) - fancy - .stdout() - .do(() => run(['foo', 'bar', 'succeed'], path.resolve(__dirname, 'fixtures/typescript/package.json'))) - .do((output: any) => expect(output.stdout).to.equal('it works!\n')) - .it('runs foo:bar:succeed with space separator') + it('should run foo:bar:succeed with space separator', async () => { + await run(['foo', 'bar', 'succeed'], path.resolve(__dirname, 'fixtures/typescript/package.json')) + expect(stdoutMock.stdout).to.equal('it works!\n') + }) }) diff --git a/test/config/config.flexible.test.ts b/test/config/config.flexible.test.ts index fce30c8c8..9e713f6b6 100644 --- a/test/config/config.flexible.test.ts +++ b/test/config/config.flexible.test.ts @@ -3,10 +3,10 @@ import * as path from 'path' import {Config} from '../../src/config/config' import {Plugin as IPlugin} from '../../src/interfaces' -import {Command as ICommand} from '../../src/interfaces' import {expect, fancy} from './test' import {Flags, Interfaces} from '../../src' +import {Command} from '../../src/command' interface Options { pjson?: any; @@ -17,8 +17,7 @@ interface Options { types?: string[]; } -// @ts-expect-error -class MyCommandClass implements ICommand.Class { +class MyCommandClass extends Command { _base = '' aliases: string[] = [] @@ -29,13 +28,6 @@ class MyCommandClass implements ICommand.Class { flags = {} - new(): ICommand.Instance { - return { - _run(): Promise { - return Promise.resolve() - }} - } - run(): PromiseLike { return Promise.resolve() } @@ -56,36 +48,36 @@ describe('Config with flexible taxonomy', () => { .stub(os, 'platform', () => platform) const load = async (): Promise => {} - const findCommand = async (): Promise => { - return new MyCommandClass() as unknown as ICommand.Class + const findCommand = async (): Promise => { + return MyCommandClass } - const commandPluginA: ICommand.Loadable = { + const commandPluginA: Command.Loadable = { strict: false, aliases: [], - args: [], + args: {}, flags: { flagA: Flags.boolean({char: 'a'}), }, hidden: false, id: commandIds[0], - async load(): Promise { - return new MyCommandClass() as unknown as ICommand.Class + async load(): Promise { + return MyCommandClass }, pluginType: types[0] ?? 'core', pluginAlias: '@My/plugina', } - const commandPluginB: ICommand.Loadable = { + const commandPluginB: Command.Loadable = { strict: false, aliases: [], - args: [], + args: {}, flags: { flagB: Flags.boolean({}), }, hidden: false, id: commandIds[1], - async load(): Promise { - return new MyCommandClass() as unknown as ICommand.Class + async load(): Promise { + return MyCommandClass }, pluginType: types[1] ?? 'core', pluginAlias: '@My/pluginb', diff --git a/test/config/config.test.ts b/test/config/config.test.ts index a6750a9a3..87014df7f 100644 --- a/test/config/config.test.ts +++ b/test/config/config.test.ts @@ -4,10 +4,9 @@ import * as path from 'path' import {Config} from '../../src/config/config' import {Plugin as IPlugin} from '../../src/interfaces' import * as util from '../../src/config/util' -import {Command as ICommand} from '../../src/interfaces' import {expect, fancy} from './test' -import {Interfaces} from '../../src' +import {Command, Interfaces} from '../../src' interface Options { pjson?: any; @@ -177,8 +176,7 @@ describe('Config', () => { commandIds = ['foo:bar', 'foo:baz'], types = [], }: Options = {}) => { - // @ts-ignore - class MyComandClass implements ICommand.Class { + class MyCommandClass extends Command { _base = '' aliases: string[] = [] @@ -187,34 +185,28 @@ describe('Config', () => { id = 'foo:bar' - new(): ICommand.Instance { - return {_run(): Promise { - return Promise.resolve() - }} - } - run(): PromiseLike { return Promise.resolve() } } + const load = async (): Promise => {} - const findCommand = async (): Promise => { - // @ts-ignore - return new MyComandClass() + const findCommand = async (): Promise => { + return MyCommandClass } - const commandPluginA: ICommand.Loadable = { + const commandPluginA: Command.Loadable = { strict: false, - aliases: [], args: [], flags: {}, hidden: false, id: commandIds[0], async load(): Promise { - return new MyComandClass() as unknown as ICommand.Class + aliases: [], args: {}, flags: {}, hidden: false, id: commandIds[0], async load(): Promise { + return MyCommandClass }, pluginType: types[0] ?? 'core', pluginAlias: '@My/plugina', } - const commandPluginB: ICommand.Loadable = { + const commandPluginB: Command.Loadable = { strict: false, - aliases: [], args: [], flags: {}, hidden: false, id: commandIds[1], async load(): Promise { - return new MyComandClass() as unknown as ICommand.Class + aliases: [], args: {}, flags: {}, hidden: false, id: commandIds[1], async load(): Promise { + return MyCommandClass }, pluginType: types[1] ?? 'core', pluginAlias: '@My/pluginb', diff --git a/test/config/fixtures/typescript/src/commands/foo/bar/baz.ts b/test/config/fixtures/typescript/src/commands/foo/bar/baz.ts index a8d06fdaf..70249ded0 100644 --- a/test/config/fixtures/typescript/src/commands/foo/bar/baz.ts +++ b/test/config/fixtures/typescript/src/commands/foo/bar/baz.ts @@ -1,5 +1,5 @@ export const Command = { - run() { + run(): void { console.log('it works!') }, } diff --git a/test/config/fixtures/typescript/src/commands/foo/bar/fail.ts b/test/config/fixtures/typescript/src/commands/foo/bar/fail.ts index 0a60697cf..429022b08 100644 --- a/test/config/fixtures/typescript/src/commands/foo/bar/fail.ts +++ b/test/config/fixtures/typescript/src/commands/foo/bar/fail.ts @@ -1,5 +1,5 @@ export const Command = { - run() { + run(): void { console.log('it fails!') throw new Error('random error') }, diff --git a/test/config/fixtures/typescript/src/commands/foo/bar/test-result.ts b/test/config/fixtures/typescript/src/commands/foo/bar/test-result.ts index a7449ff35..ad24a5732 100644 --- a/test/config/fixtures/typescript/src/commands/foo/bar/test-result.ts +++ b/test/config/fixtures/typescript/src/commands/foo/bar/test-result.ts @@ -1,5 +1,5 @@ export const Command = { - run() { + run(): string { console.log('it works!') return 'returned success!' }, diff --git a/test/config/fixtures/typescript/src/hooks/init.ts b/test/config/fixtures/typescript/src/hooks/init.ts index 541b97a2c..bca0e3722 100644 --- a/test/config/fixtures/typescript/src/hooks/init.ts +++ b/test/config/fixtures/typescript/src/hooks/init.ts @@ -1,3 +1,3 @@ -export function init() { +export function init(): void { console.log('running ts init hook') } diff --git a/test/config/fixtures/typescript/src/hooks/postrun.ts b/test/config/fixtures/typescript/src/hooks/postrun.ts index be765f289..e88ef9687 100644 --- a/test/config/fixtures/typescript/src/hooks/postrun.ts +++ b/test/config/fixtures/typescript/src/hooks/postrun.ts @@ -1,4 +1,5 @@ -export default function postrun(options: any) { +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export default function postrun(options: any): void { console.log('running ts postrun hook') if (options.Command.id === 'foo:bar:test-result') { console.log(options.result) diff --git a/test/config/fixtures/typescript/src/hooks/prerun.ts b/test/config/fixtures/typescript/src/hooks/prerun.ts index 60afddd3d..f616ee831 100644 --- a/test/config/fixtures/typescript/src/hooks/prerun.ts +++ b/test/config/fixtures/typescript/src/hooks/prerun.ts @@ -1,3 +1,3 @@ -export default function prerun() { +export default function prerun(): void { console.log('running ts prerun hook') } diff --git a/src/help/_test-help-class.ts b/test/help/_test-help-class.ts similarity index 70% rename from src/help/_test-help-class.ts rename to test/help/_test-help-class.ts index 00ad60555..be8391651 100644 --- a/src/help/_test-help-class.ts +++ b/test/help/_test-help-class.ts @@ -3,18 +3,18 @@ // this sample help class file in tests, although it is not needed // for ../help itself. -import {HelpBase} from '.' +import {HelpBase} from '../../src' export default class extends HelpBase { - async showHelp() { + async showHelp(): Promise { console.log('help') } - async showCommandHelp() { + async showCommandHelp(): Promise { console.log('command help') } - getCommandHelpForReadme() { + getCommandHelpForReadme(): string { return 'help for readme' } } diff --git a/test/help/docopts.test.ts b/test/help/docopts.test.ts index 1b997886b..12eaca7ff 100644 --- a/test/help/docopts.test.ts +++ b/test/help/docopts.test.ts @@ -27,7 +27,7 @@ describe('doc opts', () => { }) it('shows no short char', () => { const usage = DocOpts.generate({flags: { - testFlag: Flags.enum({ + testFlag: Flags.string({ name: 'testFlag', description: 'test', options: ['a', 'b'], diff --git a/test/help/fixtures/fixtures.ts b/test/help/fixtures/fixtures.ts index 393e8df15..3a4497f29 100644 --- a/test/help/fixtures/fixtures.ts +++ b/test/help/fixtures/fixtures.ts @@ -10,13 +10,11 @@ export class AppsCreate extends Command { static description = 'this only shows up in command help under DESCRIPTION'; - static disableJsonFlag = true; - static flags = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -29,9 +27,9 @@ export class AppsDestroy extends Command { static flags: Record = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -43,9 +41,9 @@ export class AppsIndex extends Command { static flags: Record = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -58,9 +56,9 @@ this only shows up in command help under DESCRIPTION`; static flags: Record = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -85,9 +83,9 @@ export class AppsAdminIndex extends Command { static flags: Record = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -100,9 +98,9 @@ export class AppsAdminAdd extends Command { static flags: Record = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } @@ -117,9 +115,9 @@ export class DbCreate extends Command { static flags = {}; - static args = []; + static args = {}; - async run() { + async run(): Promise { 'run' } } diff --git a/test/help/format-command-with-options.test.ts b/test/help/format-command-with-options.test.ts index bcd942d1b..5554567e5 100644 --- a/test/help/format-command-with-options.test.ts +++ b/test/help/format-command-with-options.test.ts @@ -1,14 +1,12 @@ import {expect, test as base} from '@oclif/test' -import {Command as Base, Flags as flags} from '../../src' +import {Args, Command as Base, Flags as flags} from '../../src' import {commandHelp, TestHelpWithOptions as TestHelp} from './help-test-utils' const g: any = global g.oclif.columns = 80 class Command extends Base { - static disableJsonFlag = true - async run() { return null } @@ -29,7 +27,10 @@ describe('formatCommand', () => { static description = `first line multiline help` - static args = [{name: 'app_name', description: 'app to use'}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -76,7 +77,10 @@ ALIASES static aliases = ['app:init', 'create'] - static args = [{name: 'app_name', description: 'app to use'.repeat(35)}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'.repeat(35)}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -122,7 +126,10 @@ ALIASES static aliases = ['app:init', 'create'] - static args = [{name: 'app_name', description: 'app to use'.repeat(35)}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'.repeat(35)}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -178,7 +185,10 @@ ALIASES static aliases = ['app:init', 'create'] - static args = [{name: 'app_name', description: 'app to use'}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'}), + } static flags = { force: flags.boolean({description: 'forces'}), @@ -234,11 +244,11 @@ OPTIONS .commandHelp(class extends Command { static id = 'apps:create' - static args = [ - {name: 'arg1', default: '.'}, - {name: 'arg2', default: '.', description: 'arg2 desc'}, - {name: 'arg3', description: 'arg3 desc'}, - ] + static args = { + arg1: Args.string({default: '.'}), + arg2: Args.string({default: '.', description: 'arg2 desc'}), + arg3: Args.string({description: 'arg3 desc'}), + } static flags = { flag1: flags.string({default: '.'}), @@ -279,9 +289,9 @@ OPTIONS .commandHelp(class extends Command { static id = 'apps:create' - static args = [ - {name: 'arg1', description: 'Show the options', options: ['option1', 'option2']}, - ] + static args = { + arg1: Args.string({description: 'Show the options', options: ['option1', 'option2']}), + } }) .it('outputs with arg options', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif apps:create [ARG1] diff --git a/test/help/format-command.test.ts b/test/help/format-command.test.ts index 53ff6a57f..3b401412d 100644 --- a/test/help/format-command.test.ts +++ b/test/help/format-command.test.ts @@ -1,6 +1,6 @@ import {expect, test as base} from '@oclif/test' -import {Command as Base, Flags as flags} from '../../src' +import {Args, Command as Base, Flags as flags} from '../../src' import {commandHelp, TestHelp} from './help-test-utils' const g: any = global @@ -30,7 +30,10 @@ multiline help` static enableJsonFlag = true - static args = [{name: 'app_name', description: 'app to use'}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -84,7 +87,10 @@ ALIASES static enableJsonFlag = true - static args = [{name: 'app_name', description: 'app to use'.repeat(35)}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'.repeat(35)}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -138,7 +144,10 @@ ALIASES static enableJsonFlag = true - static args = [{name: 'app_name', description: 'app to use'.repeat(35)}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'.repeat(35)}), + } static flags = { app: flags.string({char: 'a', hidden: true}), @@ -229,7 +238,10 @@ DESCRIPTION static enableJsonFlag = true - static args = [{name: 'app_name', description: 'app to use'}] + static args = { + // eslint-disable-next-line camelcase + app_name: Args.string({description: 'app to use'}), + } static flags = { force: flags.boolean({description: 'forces'}), @@ -261,8 +273,6 @@ ALIASES static id = 'apps:create' static description = 'root part of the description\n\nThe <%= config.bin %> CLI has <%= command.id %>' - - static disableJsonFlag = true }) .it('renders template string from description', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif apps:create @@ -277,8 +287,6 @@ DESCRIPTION static id = 'apps:create' static description = 'root part of the description\r\n\nusing both carriage \n\nreturn and new line' - - static disableJsonFlag = true }) .it('splits on carriage return and new lines', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif apps:create @@ -297,8 +305,6 @@ DESCRIPTION .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { myenum: flags.string({ description: 'the description', @@ -317,8 +323,6 @@ FLAGS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { myenum: flags.string({ options: myEnumValues, @@ -336,13 +340,11 @@ FLAGS .commandHelp(class extends Command { static id = 'apps:create' - static args = [ - {name: 'arg1', default: '.'}, - {name: 'arg2', default: '.', description: 'arg2 desc'}, - {name: 'arg3', description: 'arg3 desc'}, - ] - - static disableJsonFlag = true + static args = { + arg1: Args.string({default: '.'}), + arg2: Args.string({default: '.', description: 'arg2 desc'}), + arg3: Args.string({description: 'arg3 desc'}), + } static flags = { flag1: flags.string({default: '.'}), @@ -367,8 +369,6 @@ FLAGS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { opt: flags.boolean({allowNo: true}), } @@ -383,8 +383,6 @@ FLAGS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { opt: flags.string({ summary: 'one line summary', @@ -408,8 +406,6 @@ FLAG DESCRIPTIONS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { opt: flags.string({ summary: 'one line summary', @@ -432,8 +428,6 @@ FLAG DESCRIPTIONS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - static flags = { opt: flags.string({ summary: 'one line summary'.repeat(15), @@ -466,11 +460,9 @@ FLAG DESCRIPTIONS .commandHelp(class extends Command { static id = 'apps:create' - static disableJsonFlag = true - - static args = [ - {name: 'arg1', description: 'Show the options', options: ['option1', 'option2']}, - ] + static args = { + arg1: Args.string({description: 'Show the options', options: ['option1', 'option2']}), + } }) .it('outputs with arg options', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif apps:create [ARG1] @@ -485,8 +477,6 @@ ARGUMENTS static id = 'apps:create' static usage = '<%= config.bin %> <%= command.id %> usage' - - static disableJsonFlag = true }) .it('outputs usage with templates', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif oclif apps:create usage`)) @@ -506,8 +496,6 @@ ARGUMENTS static id = 'apps:create' static usage = undefined - - static disableJsonFlag = true }) .it('defaults usage when not specified', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif apps:create`)) @@ -517,8 +505,6 @@ ARGUMENTS test .commandHelp(class extends Command { static examples = ['it handles a list of examples', 'more example text'] - - static disableJsonFlag = true }) .it('outputs multiple examples', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif @@ -531,8 +517,6 @@ EXAMPLES test .commandHelp(class extends Command { static examples = ['it handles a single example'] - - static disableJsonFlag = true }) .it('outputs a single example', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif @@ -545,8 +529,6 @@ EXAMPLES static id = 'oclif:command' static examples = ['the bin is <%= config.bin %>', 'the command id is <%= command.id %>'] - - static disableJsonFlag = true }) .it('outputs examples using templates', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE $ oclif oclif:command diff --git a/test/help/format-commands.test.ts b/test/help/format-commands.test.ts index 1ba0c21fe..eb59c9ce0 100644 --- a/test/help/format-commands.test.ts +++ b/test/help/format-commands.test.ts @@ -1,4 +1,4 @@ -import {Command, Interfaces} from '../../src' +import {Command} from '../../src' import {expect, test as base} from '@oclif/test' import stripAnsi = require('strip-ansi') @@ -9,12 +9,12 @@ import {AppsDestroy, AppsCreate} from './fixtures/fixtures' // extensions to expose method as public for testing class TestHelp extends Help { - public formatCommands(commands: Interfaces.Command[]) { + public formatCommands(commands: Command.Class[]) { return super.formatCommands(commands) } } -const formatCommands = (commands: Interfaces.Command[]) => ({ +const formatCommands = (commands: Command.Class[]) => ({ run(ctx: {help: TestHelp; output: string}) { const help = ctx.help.formatCommands(commands) if (process.env.TEST_OUTPUT === '1') { @@ -49,8 +49,6 @@ describe('formatCommand', () => { static description = 'This is a very long command description that should wrap after too many characters have been entered' - static args = [] - async run() { 'run' } diff --git a/test/help/help-test-utils.ts b/test/help/help-test-utils.ts index 956632b51..4f636b272 100644 --- a/test/help/help-test-utils.ts +++ b/test/help/help-test-utils.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import {Command} from '../../src/command' import stripAnsi = require('strip-ansi') import {Interfaces, toCached} from '../../src' @@ -23,14 +25,14 @@ export class TestHelpWithOptions extends Help { this.opts.hideCommandSummaryInDescription = true } - public formatCommand(command: Interfaces.Command) { + public formatCommand(command: Command.Class) { return super.formatCommand(command) } } // extensions to expose method as public for testing export class TestHelp extends Help { - public formatCommand(command: Interfaces.Command) { + public formatCommand(command: Command.Class | Command.Loadable | Command.Cached) { return super.formatCommand(command) } @@ -81,7 +83,7 @@ export const topicHelp = (topic: Interfaces.Topic) => ({ }, }) -export function monkeyPatchCommands(config: any, plugins: Array<{commands: Interfaces.Command[], topics: Interfaces.Topic[]}>) { +export function monkeyPatchCommands(config: any, plugins: Array<{commands: Command.Class[], topics: Interfaces.Topic[]}>) { config.plugins = plugins config._commands = new Map() config._topics = new Map() diff --git a/test/help/show-customized-help.test.ts b/test/help/show-customized-help.test.ts index 23b731c28..965963af5 100644 --- a/test/help/show-customized-help.test.ts +++ b/test/help/show-customized-help.test.ts @@ -4,7 +4,7 @@ import * as path from 'path' import {CommandHelp, Help} from '../../src/help' import {AppsIndexWithDesc, AppsDestroy, AppsCreate, AppsTopic, AppsAdminTopic, AppsAdminAdd} from './fixtures/fixtures' -import {Interfaces, Config} from '../../src' +import {Interfaces, Config, Command} from '../../src' import {monkeyPatchCommands} from './help-test-utils' const g: any = global @@ -38,7 +38,7 @@ class TestHelp extends Help { this.opts.usageHeader = 'SYNOPSIS' } - summary(c: Interfaces.Command): string { + summary(c: Command.Class): string { // This will essentially ignore the summary return this.wrap(c.description || '') } diff --git a/test/help/util.test.ts b/test/help/util.test.ts index 8108fa4d9..d5c289b65 100644 --- a/test/help/util.test.ts +++ b/test/help/util.test.ts @@ -2,7 +2,7 @@ import {resolve} from 'path' import {Config, Interfaces} from '../../src' import {test} from '@oclif/test' import {loadHelpClass, standardizeIDFromArgv} from '../../src/help' -import configuredHelpClass from '../../src/help/_test-help-class' +import configuredHelpClass from './_test-help-class' import {expect} from 'chai' describe('util', () => { @@ -26,7 +26,7 @@ describe('util', () => { test .it('loads help class defined in pjson.oclif.helpClass', async () => { - config.pjson.oclif.helpClass = '../src/help/_test-help-class' + config.pjson.oclif.helpClass = '../test/help/_test-help-class' config.root = resolve(__dirname, '..') expect(configuredHelpClass).to.not.be.undefined diff --git a/test/integration/util.ts b/test/integration/util.ts index f7f7a053e..5b68f2440 100644 --- a/test/integration/util.ts +++ b/test/integration/util.ts @@ -30,7 +30,6 @@ function updatePkgJson(testDir: string, obj: Record): void { } export class Executor { - // eslint-disable-next-line no-useless-constructor public constructor(private testDir: string) {} public executeInTestDir(cmd: string, silent = true): Promise { diff --git a/test/interfaces/args.test.ts b/test/interfaces/args.test.ts new file mode 100644 index 000000000..89627a51c --- /dev/null +++ b/test/interfaces/args.test.ts @@ -0,0 +1,111 @@ +/** + * This test file contains no unit tests but we use the tsd package to ensure that the types are valid when the tests are compiled + */ +import {Command, Args, Interfaces} from '../../src' +import {expectType, expectNotType} from 'tsd' +import {URL} from 'url' + +type MyArgs = Interfaces.InferredArgs + +type MyType = { + foo: boolean; +} + +class MyCommand extends Command { + static description = 'describe the command here' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + ] + + static args = { + requiredString: Args.string({required: true}), + optionalString: Args.string(), + defaultString: Args.string({default: 'default'}), + + requiredBoolean: Args.boolean({required: true}), + optionalBoolean: Args.boolean(), + defaultBoolean: Args.boolean({default: true}), + + optionalInteger: Args.integer(), + requiredInteger: Args.integer({required: true}), + defaultInteger: Args.integer({default: 1}), + + optionalDirectory: Args.directory(), + requiredDirectory: Args.directory({required: true}), + defaultDirectory: Args.directory({default: 'my-dir'}), + + optionalFile: Args.file(), + requiredFile: Args.file({required: true}), + defaultFile: Args.file({default: 'my-file.json'}), + + optionalUrl: Args.url(), + requiredUrl: Args.url({required: true}), + defaultUrl: Args.url({default: new URL('http://example.com')}), + + optionalCustom: Args.custom({ + parse: async () => ({foo: true}), + })(), + requiredCustom: Args.custom({ + parse: async () => ({foo: true}), + })({required: true}), + defaultCustom: Args.custom({ + parse: async () => ({foo: true}), + })({default: {foo: true}}), + } + + public args!: MyArgs + + public async run(): Promise { + const result = await this.parse(MyCommand) + this.args = result.args + expectType(this.args) + + expectType(this.args.requiredString) + expectNotType(this.args.requiredString) + + expectType(this.args.defaultString) + expectNotType(this.args.defaultString) + + expectType(this.args.optionalString) + + expectType(this.args.requiredBoolean) + expectNotType(this.args.requiredBoolean) + expectType(this.args.defaultBoolean) + expectNotType(this.args.defaultBoolean) + expectType(this.args.optionalBoolean) + + expectType(this.args.requiredInteger) + expectNotType(this.args.requiredInteger) + expectType(this.args.defaultInteger) + expectNotType(this.args.defaultInteger) + expectType(this.args.optionalInteger) + + expectType(this.args.requiredDirectory) + expectNotType(this.args.requiredDirectory) + expectType(this.args.defaultDirectory) + expectNotType(this.args.defaultDirectory) + expectType(this.args.optionalDirectory) + + expectType(this.args.requiredFile) + expectNotType(this.args.requiredFile) + expectType(this.args.defaultFile) + expectNotType(this.args.defaultFile) + expectType(this.args.optionalFile) + + expectType(this.args.requiredUrl) + expectNotType(this.args.requiredUrl) + expectType(this.args.defaultUrl) + expectNotType(this.args.defaultUrl) + expectType(this.args.optionalUrl) + + expectType(this.args.requiredCustom) + expectNotType(this.args.requiredCustom) + expectType(this.args.defaultCustom) + expectNotType(this.args.defaultCustom) + expectType(this.args.optionalCustom) + + return result.args + } +} + diff --git a/test/interfaces/flags.test.ts b/test/interfaces/flags.test.ts index cca3c8375..2c9d6ff2e 100644 --- a/test/interfaces/flags.test.ts +++ b/test/interfaces/flags.test.ts @@ -2,29 +2,21 @@ * This test file contains no unit tests but we use the tsd package to ensure that the types are valid when the tests are compiled */ -import Command from '../../src/command' -import * as Flags from '../../src/flags' -import * as Interfaces from '../../src/interfaces' +import {Command, Flags, Interfaces} from '../../src' import {expectType, expectNotType} from 'tsd' import {URL} from 'url' abstract class BaseCommand extends Command { static enableJsonFlag = true - static globalFlags = { + static baseFlags = { optionalGlobalFlag: Flags.string(), requiredGlobalFlag: Flags.string({required: true}), defaultGlobalFlag: Flags.string({default: 'default'}), } } -type MyFlags = Interfaces.InferredFlags - -enum MyEnum { - 'A' = 'A', - 'B' = 'B', - 'C' = 'C', -} +type MyFlags = Interfaces.InferredFlags type MyType = { foo: boolean; @@ -44,24 +36,16 @@ class MyCommand extends BaseCommand { requiredMultiString: Flags.string({required: true, multiple: true}), optionalMultiString: Flags.string({multiple: true}), - defaultMultiString: Flags.string({multiple: true, default: ['default']}), + defaultMultiString: Flags.string({ + multiple: true, + default: ['default'], + defaultHelp: async _ctx => 'defaultHelp', + }), requiredBoolean: Flags.boolean({required: true}), optionalBoolean: Flags.boolean(), defaultBoolean: Flags.boolean({default: true}), - optionalEnum: Flags.enum({options: ['a', 'b', 'c']}), - requiredEnum: Flags.enum({options: ['a', 'b', 'c'], required: true}), - defaultEnum: Flags.enum({options: ['a', 'b', 'c'], default: 'a'}), - - optionalMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c']}), - requiredMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c'], required: true}), - defaultMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c'], default: ['a']}), - - optionalTypedEnum: Flags.enum({options: Object.values(MyEnum)}), - requiredTypedEnum: Flags.enum({options: Object.values(MyEnum), required: true}), - defaultTypedEnum: Flags.enum({options: Object.values(MyEnum), default: MyEnum.A}), - optionalInteger: Flags.integer(), requiredInteger: Flags.integer({required: true}), defaultInteger: Flags.integer({default: 1}), @@ -88,22 +72,15 @@ class MyCommand extends BaseCommand { optionalUrl: Flags.url(), requiredUrl: Flags.url({required: true}), - defaultUrl: Flags.url({default: new URL('http://example.com')}), + defaultUrl: Flags.url({ + default: new URL('http://example.com'), + defaultHelp: async _ctx => 'Example URL', + }), optionalMultiUrl: Flags.url({multiple: true}), requiredMultiUrl: Flags.url({multiple: true, required: true}), defaultMultiUrl: Flags.url({multiple: true, default: [new URL('http://example.com')]}), - optionalBuild: Flags.build({ - parse: async () => ({foo: true}), - })(), - requiredBuild: Flags.build({ - parse: async () => ({foo: true}), - })({required: true}), - defaultBuild: Flags.build({ - parse: async () => ({foo: true}), - })({default: {foo: true}}), - optionalCustom: Flags.custom({ parse: async () => ({foo: true}), })(), @@ -112,6 +89,7 @@ class MyCommand extends BaseCommand { })({required: true}), defaultCustom: Flags.custom({ parse: async () => ({foo: true}), + default: async _ctx => ({foo: true}), })({default: {foo: true}}), optionalMultiCustom: Flags.custom({ @@ -159,24 +137,6 @@ class MyCommand extends BaseCommand { expectNotType(this.flags.defaultBoolean) expectType(this.flags.optionalBoolean) - expectType(this.flags.requiredEnum) - expectNotType(this.flags.requiredEnum) - expectType(this.flags.defaultEnum) - expectNotType(this.flags.defaultEnum) - expectType(this.flags.optionalEnum) - - expectType(this.flags.requiredMultiEnum) - expectNotType(this.flags.requiredMultiEnum) - expectType(this.flags.defaultMultiEnum) - expectNotType(this.flags.defaultMultiEnum) - expectType(this.flags.optionalMultiEnum) - - expectType(this.flags.requiredTypedEnum) - expectNotType(this.flags.requiredTypedEnum) - expectType(this.flags.defaultTypedEnum) - expectNotType(this.flags.defaultTypedEnum) - expectType(this.flags.optionalTypedEnum) - expectType(this.flags.requiredInteger) expectNotType(this.flags.requiredInteger) expectType(this.flags.defaultInteger) @@ -225,12 +185,6 @@ class MyCommand extends BaseCommand { expectNotType(this.flags.defaultMultiUrl) expectType(this.flags.optionalMultiUrl) - expectType(this.flags.requiredBuild) - expectNotType(this.flags.requiredBuild) - expectType(this.flags.defaultBuild) - expectNotType(this.flags.defaultBuild) - expectType(this.flags.optionalBuild) - expectType(this.flags.requiredCustom) expectNotType(this.flags.requiredCustom) expectType(this.flags.defaultCustom) diff --git a/test/parser/help.test.ts b/test/parser/help.test.ts index 671e091e6..b2884712a 100644 --- a/test/parser/help.test.ts +++ b/test/parser/help.test.ts @@ -1,7 +1,7 @@ import {expect} from 'chai' import stripAnsi = require('strip-ansi') -import * as flags from '../../src/parser/flags' +import * as flags from '../../src/flags' import {flagUsages} from '../../src/parser/help' describe('flagUsage', () => { diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts index 9179c6dba..3a179b0ba 100644 --- a/test/parser/parse.test.ts +++ b/test/parser/parse.test.ts @@ -2,10 +2,10 @@ import {assert, expect} from 'chai' import * as fs from 'fs' -import {flags, parse} from '../../src/parser' -import {Interfaces} from '../../src' +import {parse} from '../../src/parser' +import {Args, Flags} from '../../src' +import {FlagDefault} from '../../src/interfaces/parser' import {URL} from 'url' -import {directory, file} from '../../src/parser/flags' import * as sinon from 'sinon' import {CLIError} from '../../src/errors' @@ -15,7 +15,7 @@ describe('parse', () => { it('--bool', async () => { const out = await parse(['--bool'], { flags: { - bool: flags.boolean(), + bool: Flags.boolean(), }, }) expect(out).to.deep.include({flags: {bool: true}}) @@ -23,7 +23,7 @@ describe('parse', () => { it('arg1', async () => { const out = await parse(['arg1'], { - args: [{name: 'foo'}], + args: {foo: Args.string()}, }) expect(out.argv).to.deep.equal(['arg1']) expect(out.args).to.deep.equal({foo: 'arg1'}) @@ -31,7 +31,7 @@ describe('parse', () => { it('arg1 arg2', async () => { const out = await parse(['arg1', 'arg2'], { - args: [{name: 'foo'}, {name: 'bar'}], + args: {foo: Args.string(), bar: Args.string()}, }) expect(out.argv).to.deep.equal(['arg1', 'arg2']) expect(out.args).to.deep.equal({foo: 'arg1', bar: 'arg2'}) @@ -51,7 +51,7 @@ describe('parse', () => { it('--bool', async () => { const out = await parse(['--bool'], { flags: { - bool: flags.boolean(), + bool: Flags.boolean(), }, }) expect(out.raw[0]).to.deep.include({flag: 'bool'}) @@ -59,15 +59,15 @@ describe('parse', () => { it('arg1', async () => { const out = await parse(['arg1'], { - args: [{name: 'foo'}], + args: {foo: Args.string()}, }) expect(out.raw[0]).to.have.property('input', 'arg1') }) it('parses args and flags', async () => { const out = await parse(['foo', '--myflag', 'bar', 'baz'], { - args: [{name: 'myarg'}, {name: 'myarg2'}], - flags: {myflag: flags.string()}, + args: {myarg: Args.string(), myarg2: Args.string()}, + flags: {myflag: Flags.string()}, }) expect(out.argv[0]).to.equal('foo') expect(out.argv[1]).to.equal('baz') @@ -77,7 +77,7 @@ describe('parse', () => { describe('flags', () => { it('parses flags', async () => { const out = await parse(['--myflag', '--myflag2'], { - flags: {myflag: flags.boolean(), myflag2: flags.boolean()}, + flags: {myflag: Flags.boolean(), myflag2: Flags.boolean()}, }) expect(Boolean(out.flags.myflag)).to.equal(true) expect(Boolean(out.flags.myflag2)).to.equal(true) @@ -86,8 +86,8 @@ describe('parse', () => { it('parses short flags', async () => { const out = await parse(['-mf'], { flags: { - force: flags.boolean({char: 'f'}), - myflag: flags.boolean({char: 'm'}), + force: Flags.boolean({char: 'f'}), + myflag: Flags.boolean({char: 'm'}), }, }) expect(Boolean(out.flags.myflag)).to.equal(true) @@ -97,7 +97,7 @@ describe('parse', () => { it('parses flag value with "=" to separate', async () => { const out = await parse(['--myflag=foo'], { flags: { - myflag: flags.string({char: 'm'}), + myflag: Flags.string({char: 'm'}), }, }) expect(out.flags).to.deep.equal({myflag: 'foo'}) @@ -106,7 +106,7 @@ describe('parse', () => { it('parses flag value with "=" in value', async () => { const out = await parse(['--myflag', '=foo'], { flags: { - myflag: flags.string({char: 'm'}), + myflag: Flags.string({char: 'm'}), }, }) expect(out.flags).to.deep.equal({myflag: '=foo'}) @@ -115,7 +115,7 @@ describe('parse', () => { it('parses short flag value with "="', async () => { const out = await parse(['-m=foo'], { flags: { - myflag: flags.string({char: 'm'}), + myflag: Flags.string({char: 'm'}), }, }) expect(out.flags).to.deep.equal({myflag: 'foo'}) @@ -124,7 +124,7 @@ describe('parse', () => { it('parses value of ""', async () => { const out = await parse(['-m', ''], { flags: { - myflag: flags.string({char: 'm'}), + myflag: Flags.string({char: 'm'}), }, }) expect(out.flags).to.deep.equal({myflag: ''}) @@ -135,7 +135,7 @@ describe('parse', () => { try { await parse([], { flags: { - myflag: flags.string({ + myflag: Flags.string({ description: 'flag description', required: true, }), @@ -152,8 +152,8 @@ describe('parse', () => { it('removes flags from argv', async () => { const out = await parse(['--myflag', 'bar', 'foo'], { - args: [{name: 'myarg'}], - flags: {myflag: flags.string()}, + args: {myarg: Args.string()}, + flags: {myflag: Flags.string()}, }) expect(out.flags).to.deep.equal({myflag: 'bar'}) expect(out.argv).to.deep.equal(['foo']) @@ -164,19 +164,11 @@ describe('parse', () => { let message = '' try { await parse(['arg1'], { - args: [ - {name: 'arg1', required: true}, - { - description: 'arg2 desc', - name: 'arg2', - required: true, - }, - { - description: 'arg3 desc', - name: 'arg3', - required: true, - }, - ], + args: { + arg1: Args.string({required: true}), + arg2: Args.string({required: true, description: 'arg2 desc'}), + arg3: Args.string({required: true, description: 'arg3 desc'}), + }, }) } catch (error: any) { message = error.message @@ -192,7 +184,9 @@ See more help with --help`) let message = '' try { await parse(['arg1', 'arg2'], { - args: [{name: 'arg1', required: true}], + args: { + arg1: Args.string({required: true}), + }, }) } catch (error: any) { message = error.message @@ -203,37 +197,41 @@ See more help with --help`) it('parses args', async () => { const out = await parse(['foo', 'bar'], { - args: [{name: 'myarg'}, {name: 'myarg2'}], + args: {myarg: Args.string(), myarg2: Args.string()}, }) expect(out.argv).to.deep.equal(['foo', 'bar']) }) it('skips optional args', async () => { const out = await parse(['foo'], { - args: [{name: 'myarg'}, {name: 'myarg2'}], + args: {myarg: Args.string(), myarg2: Args.string()}, }) expect(out.argv).to.deep.equal(['foo']) }) it('skips non-required args', async () => { const out = await parse(['foo'], { - args: [ - {name: 'myarg', required: false}, - {name: 'myarg2', required: false}, - ], + args: {myarg: Args.string(), myarg2: Args.string()}, }) expect(out.argv).to.deep.equal(['foo']) }) - it('parses something looking like a flag as an arg', async () => { - const out = await parse(['--foo'], { - args: [{name: 'myarg'}], - }) - expect(out.argv).to.deep.equal(['--foo']) + it('throws an error when parsing a non-existent flag', async () => { + try { + await parse(['arg', '--foo'], { + args: { + myArg: Args.string(), + }, + }) + assert.fail('should have thrown') + } catch (error) { + const err = error as Error + expect(err.message).to.include('Nonexistent flag: --foo') + } }) it('parses - as an arg', async () => { const out = await parse(['-'], { - args: [{name: 'myarg'}], + args: {myarg: Args.string()}, }) expect(out.argv).to.deep.equal(['-']) }) @@ -244,10 +242,10 @@ See more help with --help`) let message = '' try { await parse([], { - args: [ - {name: 'arg1', required: true}, - {name: 'arg2', required: false, default: 'some_default'}, - ], + args: { + arg1: Args.string({required: true}), + arg2: Args.string({required: false, default: 'some_default'}), + }, }) } catch (error: any) { message = error.message @@ -260,10 +258,10 @@ See more help with --help`) it('two args: only first is required, only first has a default', async () => { await parse([], { - args: [ - {name: 'arg1', required: true, default: 'my_default'}, - {name: 'arg2', required: false}, - ], + args: { + arg1: Args.string({required: true, default: 'my_default'}), + arg2: Args.string({required: false}), + }, }) // won't reach here if thrown expect(() => {}).to.not.throw() @@ -271,10 +269,10 @@ See more help with --help`) it('two args: both have a default, only first is required', async () => { await parse([], { - args: [ - {name: 'arg1', required: true, default: 'my_default'}, - {name: 'arg2', required: false, default: 'some_default'}, - ], + args: { + arg1: Args.string({required: true, default: 'my_default'}), + arg2: Args.string({required: false, default: 'some_default'}), + }, }) // won't reach here if thrown expect(() => {}).to.not.throw() @@ -286,10 +284,10 @@ See more help with --help`) let message = '' try { await parse([], { - args: [ - {name: 'arg1', required: false}, - {name: 'arg2', required: true, default: 'some_default'}, - ], + args: { + arg1: Args.string({required: false}), + arg2: Args.string({required: true, default: 'some_default'}), + }, }) } catch (error: any) { message = error.message @@ -305,12 +303,12 @@ See more help with --help`) let message = '' try { await parse([], { - args: [ - {name: 'arg1', required: false}, - {name: 'arg2', required: false, default: 'my_default'}, - {name: 'arg3', required: false}, - {name: 'arg4', required: true}, - ], + args: { + arg1: Args.string({required: false}), + arg2: Args.string({required: false, default: 'my_default'}), + arg3: Args.string({required: false}), + arg4: Args.string({required: true}), + }, }) } catch (error: any) { message = error.message @@ -329,9 +327,9 @@ See more help with --help`) it('parses multiple flags', async () => { const out = await parse(['--bar', 'a', '--bar=b', '--foo=c', '--baz=d'], { flags: { - foo: flags.string(), - bar: flags.string({multiple: true, required: true}), - baz: flags.string({required: true}), + foo: Flags.string(), + bar: Flags.string({multiple: true, required: true}), + baz: Flags.string({required: true}), }, }) expect(out.flags.foo!.toUpperCase()).to.equal('C') @@ -341,7 +339,7 @@ See more help with --help`) it('parses multiple flags on custom flags', async () => { const out = await parse(['--foo', 'a', '--foo=b'], { flags: { - foo: flags.option({multiple: true, parse: async i => i}), + foo: Flags.custom({multiple: true, parse: async i => i})(), }, }) expect(out.flags).to.deep.include({foo: ['a', 'b']}) @@ -351,8 +349,8 @@ See more help with --help`) describe('strict: false', () => { it('skips flag parsing after "--"', async () => { const out = await parse(['foo', 'bar', '--', '--myflag'], { - args: [{name: 'argOne'}], - flags: {myflag: flags.boolean()}, + args: {argOne: Args.string()}, + flags: {myflag: Flags.boolean()}, strict: false, }) expect(out.argv).to.deep.equal(['foo', 'bar', '--myflag']) @@ -362,10 +360,11 @@ See more help with --help`) describe('--: false', () => { it('can be disabled', async () => { const out = await parse(['foo', 'bar', '--', '--myflag'], { - args: [{name: 'argOne'}], + args: {argOne: Args.string()}, strict: false, '--': false, }) + console.log(out) expect(out.argv).to.deep.equal(['foo', 'bar', '--', '--myflag']) expect(out.args).to.deep.equal({argOne: 'foo'}) }) @@ -374,29 +373,34 @@ See more help with --help`) it('does not repeat arguments', async () => { const out = await parse(['foo', '--myflag=foo bar'], { strict: false, + flags: { + myflag: Flags.string(), + }, }) - expect(out.argv).to.deep.equal(['foo', '--myflag=foo bar']) + + expect(out.argv).to.deep.equal(['foo']) + expect(out.flags).to.deep.equal({myflag: 'foo bar'}) }) }) describe('integer flag', () => { it('parses integers', async () => { const out = await parse(['--int', '100'], { - flags: {int: flags.integer(), s: flags.string()}, + flags: {int: Flags.integer(), s: Flags.string()}, }) expect(out.flags).to.deep.include({int: 100}) }) it('parses zero', async () => { const out = await parse(['--int', '0'], { - flags: {int: flags.integer(), s: flags.string()}, + flags: {int: Flags.integer(), s: Flags.string()}, }) expect(out.flags).to.deep.include({int: 0}) }) it('parses negative integers', async () => { const out = await parse(['--int', '-123'], { - flags: {int: flags.integer(), s: flags.string()}, + flags: {int: Flags.integer(), s: Flags.string()}, }) expect(out.flags).to.deep.include({int: -123}) }) @@ -405,7 +409,7 @@ See more help with --help`) let message = '' try { await parse(['--int', '3.14'], { - flags: {int: flags.integer()}, + flags: {int: Flags.integer()}, }) } catch (error: any) { message = error.message @@ -418,7 +422,7 @@ See more help with --help`) let message = '' try { await parse(['--int', '3/4'], { - flags: {int: flags.integer()}, + flags: {int: Flags.integer()}, }) } catch (error: any) { message = error.message @@ -431,7 +435,7 @@ See more help with --help`) let message = '' try { await parse(['--int', 's10'], { - flags: {int: flags.integer()}, + flags: {int: Flags.integer()}, }) } catch (error: any) { message = error.message @@ -443,25 +447,25 @@ See more help with --help`) describe('min/max', () => { it('min pass equal', async () => { const out = await parse(['--int', '10'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) expect(out.flags).to.deep.include({int: 10}) }) it('min pass gt', async () => { const out = await parse(['--int', '11'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) expect(out.flags).to.deep.include({int: 11}) }) it('max pass lt', async () => { const out = await parse(['--int', '19'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) expect(out.flags).to.deep.include({int: 19}) }) it('max pass equal', async () => { const out = await parse(['--int', '20'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) expect(out.flags).to.deep.include({int: 20}) }) @@ -470,7 +474,7 @@ See more help with --help`) let message = '' try { await parse(['--int', '9'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) } catch (error: any) { message = error.message @@ -482,7 +486,7 @@ See more help with --help`) let message = '' try { await parse(['--int', '21'], { - flags: {int: flags.integer({min: 10, max: 20})}, + flags: {int: Flags.integer({min: 10, max: 20})}, }) } catch (error: any) { message = error.message @@ -500,7 +504,7 @@ See more help with --help`) const validateEvenNumberString = async (input:string) => Number.parseInt(input, 10) % 2 === 0 ? Number.parseInt(input, 10) : assert.fail(customParseException) it('accepts custom parse that passes', async () => { const out = await parse([`--int=${testIntPass}`], { - flags: {int: flags.integer({parse: validateEvenNumberString})}, + flags: {int: Flags.integer({parse: validateEvenNumberString})}, }) expect(out.flags).to.deep.include({int: testIntPass}) }) @@ -508,7 +512,7 @@ See more help with --help`) it('accepts custom parse that fails', async () => { try { const out = await parse([`--int=${testIntFail}`], { - flags: {int: flags.integer({parse: validateEvenNumberString})}, + flags: {int: Flags.integer({parse: validateEvenNumberString})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -520,16 +524,11 @@ See more help with --help`) }) }) - it('--no-color', async () => { - const out = await parse(['--no-color'], {}) - expect(out.flags).to.deep.include({color: false}) - }) - describe('parse', () => { it('parse', async () => { const out = await parse(['--foo=bar', '100'], { - args: [{name: 'num', parse: async i => Number.parseInt(i, 10)}], - flags: {foo: flags.string({parse: async input => input.toUpperCase()})}, + args: {num: Args.integer()}, + flags: {foo: Flags.string({parse: async input => input.toUpperCase()})}, }) expect(out.flags).to.deep.include({foo: 'BAR'}) expect(out.args).to.deep.include({num: 100}) @@ -538,7 +537,7 @@ See more help with --help`) it('parse with a default does not parse default', async () => { const out = await parse([], { - flags: {foo: flags.string({parse: async input => input.toUpperCase(), default: 'baz'})}, + flags: {foo: Flags.string({parse: async input => input.toUpperCase(), default: 'baz'})}, }) expect(out.flags).to.deep.include({foo: 'baz'}) }) @@ -553,7 +552,7 @@ See more help with --help`) it('uses default via value', async () => { const out = await parse([], { flags: { - foo: flags.build({ + foo: Flags.custom({ parse: async input => new TestClass(input), default: new TestClass('baz'), })(), @@ -564,7 +563,7 @@ See more help with --help`) it('uses default via function', async () => { const out = await parse([], { flags: { - foo: flags.build({ + foo: Flags.custom({ parse: async input => new TestClass(input), default: async () => new TestClass('baz'), })(), @@ -575,7 +574,7 @@ See more help with --help`) it('uses parser when value provided', async () => { const out = await parse(['--foo=bar'], { flags: { - foo: flags.build({ + foo: Flags.custom({ parse: async input => new TestClass(input), default: new TestClass('baz'), })(), @@ -589,7 +588,7 @@ See more help with --help`) // const out = await parse({ // args: [{ name: 'num', parse: (_, ctx) => ctx.arg.name!.toUpperCase() }], // argv: ['--foo=bar', '100'], - // flags: { foo: flags.string({ parse: (_, ctx) => ctx.flag.name.toUpperCase() }) }, + // flags: { foo: string({ parse: (_, ctx) => ctx.flag.name.toUpperCase() }) }, // }) // expect(out.flags).to.deep.include({ foo: 'FOO' }) // expect(out.args).to.deep.include({ num: 'NUM' }) @@ -599,7 +598,7 @@ See more help with --help`) describe('flag with multiple inputs', () => { it('flag multiple with flag in the middle', async () => { const out = await parse(['--foo=bar', '--foo', '100', '--hello', 'world'], { - flags: {foo: flags.string({multiple: true}), hello: flags.string()}, + flags: {foo: Flags.string({multiple: true}), hello: Flags.string()}, }) expect(out.flags).to.deep.include({foo: ['bar', '100']}) expect(out.flags).to.deep.include({hello: 'world'}) @@ -610,8 +609,8 @@ See more help with --help`) ['--foo', './a.txt', './b.txt', './c.txt', '--hello', 'world'], { flags: { - foo: flags.string({multiple: true}), - hello: flags.string(), + foo: Flags.string({multiple: true}), + hello: Flags.string(), }, }, ) @@ -625,8 +624,8 @@ See more help with --help`) const out = await parse( ['--foo', './a.txt', './b.txt', './c.txt', '--', '15'], { - args: [{name: 'num'}], - flags: {foo: flags.string({multiple: true})}, + args: {num: Args.string()}, + flags: {foo: Flags.string({multiple: true})}, }, ) expect(out.flags).to.deep.include({ @@ -634,19 +633,22 @@ See more help with --help`) }) expect(out.args).to.deep.include({num: '15'}) }) - it('flag multiple with arguments, custom parser', async () => { + it('flag multiple with arguments and custom delimiter and parser', async () => { const out = await parse( ['--foo', './a.txt,./b.txt', '--foo', './c.txt', '--', '15'], { - args: [{name: 'num'}], - flags: {foo: flags.string({ - multiple: true, - parse: async input => input.split(',').map(i => i.trim()), - })}, + args: {num: Args.string()}, + flags: { + foo: Flags.string({ + multiple: true, + delimiter: ',', + parse: async input => input.replace('.txt', '.json'), + }), + }, }, ) expect(out.flags).to.deep.include({ - foo: ['./a.txt', './b.txt', './c.txt'], + foo: ['./a.json', './b.json', './c.json'], }) expect(out.args).to.deep.include({num: '15'}) }) @@ -656,10 +658,10 @@ See more help with --help`) it('generates metadata for defaults', async () => { const out = await parse(['-n', 'heroku'], { flags: { - name: flags.string({ + name: Flags.string({ char: 'n', }), - startup: flags.string({ + startup: Flags.string({ char: 's', default: 'apero', }), @@ -672,8 +674,8 @@ See more help with --help`) it('defaults', async () => { const out = await parse([], { - args: [{name: 'baz', default: 'BAZ'}], - flags: {foo: flags.string({default: 'bar'})}, + args: {baz: Args.string({default: 'BAZ'})}, + flags: {foo: Flags.string({default: 'bar'})}, }) expect(out.args).to.deep.include({baz: 'BAZ'}) expect(out.argv).to.deep.equal(['BAZ']) @@ -682,15 +684,15 @@ See more help with --help`) it('accepts falsy', async () => { const out = await parse([], { - args: [{name: 'baz', default: false}], + args: {baz: Args.boolean({default: false})}, }) expect(out.args).to.deep.include({baz: false}) }) it('default as function', async () => { const out = await parse([], { - args: [{name: 'baz', default: () => 'BAZ'}], - flags: {foo: flags.string({default: async () => 'bar'})}, + args: {baz: Args.string({default: async () => 'BAZ'})}, + flags: {foo: Flags.string({default: async () => 'bar'})}, }) expect(out.args).to.deep.include({baz: 'BAZ'}) expect(out.argv).to.deep.equal(['BAZ']) @@ -698,25 +700,22 @@ See more help with --help`) }) it('default has options', async () => { - const def: Interfaces.Default = async ({options}) => + const def: FlagDefault = async ({options}) => options.description const out = await parse([], { - // args: [{ name: 'baz', default: () => 'BAZ' }], - flags: {foo: flags.string({description: 'bar', default: def})}, + flags: {foo: Flags.string({description: 'bar', default: def})}, }) - // expect(out.args).to.deep.include({ baz: 'BAZ' }) - // expect(out.argv).to.deep.include(['BAZ']) expect(out.flags).to.deep.include({foo: 'bar'}) }) it('can default to a different flag', async () => { - const def: Interfaces.Default = async opts => opts.flags.foo + const def: FlagDefault = async opts => opts.flags.foo const out = await parse(['--foo=bar'], { flags: { - bar: flags.string({ + bar: Flags.string({ default: def, }), - foo: flags.string(), + foo: Flags.string(), }, }) expect(out.flags).to.deep.include({foo: 'bar', bar: 'bar'}) @@ -727,7 +726,7 @@ See more help with --help`) it('default is true', async () => { const out = await parse([], { flags: { - color: flags.boolean({default: true}), + color: Flags.boolean({default: true}), }, }) expect(out).to.deep.include({flags: {color: true}}) @@ -736,7 +735,7 @@ See more help with --help`) it('default is false', async () => { const out = await parse([], { flags: { - color: flags.boolean({default: false}), + color: Flags.boolean({default: false}), }, }) expect(out).to.deep.include({flags: {color: false}}) @@ -745,7 +744,7 @@ See more help with --help`) it('default as function', async () => { const out = await parse([], { flags: { - color: flags.boolean({default: async () => true}), + color: Flags.boolean({default: async () => true}), }, }) expect(out).to.deep.include({flags: {color: true}}) @@ -754,7 +753,7 @@ See more help with --help`) it('overridden true default', async () => { const out = await parse(['--no-color'], { flags: { - color: flags.boolean({allowNo: true, default: true}), + color: Flags.boolean({allowNo: true, default: true}), }, }) expect(out).to.deep.include({flags: {color: false}}) @@ -763,7 +762,7 @@ See more help with --help`) it('overridden false default', async () => { const out = await parse(['--color'], { flags: { - color: flags.boolean({default: false}), + color: Flags.boolean({default: false}), }, }) expect(out).to.deep.include({flags: {color: true}}) @@ -772,7 +771,7 @@ See more help with --help`) describe('custom option', () => { it('can pass parse fn', async () => { - const foo = flags.option({char: 'f', parse: async () => 100}) + const foo = Flags.custom({char: 'f', parse: async () => 100})() const out = await parse(['-f', 'bar'], { flags: {foo}, }) @@ -782,14 +781,14 @@ See more help with --help`) describe('build', () => { it('can pass parse fn', async () => { - const foo = flags.build({char: 'f', parse: async () => 100}) + const foo = Flags.custom({char: 'f', parse: async () => 100}) const out = await parse(['-f', 'bar'], { flags: {foo: foo()}, }) expect(out.flags).to.deep.include({foo: 100}) }) it('does not require parse fn', async () => { - const foo = flags.build({char: 'f'}) + const foo = Flags.custom({char: 'f'}) const out = await parse(['-f', 'bar'], { flags: {foo: foo()}, }) @@ -800,7 +799,7 @@ See more help with --help`) describe('flag options', () => { it('accepts valid option', async () => { const out = await parse(['--foo', 'myotheropt'], { - flags: {foo: flags.string({options: ['myopt', 'myotheropt']})}, + flags: {foo: Flags.string({options: ['myopt', 'myotheropt']})}, }) expect(out.flags.foo).to.equal('myotheropt') }) @@ -809,7 +808,7 @@ See more help with --help`) let message = '' try { await parse(['--foo', 'invalidopt'], { - flags: {foo: flags.string({options: ['myopt', 'myotheropt']})}, + flags: {foo: Flags.string({options: ['myopt', 'myotheropt']})}, }) } catch (error: any) { message = error.message @@ -822,7 +821,7 @@ See more help with --help`) process.env.TEST_FOO = 'invalidopt' try { await parse([], { - flags: {foo: flags.string({options: ['myopt', 'myotheropt'], env: 'TEST_FOO'})}, + flags: {foo: Flags.string({options: ['myopt', 'myotheropt'], env: 'TEST_FOO'})}, }) } catch (error: any) { message = error.message @@ -835,7 +834,7 @@ See more help with --help`) process.env.TEST_FOO = 'myopt' const out = await parse([], { - flags: {foo: flags.string({options: ['myopt', 'myotheropt'], env: 'TEST_FOO'})}, + flags: {foo: Flags.string({options: ['myopt', 'myotheropt'], env: 'TEST_FOO'})}, }) expect(out.flags.foo).to.equal('myopt') }) @@ -844,7 +843,7 @@ See more help with --help`) describe('url flag', () => { it('accepts valid url', async () => { const out = await parse(['--foo', 'https://example.com'], { - flags: {foo: flags.url()}, + flags: {foo: Flags.url()}, }) expect(out.flags.foo).to.be.instanceOf(URL) expect(out.flags.foo?.href).to.equal('https://example.com/') @@ -854,7 +853,7 @@ See more help with --help`) let message = '' try { await parse(['--foo', 'example'], { - flags: {foo: flags.url()}, + flags: {foo: Flags.url()}, }) } catch (error: any) { message = error.message @@ -867,7 +866,7 @@ See more help with --help`) describe('arg options', () => { it('accepts valid option', async () => { const out = await parse(['myotheropt'], { - args: [{name: 'foo', options: ['myopt', 'myotheropt']}], + args: {foo: Args.string({options: ['myopt', 'myotheropt']})}, }) expect(out.args.foo).to.equal('myotheropt') }) @@ -876,7 +875,7 @@ See more help with --help`) let message = '' try { await parse(['invalidopt'], { - args: [{name: 'foo', options: ['myopt', 'myotheropt']}], + args: {foo: Args.string({options: ['myopt', 'myotheropt']})}, }) } catch (error: any) { message = error.message @@ -891,7 +890,7 @@ See more help with --help`) it('accepts as environment variable', async () => { process.env.TEST_FOO = '101' const out = await parse([], { - flags: {foo: flags.string({env: 'TEST_FOO'})}, + flags: {foo: Flags.string({env: 'TEST_FOO'})}, }) expect(out.flags.foo).to.equal('101') delete process.env.TEST_FOO @@ -905,7 +904,7 @@ See more help with --help`) process.env.TEST_FOO = value const out = await parse([], { flags: { - foo: flags.boolean({env: 'TEST_FOO'}), + foo: Flags.boolean({env: 'TEST_FOO'}), }, }) expect(out.flags.foo).to.be.true @@ -919,7 +918,7 @@ See more help with --help`) process.env.TEST_FOO = value const out = await parse([], { flags: { - foo: flags.boolean({env: 'TEST_FOO'}), + foo: Flags.boolean({env: 'TEST_FOO'}), }, }) expect(out.flags.foo).to.be.false @@ -931,7 +930,7 @@ See more help with --help`) delete process.env.TEST_FOO const out = await parse([], { flags: { - foo: flags.boolean({env: 'TEST_FOO'}), + foo: Flags.boolean({env: 'TEST_FOO'}), }, }) expect(out.flags.foo).to.be.undefined @@ -941,7 +940,7 @@ See more help with --help`) delete process.env.TEST_FOO const out = await parse([], { flags: { - foo: flags.boolean({env: 'TEST_FOO', default: true}), + foo: Flags.boolean({env: 'TEST_FOO', default: true}), }, }) expect(out.flags.foo).to.be.true @@ -952,9 +951,10 @@ See more help with --help`) describe('flag context', () => { it('accepts context in parse', async () => { const out = await parse(['--foo'], { + // @ts-expect-error context: {a: 101}, flags: { - foo: flags.boolean({ + foo: Flags.boolean({ parse: async (_: any, ctx: any) => ctx.a, }), }, @@ -965,7 +965,7 @@ See more help with --help`) it('parses multiple flags', async () => { const out = await parse(['--foo=a', '--foo', 'b'], { - flags: {foo: flags.string()}, + flags: {foo: Flags.string()}, }) expect(out.flags.foo).to.equal('b') }) @@ -974,8 +974,8 @@ See more help with --help`) it('ignores', async () => { await parse([], { flags: { - foo: flags.string({dependsOn: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({dependsOn: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) }) @@ -983,8 +983,8 @@ See more help with --help`) it('succeeds', async () => { const out = await parse(['--foo', 'a', '-bb'], { flags: { - foo: flags.string({dependsOn: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({dependsOn: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) expect(out.flags.foo).to.equal('a') @@ -996,8 +996,8 @@ See more help with --help`) try { await parse(['--foo', 'a'], { flags: { - foo: flags.string({dependsOn: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({dependsOn: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) } catch (error: any) { @@ -1012,8 +1012,8 @@ See more help with --help`) it('ignores', async () => { await parse([], { flags: { - foo: flags.string({exclusive: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({exclusive: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) }) @@ -1021,8 +1021,8 @@ See more help with --help`) it('succeeds', async () => { const out = await parse(['--foo', 'a'], { flags: { - foo: flags.string({exclusive: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({exclusive: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) expect(out.flags.foo).to.equal('a') @@ -1033,8 +1033,8 @@ See more help with --help`) try { await parse(['--foo', 'a', '-bb'], { flags: { - foo: flags.string({exclusive: ['bar']}), - bar: flags.string({char: 'b'}), + foo: Flags.string({exclusive: ['bar']}), + bar: Flags.string({char: 'b'}), }, }) } catch (error: any) { @@ -1051,8 +1051,8 @@ See more help with --help`) try { await parse([], { flags: { - foo: flags.string({exactlyOne: ['bar', 'foo']}), - bar: flags.string({char: 'b', exactlyOne: ['bar', 'foo']}), + foo: Flags.string({exactlyOne: ['bar', 'foo']}), + bar: Flags.string({char: 'b', exactlyOne: ['bar', 'foo']}), }, }) } catch (error: any) { @@ -1067,8 +1067,8 @@ See more help with --help`) try { await parse(['--foo', 'a', '--bar', 'b'], { flags: { - foo: flags.string({exactlyOne: ['bar']}), - bar: flags.string({char: 'b', exactlyOne: ['foo']}), + foo: Flags.string({exactlyOne: ['bar']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo']}), }, }) } catch (error: any) { @@ -1081,9 +1081,9 @@ See more help with --help`) it('succeeds if exactly one', async () => { const out = await parse(['--foo', 'a', '--else', '4'], { flags: { - foo: flags.string({exactlyOne: ['bar']}), - bar: flags.string({char: 'b', exactlyOne: ['foo']}), - else: flags.string({char: 'e'}), + foo: Flags.string({exactlyOne: ['bar']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo']}), + else: Flags.string({char: 'e'}), }, }) expect(out.flags.foo).to.equal('a') @@ -1092,9 +1092,9 @@ See more help with --help`) it('succeeds if exactly one (the other option)', async () => { const out = await parse(['--bar', 'b', '--else', '4'], { flags: { - foo: flags.string({exactlyOne: ['bar']}), - bar: flags.string({char: 'b', exactlyOne: ['foo']}), - else: flags.string({char: 'e'}), + foo: Flags.string({exactlyOne: ['bar']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo']}), + else: Flags.string({char: 'e'}), }, }) expect(out.flags.bar).to.equal('b') @@ -1103,9 +1103,9 @@ See more help with --help`) it('succeeds if exactly one of three', async () => { const out = await parse(['--bar', 'b'], { flags: { - foo: flags.string({exactlyOne: ['bar', 'else']}), - bar: flags.string({char: 'b', exactlyOne: ['foo', 'else']}), - else: flags.string({char: 'e', exactlyOne: ['foo', 'bar']}), + foo: Flags.string({exactlyOne: ['bar', 'else']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo', 'else']}), + else: Flags.string({char: 'e', exactlyOne: ['foo', 'bar']}), }, }) expect(out.flags.bar).to.equal('b') @@ -1114,9 +1114,9 @@ See more help with --help`) it('lets user list flag in its own list', async () => { const out = await parse(['--bar', 'b'], { flags: { - foo: flags.string({exactlyOne: ['foo', 'bar', 'else']}), - bar: flags.string({char: 'b', exactlyOne: ['foo', 'bar', 'else']}), - else: flags.string({char: 'e', exactlyOne: ['foo', 'bar', 'else']}), + foo: Flags.string({exactlyOne: ['foo', 'bar', 'else']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo', 'bar', 'else']}), + else: Flags.string({char: 'e', exactlyOne: ['foo', 'bar', 'else']}), }, }) expect(out.flags.bar).to.equal('b') @@ -1127,9 +1127,9 @@ See more help with --help`) try { await parse(['--foo', 'a', '--else', '4'], { flags: { - foo: flags.string({exactlyOne: ['bar', 'else']}), - bar: flags.string({char: 'b', exactlyOne: ['foo', 'else']}), - else: flags.string({char: 'e', exactlyOne: ['foo', 'bar']}), + foo: Flags.string({exactlyOne: ['bar', 'else']}), + bar: Flags.string({char: 'b', exactlyOne: ['foo', 'else']}), + else: Flags.string({char: 'e', exactlyOne: ['foo', 'bar']}), }, }) } catch (error: any) { @@ -1141,9 +1141,9 @@ See more help with --help`) it('handles cross-references/pairings that don\'t make sense', async () => { const crazyFlags = { - foo: flags.string({exactlyOne: ['bar']}), - bar: flags.string({char: 'b', exactlyOne: ['else']}), - else: flags.string({char: 'e'}), + foo: Flags.string({exactlyOne: ['bar']}), + bar: Flags.string({char: 'b', exactlyOne: ['else']}), + else: Flags.string({char: 'e'}), } let message1 = '' try { @@ -1180,7 +1180,7 @@ See more help with --help`) it('is undefined if not set', async () => { const out = await parse([], { flags: { - foo: flags.boolean({allowNo: true}), + foo: Flags.boolean({allowNo: true}), }, }) expect(out.flags.foo).to.equal(undefined) @@ -1188,7 +1188,7 @@ See more help with --help`) it('is false', async () => { const out = await parse(['--no-foo'], { flags: { - foo: flags.boolean({allowNo: true}), + foo: Flags.boolean({allowNo: true}), }, }) expect(out.flags.foo).to.equal(false) @@ -1196,7 +1196,7 @@ See more help with --help`) it('is true', async () => { const out = await parse(['--foo'], { flags: { - foo: flags.boolean({allowNo: true}), + foo: Flags.boolean({allowNo: true}), }, }) expect(out.flags.foo).to.equal(true) @@ -1221,14 +1221,14 @@ See more help with --help`) const testDir = 'some/dir' it('passes if dir !exists but exists:false', async () => { const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: false})}, + flags: {dir: Flags.directory({exists: false})}, }) expect(existsStub.callCount).to.equal(0) expect(out.flags).to.deep.include({dir: testDir}) }) it('passes if dir !exists but exists not defined', async () => { const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory()}, + flags: {dir: Flags.directory()}, }) expect(existsStub.callCount).to.equal(0) expect(out.flags).to.deep.include({dir: testDir}) @@ -1237,7 +1237,7 @@ See more help with --help`) existsStub.returns(true) statStub.returns({isDirectory: () => true}) const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: true})}, + flags: {dir: Flags.directory({exists: true})}, }) expect(out.flags).to.deep.include({dir: testDir}) }) @@ -1245,7 +1245,7 @@ See more help with --help`) existsStub.returns(false) try { const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: true})}, + flags: {dir: Flags.directory({exists: true})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1260,7 +1260,7 @@ See more help with --help`) statStub.returns({isDirectory: () => false}) try { const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: true})}, + flags: {dir: Flags.directory({exists: true})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1275,7 +1275,7 @@ See more help with --help`) existsStub.returns(true) statStub.returns({isDirectory: () => true}) const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: true, parse: async input => input.includes('some') ? input : assert.fail(customParseException)})}, + flags: {dir: Flags.directory({exists: true, parse: async input => input.includes('some') ? input : assert.fail(customParseException)})}, }) expect(out.flags).to.deep.include({dir: testDir}) }) @@ -1285,7 +1285,7 @@ See more help with --help`) statStub.returns({isDirectory: () => true}) try { const out = await parse([`--dir=${testDir}`], { - flags: {dir: directory({exists: true, parse: async input => input.includes('NOT_THERE') ? input : assert.fail(customParseException)})}, + flags: {dir: Flags.directory({exists: true, parse: async input => input.includes('NOT_THERE') ? input : assert.fail(customParseException)})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1301,14 +1301,14 @@ See more help with --help`) const testFile = 'some/file.ext' it('passes if file doesn\'t exist but not exists:true', async () => { const out = await parse([`--file=${testFile}`], { - flags: {file: file({exists: false})}, + flags: {file: Flags.file({exists: false})}, }) expect(out.flags).to.deep.include({file: testFile}) expect(existsStub.callCount).to.equal(0) }) it('passes if file doesn\'t exist but not exists not defined', async () => { const out = await parse([`--file=${testFile}`], { - flags: {file: file()}, + flags: {file: Flags.file()}, }) expect(out.flags).to.deep.include({file: testFile}) expect(existsStub.callCount).to.equal(0) @@ -1317,7 +1317,7 @@ See more help with --help`) existsStub.returns(true) statStub.returns({isFile: () => true}) const out = await parse([`--file=${testFile}`], { - flags: {file: file({exists: true})}, + flags: {file: Flags.file({exists: true})}, }) expect(out.flags).to.deep.include({file: testFile}) }) @@ -1325,7 +1325,7 @@ See more help with --help`) existsStub.returns(false) try { const out = await parse([`--file=${testFile}`], { - flags: {file: file({exists: true})}, + flags: {file: Flags.file({exists: true})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1338,7 +1338,7 @@ See more help with --help`) statStub.returns({isFile: () => false}) try { const out = await parse([`--file=${testFile}`], { - flags: {file: file({exists: true})}, + flags: {file: Flags.file({exists: true})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1352,7 +1352,7 @@ See more help with --help`) existsStub.returns(true) statStub.returns({isFile: () => true}) const out = await parse([`--dir=${testFile}`], { - flags: {dir: file({exists: false, parse: async input => input.includes('some') ? input : assert.fail(customParseException)})}, + flags: {dir: Flags.file({exists: false, parse: async input => input.includes('some') ? input : assert.fail(customParseException)})}, }) expect(out.flags).to.deep.include({dir: testFile}) }) @@ -1362,7 +1362,7 @@ See more help with --help`) statStub.returns({isFile: () => true}) try { const out = await parse([`--dir=${testFile}`], { - flags: {dir: file({exists: true, parse: async input => input.includes('NOT_THERE') ? input : assert.fail(customParseException)})}, + flags: {dir: Flags.file({exists: true, parse: async input => input.includes('NOT_THERE') ? input : assert.fail(customParseException)})}, }) throw new Error(`Should have thrown an error ${JSON.stringify(out)}`) } catch (error_) { @@ -1379,7 +1379,7 @@ See more help with --help`) it('works with defined name', async () => { const out = await parse(['--foo'], { flags: { - foo: flags.boolean({ + foo: Flags.boolean({ aliases: ['bar'], }), }, @@ -1390,7 +1390,7 @@ See more help with --help`) it('works with aliased name', async () => { const out = await parse(['--bar'], { flags: { - foo: flags.boolean({ + foo: Flags.boolean({ aliases: ['bar'], }), }, diff --git a/test/parser/validate.test.ts b/test/parser/validate.test.ts index 61183bec6..c637dc311 100644 --- a/test/parser/validate.test.ts +++ b/test/parser/validate.test.ts @@ -22,7 +22,7 @@ describe('validate', () => { exclusive: [], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -95,12 +95,9 @@ describe('validate', () => { required: true, }, }, - args: [ - { - name: 'zero', - required: true, - }, - ], + args: { + zero: {required: true}, + }, strict: true, context: {}, '--': true, @@ -129,7 +126,7 @@ describe('validate', () => { required: true, }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -174,7 +171,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -216,7 +213,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -254,7 +251,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -302,7 +299,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -350,7 +347,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -393,7 +390,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -431,7 +428,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -479,7 +476,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -527,7 +524,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -570,7 +567,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -607,7 +604,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -662,7 +659,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -719,7 +716,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, @@ -874,7 +871,7 @@ describe('validate', () => { ], }, }, - args: [], + args: {}, strict: true, context: {}, '--': true, diff --git a/yarn.lock b/yarn.lock index a3cfe6818..e28e9b772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -503,7 +503,7 @@ tslib "^2.4.1" yarn "^1.22.18" -"@oclif/screen@^3.0.3", "@oclif/screen@^3.0.4": +"@oclif/screen@^3.0.3": version "3.0.4" resolved "https://registry.yarnpkg.com/@oclif/screen/-/screen-3.0.4.tgz#663db0ecaf23f3184e7f01886ed578060e4a7f1c" integrity sha512-IMsTN1dXEXaOSre27j/ywGbBjrzx0FNd1XmuhCWCB9NTPrhWI1Ifbz+YLSEcstfQfocYsrbrIessxXb2oon4lA== @@ -753,6 +753,11 @@ resolved "https://registry.yarnpkg.com/@types/supports-color/-/supports-color-8.1.1.tgz#1b44b1b096479273adf7f93c75fc4ecc40a61ee4" integrity sha512-dPWnWsf+kzIG140B8z2w3fr5D03TLWbOAFQl45xUpI3vcizeXriNR5VYkWZ+WTMsUHqZ9Xlt3hrxGNANFyNQfw== +"@types/wordwrap@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/wordwrap/-/wordwrap-1.0.1.tgz#6cfbe1e2ea03a30831453389e5fdec8be40fd178" + integrity sha512-xe+rWyom8xn0laMWH3M7elOpWj2rDQk+3f13RAur89GKsf4FO5qmBNtXXtwepFo2XNgQI0nePdCEStoHFnNvWg== + "@types/wrap-ansi@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" @@ -3414,6 +3419,11 @@ word-wrap@^1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + workerpool@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b"