From 27175d6f0693533b7cfbf57de65da626168d872f Mon Sep 17 00:00:00 2001 From: peternhale Date: Wed, 26 Jan 2022 08:54:33 -0700 Subject: [PATCH] feat: merge cli-ux library with oclif/core (#345) * feat: merge cli-ux library with oclif/core Removes the circlular dependency between oclif/core and cli-ux @W-10255961@ * chore: fix relative paths * feat: merge cli-ux library with oclif/core Removes the circlular dependency between oclif/core and cli-ux * v1.1.2-test-02 * chore: resolve name collision for core/Config and cli-ux/Config * chore: revert to latest version number * chore: fix build error * chore: fix imports in tests * chore: wrap cli-ux exported members in namespace * chore: remove unneeded import * Revert "chore: remove unneeded import" This reverts commit 8d2f1ecf542d522ce403eceac5259166969259c5. * Revert "chore: wrap cli-ux exported members in namespace" This reverts commit 905e5d5a2d8f4542536a9ea561181b5bbf5322c1. * chore: rework cliux namespace export * chore: document migration * chore: apply suggestions from code review Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> * chore: remove unneeded console.log statement * chore: update migration guide * chore: fix up examples * chore: apply review suggestions * chore: adjust exports * chore: abandon use of namesapce * v1.1.2-test-07 * v1.1.2-test-08 * v1.1.2-test-09 * chore: remove use of lodash/castArray * chore: exclude windows latest, see npm/cli#4234 * fix: integration test command in ci config * chore: fix integration test job name Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> Co-authored-by: Rodrigo Espinosa de los Monteros <1084688+RodEsp@users.noreply.github.com> --- .circleci/config.yml | 10 +- .gitignore | 1 + MIGRATION.md | 4 +- README.md | 4 + package.json | 21 +- src/cli-ux/README.md | 328 ++++++++++++++++++++++++ src/cli-ux/action/base.ts | 201 +++++++++++++++ src/cli-ux/action/pride-spinner.ts | 32 +++ src/cli-ux/action/simple.ts | 44 ++++ src/cli-ux/action/spinner.ts | 91 +++++++ src/cli-ux/action/spinners.ts | 372 +++++++++++++++++++++++++++ src/cli-ux/config.ts | 65 +++++ src/cli-ux/deps.ts | 45 ++++ src/cli-ux/exit.ts | 17 ++ src/cli-ux/global.d.ts | 7 + src/cli-ux/index.ts | 155 +++++++++++ src/cli-ux/list.ts | 34 +++ src/cli-ux/open.ts | 88 +++++++ src/cli-ux/prompt.ts | 167 ++++++++++++ src/cli-ux/styled/header.ts | 7 + src/cli-ux/styled/json.ts | 17 ++ src/cli-ux/styled/object.ts | 40 +++ src/cli-ux/styled/progress.ts | 14 + src/cli-ux/styled/table.ts | 384 ++++++++++++++++++++++++++++ src/cli-ux/styled/tree.ts | 40 +++ src/cli-ux/wait.ts | 6 + src/command.ts | 9 +- src/index.ts | 2 + test/cli-ux/export.test.ts | 24 ++ test/cli-ux/fancy.ts | 24 ++ test/cli-ux/helpers/init.js | 3 + test/cli-ux/index.test.ts | 24 ++ test/cli-ux/prompt.test.ts | 75 ++++++ test/cli-ux/styled/object.test.ts | 19 ++ test/cli-ux/styled/progress.test.ts | 27 ++ test/cli-ux/styled/table.test.ts | 321 +++++++++++++++++++++++ test/cli-ux/styled/tree.test.ts | 24 ++ test/helpers/init.js | 1 + test/integration/sf.e2e.ts | 19 +- tsconfig.json | 3 +- yarn.lock | 206 +++++---------- 41 files changed, 2811 insertions(+), 164 deletions(-) create mode 100644 src/cli-ux/README.md create mode 100644 src/cli-ux/action/base.ts create mode 100644 src/cli-ux/action/pride-spinner.ts create mode 100644 src/cli-ux/action/simple.ts create mode 100644 src/cli-ux/action/spinner.ts create mode 100644 src/cli-ux/action/spinners.ts create mode 100644 src/cli-ux/config.ts create mode 100644 src/cli-ux/deps.ts create mode 100644 src/cli-ux/exit.ts create mode 100644 src/cli-ux/global.d.ts create mode 100644 src/cli-ux/index.ts create mode 100644 src/cli-ux/list.ts create mode 100644 src/cli-ux/open.ts create mode 100644 src/cli-ux/prompt.ts create mode 100644 src/cli-ux/styled/header.ts create mode 100644 src/cli-ux/styled/json.ts create mode 100644 src/cli-ux/styled/object.ts create mode 100644 src/cli-ux/styled/progress.ts create mode 100644 src/cli-ux/styled/table.ts create mode 100644 src/cli-ux/styled/tree.ts create mode 100644 src/cli-ux/wait.ts create mode 100644 test/cli-ux/export.test.ts create mode 100644 test/cli-ux/fancy.ts create mode 100644 test/cli-ux/helpers/init.js create mode 100644 test/cli-ux/index.test.ts create mode 100644 test/cli-ux/prompt.test.ts create mode 100644 test/cli-ux/styled/object.test.ts create mode 100644 test/cli-ux/styled/progress.test.ts create mode 100644 test/cli-ux/styled/table.test.ts create mode 100644 test/cli-ux/styled/tree.test.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 12f808dec..2a4ed5c07 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,8 +18,13 @@ workflows: - latest - lts - maintenance + exclude: + - os: windows + node_version: latest alias: unit-tests - release-management/test-package: + name: release-management/test-package-yarn test:e2e-<< matrix.node_version >>-<< matrix.os >> + command: yarn test:e2e matrix: parameters: os: @@ -29,8 +34,9 @@ workflows: - latest - lts - maintenance - command: - - yarn test:e2e + exclude: + - os: windows + node_version: latest alias: integration-tests - release-management/release-package: context: SF-CLI-RELEASE-PROCESS diff --git a/.gitignore b/.gitignore index bb0a634b4..54209a862 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /node_modules /tmp /test/tmp +.DS_Store diff --git a/MIGRATION.md b/MIGRATION.md index 76b9d34ef..cbf1e32e5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -10,13 +10,13 @@ Replace imports from the old libraries with `@oclif/core`. For example, ```typescript import Help from '@oclif/plugin-help'; import {Topic} from '@oclif/config'; -import {Command, flags} from '@oclif/command' +import {Command, Flags} from '@oclif/command' ``` With this import: ```typescript -import {Command, flags, Topic, Help} from '@oclif/core'; +import {Command, Flags, Topic, Help} from '@oclif/core'; ``` ## Update your bin scirpts diff --git a/README.md b/README.md index b471adf65..5e21aa549 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ Migrating If you're migrating from the old oclif libraries (`@oclif/config`, `@oclif/command`, `@oclif/error`, `@oclif/parser`), see the [migration guide](./MIGRATION.md). +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. + Usage ===== diff --git a/package.json b/package.json index 6c40a5e0e..3324120a8 100644 --- a/package.json +++ b/package.json @@ -6,19 +6,31 @@ "bugs": "https://github.com/oclif/core/issues", "dependencies": { "@oclif/linewrap": "^1.0.0", + "@oclif/screen": "^3.0.2", + "ansi-escapes": "^4.3.0", + "ansi-styles": "^4.2.0", + "cardinal": "^2.1.1", "chalk": "^4.1.2", "clean-stack": "^3.0.1", - "cli-ux": "^6.0.6", + "cli-progress": "^3.10.0", "debug": "^4.3.3", "ejs": "^3.1.6", "fs-extra": "^9.1.0", "get-package-type": "^0.1.0", "globby": "^11.0.4", + "hyperlinker": "^1.0.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", + "js-yaml": "^3.13.1", + "lodash": "^4.17.21", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.4", + "password-prompt": "^1.1.2", "semver": "^7.3.5", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", "tslib": "^2.3.1", "widest-line": "^3.1.0", "wrap-ansi": "^7.0.0" @@ -28,12 +40,16 @@ "@oclif/plugin-help": "^5.1.7", "@oclif/plugin-plugins": "^2.0.8", "@oclif/test": "^1.2.8", + "@types/ansi-styles": "^3.2.1", "@types/chai": "^4.2.22", "@types/chai-as-promised": "^7.1.4", "@types/clean-stack": "^2.1.1", + "@types/cli-progress": "^3.9.2", "@types/ejs": "^3.1.0", "@types/fs-extra": "^9.0.13", "@types/indent-string": "^4.0.1", + "@types/js-yaml": "^3.12.1", + "@types/lodash": "^4.14.117", "@types/mocha": "^8.2.3", "@types/nock": "^11.1.0", "@types/node": "^15.14.9", @@ -42,6 +58,7 @@ "@types/semver": "^7.3.9", "@types/shelljs": "^0.8.10", "@types/strip-ansi": "^5.2.1", + "@types/supports-color": "^8.1.1", "@types/wrap-ansi": "^3.0.0", "chai": "^4.3.4", "chai-as-promised": "^7.1.1", @@ -59,7 +76,7 @@ "shx": "^0.3.3", "sinon": "^11.1.2", "ts-node": "^9.1.1", - "typescript": "4.5.2" + "typescript": "4.4.4" }, "resolutions": { "@oclif/command": "1.8.9", diff --git a/src/cli-ux/README.md b/src/cli-ux/README.md new file mode 100644 index 000000000..c2c362e04 --- /dev/null +++ b/src/cli-ux/README.md @@ -0,0 +1,328 @@ +# 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 IO utilities + +# Usage + +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?') +``` + +JavaScript: + +```javascript +const {CliUx} = require('@oclif/core') + +CliUx.ux.prompt('What is your name?') +``` + +# CliUx.ux.prompt() + +Prompt for user input. + +```typescript +// just prompt for input +await CliUx.ux.prompt('What is your name?') + +// mask input after enter is pressed +await CliUx.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'}) + +// yes/no confirmation +await CliUx.ux.confirm('Continue?') + +// "press any key to continue" +await CliUx.ux.anykey() +``` + +![prompt demo](assets/prompt.gif) + +# CliUx.ux.url(text, uri) + +Create a hyperlink (if supported in the terminal) + +```typescript +await CliUx.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 + +Shows a spinner + +```typescript +// start the spinner +CliUx.ux.action.start('starting a process') +// show on stdout instead of stderr +CliUx.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' +``` + +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 + +Shows an iterm annotation + +```typescript +CliUx.ux.annotation('sometext', 'annotated with this text') +``` + +![annotation demo](assets/annotation.png) + +# CliUx.ux.wait + +Waits for 1 second or given milliseconds + +```typescript +await CliUx.ux.wait() +await CliUx.ux.wait(3000) +``` + +# CliUx.ux.table + +Displays tabular data + +```typescript +CliUx.ux.table(data, columns, options) +``` + +Where: + +- `data`: array of data objects to display +- `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. + +```typescript +{ + columns: Flags.string({exclusive: ['additional'], description: 'only show provided columns (comma-separated)'}), + sort: Flags.string({description: 'property to sort by (prepend '-' for descending)'}), + filter: Flags.string({description: 'filter property by partial string matching, ex: name=foo'}), + csv: Flags.boolean({exclusive: ['no-truncate'], description: 'output is csv format'}), + extended: Flags.boolean({char: 'x', description: 'show extra columns'}), + 'no-truncate': Flags.boolean({exclusive: ['csv'], description: 'do not truncate output to fit screen'}), + 'no-header': Flags.boolean({exclusive: ['csv'], description: 'hide table header from output'}), +} +``` + +Passing `{only: ['columns']}` or `{except: ['columns']}` as an argument into `CliUx.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 = { + // where `.name` is a property of a data object + name: {}, // "Name" inferred as the column header + id: { + header: 'ID', // override column header + minWidth: '10', // column must display at this width or greater + extended: true, // only display this column when the --extended flag is present + get: row => `US-O1-${row.id}`, // custom getter for data row object + }, +} +``` + +`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 = { + printLine: myLogger, // custom logger + columns: flags.columns, + sort: flags.sort, + filter: flags.filter, + csv: flags.csv, + extended: flags.extended, + 'no-truncate': flags['no-truncate'], + 'no-header': flags['no-header'], +} +``` + +Example class: + +```typescript +import {Command, CliUx} from '@oclif/core' +import axios from 'axios' + +export default class Users extends Command { + static flags = { + ...CliUx.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, { + name: { + minWidth: 7, + }, + company: { + get: row => row.company && row.company.name + }, + id: { + header: 'ID', + extended: true + } + }, { + printLine: this.log, + ...flags, // parsed flags + }) + } +} +``` + +Displays: + +```shell +$ example-cli users +Name Company +Leanne Graham Romaguera-Crona +Ervin Howell Deckow-Crist +Clementine Bauch Romaguera-Jacobson +Patricia Lebsack Robel-Corkery +Chelsey Dietrich Keebler LLC +Mrs. Dennis Schulist Considine-Lockman +Kurtis Weissnat Johns Group +Nicholas Runolfsdottir V Abernathy Group +Glenna Reichert Yost and Sons +Clementina DuBuque Hoeger LLC + +$ example-cli users --extended +Name Company ID +Leanne Graham Romaguera-Crona 1 +Ervin Howell Deckow-Crist 2 +Clementine Bauch Romaguera-Jacobson 3 +Patricia Lebsack Robel-Corkery 4 +Chelsey Dietrich Keebler LLC 5 +Mrs. Dennis Schulist Considine-Lockman 6 +Kurtis Weissnat Johns Group 7 +Nicholas Runolfsdottir V Abernathy Group 8 +Glenna Reichert Yost and Sons 9 +Clementina DuBuque Hoeger LLC 10 + +$ example-cli users --columns=name +Name +Leanne Graham +Ervin Howell +Clementine Bauch +Patricia Lebsack +Chelsey Dietrich +Mrs. Dennis Schulist +Kurtis Weissnat +Nicholas Runolfsdottir V +Glenna Reichert +Clementina DuBuque + +$ example-cli users --filter="company=Group" +Name Company +Kurtis Weissnat Johns Group +Nicholas Runolfsdottir V Abernathy Group + +$ example-cli users --sort=company +Name Company +Nicholas Runolfsdottir V Abernathy Group +Mrs. Dennis Schulist Considine-Lockman +Ervin Howell Deckow-Crist +Clementina DuBuque Hoeger LLC +Kurtis Weissnat Johns Group +Chelsey Dietrich Keebler LLC +Patricia Lebsack Robel-Corkery +Leanne Graham Romaguera-Crona +Clementine Bauch Romaguera-Jacobson +Glenna Reichert Yost and Sons +``` + +# CliUx.ux.tree + +Generate a tree and display it + +```typescript +let tree = CliUx.ux.tree() +tree.insert('foo') +tree.insert('bar') + +let subtree = CliUx.ux.tree() +subtree.insert('qux') +tree.nodes.bar.insert('baz', subtree) + +tree.display() +``` + +Outputs: +```shell +├─ foo +└─ bar + └─ baz + └─ qux +``` + +# CliUx.ux.progress + +Generate a customizable progress bar and display it + +```typescript +const simpleBar = CliUx.ux.progress() +simpleBar.start() + +const customBar = CliUx.ux.progress({ + format: 'PROGRESS | {bar} | {value}/{total} Files', + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + }) +customBar.start() +``` + +Outputs: +```shell +bar1: +progress [=====================-------------------] 53% | ETA: 1s | 53/100 +bar2: +PROGRESS | █████████████████████████████░░░░░░░░░░░ | 146/204 Files +``` + +To see a more detailed example, run +```shell script +$ ts-node examples/progress.ts +``` + +This example extends [cli-progress](https://www.npmjs.com/package/cli-progress). +See the cli-progress module for all the options and the customizations that can be passed in with the options object. +Only the single bar variant of cli-progress is currently supported. + + diff --git a/src/cli-ux/action/base.ts b/src/cli-ux/action/base.ts new file mode 100644 index 000000000..b2b141915 --- /dev/null +++ b/src/cli-ux/action/base.ts @@ -0,0 +1,201 @@ +import {inspect} from 'util' +import {castArray} from '../../util' + +export interface ITask { + action: string; + status: string | undefined; + active: boolean; +} + +export type ActionType = 'spinner' | 'simple' | 'debug' + +export interface Options { + stdout?: boolean; +} + +export class ActionBase { + type!: ActionType + + std: 'stdout' | 'stderr' = 'stderr' + + protected stdmocks?: ['stdout' | 'stderr', string[]][] + + private stdmockOrigs = { + stdout: process.stdout.write, + stderr: process.stderr.write, + } + + public start(action: string, status?: string, opts: Options = {}) { + this.std = opts.stdout ? 'stdout' : 'stderr' + const task = {action, status, active: Boolean(this.task && this.task.active)} + this.task = task + + this._start() + task.active = true + this._stdout(true) + } + + public stop(msg = 'done') { + const task = this.task + if (!task) { + return + } + + this._stop(msg) + task.active = false + this.task = undefined + this._stdout(false) + } + + private get globals(): { action: { task?: ITask }; output: string | undefined } { + (global as any)['cli-ux'] = (global as any)['cli-ux'] || {} + const globals = (global as any)['cli-ux'] + globals.action = globals.action || {} + return globals + } + + public get task(): ITask | undefined { + return this.globals.action.task + } + + public set task(task: ITask | undefined) { + this.globals.action.task = task + } + + protected get output(): string | undefined { + return this.globals.output + } + + protected set output(output: string | undefined) { + this.globals.output = output + } + + get running(): boolean { + return Boolean(this.task) + } + + get status(): string | undefined { + return this.task ? this.task.status : undefined + } + + set status(status: string | undefined) { + const task = this.task + if (!task) { + return + } + + if (task.status === status) { + return + } + + this._updateStatus(status, task.status) + task.status = status + } + + public async pauseAsync(fn: () => Promise, icon?: string) { + const task = this.task + const active = task && task.active + if (task && active) { + this._pause(icon) + this._stdout(false) + task.active = false + } + + const ret = await fn() + if (task && active) { + this._resume() + } + + return ret + } + + public pause(fn: () => any, icon?: string) { + const task = this.task + const active = task && task.active + if (task && active) { + this._pause(icon) + this._stdout(false) + task.active = false + } + + const ret = fn() + if (task && active) { + this._resume() + } + + return ret + } + + protected _start() { + throw new Error('not implemented') + } + + protected _stop(_: string) { + throw new Error('not implemented') + } + + protected _resume() { + if (this.task) this.start(this.task.action, this.task.status) + } + + protected _pause(_?: string) { + throw new Error('not implemented') + } + + protected _updateStatus(_: string | undefined, __?: string) {} + + // mock out stdout/stderr so it doesn't screw up the rendering + protected _stdout(toggle: boolean) { + try { + const outputs: ['stdout', 'stderr'] = ['stdout', 'stderr'] + if (toggle) { + if (this.stdmocks) return + this.stdmockOrigs = { + stdout: process.stdout.write, + stderr: process.stderr.write, + } + + this.stdmocks = [] + for (const std of outputs) { + (process[std] as any).write = (...args: any[]) => { + this.stdmocks!.push([std, args] as ['stdout' | 'stderr', string[]]) + } + } + } else { + if (!this.stdmocks) return + // this._write('stderr', '\nresetstdmock\n\n\n') + delete this.stdmocks + for (const std of outputs) process[std].write = this.stdmockOrigs[std] as any + } + } catch (error) { + this._write('stderr', inspect(error)) + } + } + + // flush mocked stdout/stderr + protected _flushStdout() { + try { + let output = '' + let std: 'stdout' | 'stderr' | undefined + while (this.stdmocks && this.stdmocks.length > 0) { + const cur = this.stdmocks.shift() as ['stdout' | 'stderr', string[]] + std = cur[0] + this._write(std, cur[1]) + output += (cur[1][0] as any).toString('utf8') + } + // add newline if there isn't one already + // otherwise we'll just overwrite it when we render + + if (output && std && output[output.length - 1] !== '\n') { + this._write(std, '\n') + } + } catch (error) { + this._write('stderr', inspect(error)) + } + } + + // write to the real stdout/stderr + protected _write(std: 'stdout' | 'stderr', s: string | string[]) { + 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 new file mode 100644 index 000000000..28731da96 --- /dev/null +++ b/src/cli-ux/action/pride-spinner.ts @@ -0,0 +1,32 @@ +// tslint:disable restrict-plus-operands + +import * as chalk from 'chalk' +import * as supportsColor from 'supports-color' + +import SpinnerAction from './spinner' + +function color(s: string, frameIndex: number): string { + const prideColors = [ + chalk.keyword('pink'), + chalk.red, + chalk.keyword('orange'), + chalk.yellow, + chalk.green, + chalk.cyan, + chalk.blue, + chalk.magenta, + ] + + if (!supportsColor) return s + const has256 = supportsColor.stdout ? supportsColor.stdout.has256 : (process.env.TERM || '').includes('256') + const prideColor = prideColors[frameIndex] || prideColors[0] + return has256 ? prideColor(s) : chalk.magenta(s) +} + +export default class PrideSpinnerAction extends SpinnerAction { + protected _frame(): string { + const frame = this.frames[this.frameIndex] + this.frameIndex = ++this.frameIndex % this.frames.length + return color(frame, this.frameIndex) + } +} diff --git a/src/cli-ux/action/simple.ts b/src/cli-ux/action/simple.ts new file mode 100644 index 000000000..48722ae32 --- /dev/null +++ b/src/cli-ux/action/simple.ts @@ -0,0 +1,44 @@ +import {ActionBase, ActionType} from './base' + +export default class SimpleAction extends ActionBase { + public type: ActionType = 'simple' + + protected _start() { + const task = this.task + if (!task) return + this._render(task.action, task.status) + } + + protected _pause(icon?: string) { + if (icon) this._updateStatus(icon) + else this._flush() + } + + protected _resume() {} + + protected _updateStatus(status: string, prevStatus?: string, newline = false) { + const task = this.task + if (!task) return + if (task.active && !prevStatus) this._write(this.std, ` ${status}`) + else this._write(this.std, `${task.action}... ${status}`) + if (newline || !prevStatus) this._flush() + } + + protected _stop(status: string) { + const task = this.task + if (!task) return + this._updateStatus(status, task.status, true) + } + + private _render(action: string, status?: string) { + const task = this.task + if (!task) return + if (task.active) this._flush() + this._write(this.std, status ? `${action}... ${status}` : `${action}...`) + } + + private _flush() { + this._write(this.std, '\n') + this._flushStdout() + } +} diff --git a/src/cli-ux/action/spinner.ts b/src/cli-ux/action/spinner.ts new file mode 100644 index 000000000..936d602c2 --- /dev/null +++ b/src/cli-ux/action/spinner.ts @@ -0,0 +1,91 @@ +// tslint:disable restrict-plus-operands + +import * as chalk from 'chalk' +import * as supportsColor from 'supports-color' + +import deps from '../deps' + +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) +} + +export default class SpinnerAction extends ActionBase { + public type: ActionType = 'spinner' + + spinner?: NodeJS.Timeout + + frames: any + + frameIndex: number + + constructor() { + super() + this.frames = spinners[process.platform === 'win32' ? 'line' : 'dots2'].frames + this.frameIndex = 0 + } + + protected _start() { + this._reset() + if (this.spinner) clearInterval(this.spinner) + this._render() + this.spinner = setInterval(icon => + this._render.bind(this)(icon), + process.platform === 'win32' ? 500 : 100, + 'spinner', + ) + const interval = this.spinner + interval.unref() + } + + protected _stop(status: string) { + if (this.task) this.task.status = status + if (this.spinner) clearInterval(this.spinner) + this._render() + this.output = undefined + } + + protected _pause(icon?: string) { + if (this.spinner) clearInterval(this.spinner) + this._reset() + if (icon) this._render(` ${icon}`) + this.output = undefined + } + + protected _frame(): string { + const frame = this.frames[this.frameIndex] + this.frameIndex = ++this.frameIndex % this.frames.length + return color(frame) + } + + private _render(icon?: string) { + const task = this.task + if (!task) return + this._reset() + this._flushStdout() + const frame = icon === 'spinner' ? ` ${this._frame()}` : icon || '' + const status = task.status ? ` ${task.status}` : '' + this.output = `${task.action}...${frame}${status}\n` + this._write(this.std, this.output) + } + + 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.output = undefined + } + + private _lines(s: string): number { + return deps + .stripAnsi(s) + .split('\n') + .map(l => Math.ceil(l.length / deps.screen.errtermwidth)) + .reduce((c, i) => c + i, 0) + } +} diff --git a/src/cli-ux/action/spinners.ts b/src/cli-ux/action/spinners.ts new file mode 100644 index 000000000..e953a876d --- /dev/null +++ b/src/cli-ux/action/spinners.ts @@ -0,0 +1,372 @@ +module.exports = { + hexagon: { + interval: 400, + frames: ['⬡', '⬢'], + }, + dots: { + interval: 80, + frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + }, + dots2: { + interval: 80, + frames: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], + }, + dots3: { + interval: 80, + frames: ['⠋', '⠙', '⠚', '⠞', '⠖', '⠦', '⠴', '⠲', '⠳', '⠓'], + }, + dots4: { + interval: 80, + frames: ['⠄', '⠆', '⠇', '⠋', '⠙', '⠸', '⠰', '⠠', '⠰', '⠸', '⠙', '⠋', '⠇', '⠆'], + }, + dots5: { + interval: 80, + frames: ['⠋', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋'], + }, + dots6: { + interval: 80, + frames: [ + '⠁', + '⠉', + '⠙', + '⠚', + '⠒', + '⠂', + '⠂', + '⠒', + '⠲', + '⠴', + '⠤', + '⠄', + '⠄', + '⠤', + '⠴', + '⠲', + '⠒', + '⠂', + '⠂', + '⠒', + '⠚', + '⠙', + '⠉', + '⠁', + ], + }, + dots7: { + interval: 80, + frames: [ + '⠈', + '⠉', + '⠋', + '⠓', + '⠒', + '⠐', + '⠐', + '⠒', + '⠖', + '⠦', + '⠤', + '⠠', + '⠠', + '⠤', + '⠦', + '⠖', + '⠒', + '⠐', + '⠐', + '⠒', + '⠓', + '⠋', + '⠉', + '⠈', + ], + }, + dots8: { + interval: 80, + frames: [ + '⠁', + '⠁', + '⠉', + '⠙', + '⠚', + '⠒', + '⠂', + '⠂', + '⠒', + '⠲', + '⠴', + '⠤', + '⠄', + '⠄', + '⠤', + '⠠', + '⠠', + '⠤', + '⠦', + '⠖', + '⠒', + '⠐', + '⠐', + '⠒', + '⠓', + '⠋', + '⠉', + '⠈', + '⠈', + ], + }, + dots9: { + interval: 80, + frames: ['⢹', '⢺', '⢼', '⣸', '⣇', '⡧', '⡗', '⡏'], + }, + dots10: { + interval: 80, + frames: ['⢄', '⢂', '⢁', '⡁', '⡈', '⡐', '⡠'], + }, + dots11: { + interval: 100, + frames: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], + }, + line: { + interval: 130, + frames: ['-', '\\', '|', '/'], + }, + line2: { + interval: 100, + frames: ['⠂', '-', '–', '—', '–', '-'], + }, + pipe: { + interval: 100, + frames: ['┤', '┘', '┴', '└', '├', '┌', '┬', '┐'], + }, + simpleDots: { + interval: 400, + frames: ['. ', '.. ', '...', ' '], + }, + simpleDotsScrolling: { + interval: 200, + frames: ['. ', '.. ', '...', ' ..', ' .', ' '], + }, + star: { + interval: 70, + frames: ['✶', '✸', '✹', '✺', '✹', '✷'], + }, + star2: { + interval: 80, + frames: ['+', 'x', '*'], + }, + flip: { + interval: 70, + frames: ['_', '_', '_', '-', '`', '`', '\'', '´', '-', '_', '_', '_'], + }, + hamburger: { + interval: 100, + frames: ['☱', '☲', '☴'], + }, + growVertical: { + interval: 120, + frames: ['▁', '▃', '▄', '▅', '▆', '▇', '▆', '▅', '▄', '▃'], + }, + growHorizontal: { + interval: 120, + frames: ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '▊', '▋', '▌', '▍', '▎'], + }, + balloon: { + interval: 140, + frames: [' ', '.', 'o', 'O', '@', '*', ' '], + }, + balloon2: { + interval: 120, + frames: ['.', 'o', 'O', '°', 'O', 'o', '.'], + }, + noise: { + interval: 100, + frames: ['▓', '▒', '░'], + }, + bounce: { + interval: 120, + frames: ['⠁', '⠂', '⠄', '⠂'], + }, + boxBounce: { + interval: 120, + frames: ['▖', '▘', '▝', '▗'], + }, + boxBounce2: { + interval: 100, + frames: ['▌', '▀', '▐', '▄'], + }, + triangle: { + interval: 50, + frames: ['◢', '◣', '◤', '◥'], + }, + arc: { + interval: 100, + frames: ['◜', '◠', '◝', '◞', '◡', '◟'], + }, + circle: { + interval: 120, + frames: ['◡', '⊙', '◠'], + }, + squareCorners: { + interval: 180, + frames: ['◰', '◳', '◲', '◱'], + }, + circleQuarters: { + interval: 120, + frames: ['◴', '◷', '◶', '◵'], + }, + circleHalves: { + interval: 50, + frames: ['◐', '◓', '◑', '◒'], + }, + squish: { + interval: 100, + frames: ['╫', '╪'], + }, + toggle: { + interval: 250, + frames: ['⊶', '⊷'], + }, + toggle2: { + interval: 80, + frames: ['▫', '▪'], + }, + toggle3: { + interval: 120, + frames: ['□', '■'], + }, + toggle4: { + interval: 100, + frames: ['■', '□', '▪', '▫'], + }, + toggle5: { + interval: 100, + frames: ['▮', '▯'], + }, + toggle6: { + interval: 300, + frames: ['ဝ', '၀'], + }, + toggle7: { + interval: 80, + frames: ['⦾', '⦿'], + }, + toggle8: { + interval: 100, + frames: ['◍', '◌'], + }, + toggle9: { + interval: 100, + frames: ['◉', '◎'], + }, + toggle10: { + interval: 100, + frames: ['㊂', '㊀', '㊁'], + }, + toggle11: { + interval: 50, + frames: ['⧇', '⧆'], + }, + toggle12: { + interval: 120, + frames: ['☗', '☖'], + }, + toggle13: { + interval: 80, + frames: ['=', '*', '-'], + }, + arrow: { + interval: 100, + frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], + }, + arrow2: { + interval: 80, + frames: ['⬆️ ', '↗️ ', '➡️ ', '↘️ ', '⬇️ ', '↙️ ', '⬅️ ', '↖️ '], + }, + arrow3: { + interval: 120, + frames: ['▹▹▹▹▹', '▸▹▹▹▹', '▹▸▹▹▹', '▹▹▸▹▹', '▹▹▹▸▹', '▹▹▹▹▸'], + }, + bouncingBar: { + interval: 80, + frames: ['[ ]', '[ =]', '[ ==]', '[ ===]', '[====]', '[=== ]', '[== ]', '[= ]'], + }, + bouncingBall: { + interval: 80, + frames: [ + '( ● )', + '( ● )', + '( ● )', + '( ● )', + '( ●)', + '( ● )', + '( ● )', + '( ● )', + '( ● )', + '(● )', + ], + }, + smiley: { + interval: 200, + frames: ['😄 ', '😝 '], + }, + monkey: { + interval: 300, + frames: ['🙈 ', '🙈 ', '🙉 ', '🙊 '], + }, + hearts: { + interval: 100, + frames: ['💛 ', '💙 ', '💜 ', '💚 ', '❤️ '], + }, + clock: { + interval: 100, + frames: ['🕐 ', '🕑 ', '🕒 ', '🕓 ', '🕔 ', '🕕 ', '🕖 ', '🕗 ', '🕘 ', '🕙 ', '🕚 '], + }, + earth: { + interval: 180, + frames: ['🌍 ', '🌎 ', '🌏 '], + }, + moon: { + interval: 80, + frames: ['🌑 ', '🌒 ', '🌓 ', '🌔 ', '🌕 ', '🌖 ', '🌗 ', '🌘 '], + }, + runner: { + interval: 140, + frames: ['🚶 ', '🏃 '], + }, + pong: { + interval: 80, + frames: [ + '▐⠂ ▌', + '▐⠈ ▌', + '▐ ⠂ ▌', + '▐ ⠠ ▌', + '▐ ⡀ ▌', + '▐ ⠠ ▌', + '▐ ⠂ ▌', + '▐ ⠈ ▌', + '▐ ⠂ ▌', + '▐ ⠠ ▌', + '▐ ⡀ ▌', + '▐ ⠠ ▌', + '▐ ⠂ ▌', + '▐ ⠈ ▌', + '▐ ⠂▌', + '▐ ⠠▌', + '▐ ⡀▌', + '▐ ⠠ ▌', + '▐ ⠂ ▌', + '▐ ⠈ ▌', + '▐ ⠂ ▌', + '▐ ⠠ ▌', + '▐ ⡀ ▌', + '▐ ⠠ ▌', + '▐ ⠂ ▌', + '▐ ⠈ ▌', + '▐ ⠂ ▌', + '▐ ⠠ ▌', + '▐ ⡀ ▌', + '▐⠠ ▌', + ], + }, +} diff --git a/src/cli-ux/config.ts b/src/cli-ux/config.ts new file mode 100644 index 000000000..e3cb40f13 --- /dev/null +++ b/src/cli-ux/config.ts @@ -0,0 +1,65 @@ +import * as semver from 'semver' + +import {ActionBase} from './action/base' + +const version = semver.parse(require('../../package.json').version)! + +export type Levels = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' + +export interface ConfigMessage { + type: 'config'; + prop: string; + value: any; +} + +const g: any = global +const globals = g['cli-ux'] || (g['cli-ux'] = {}) + +const actionType = ( + Boolean(process.stderr.isTTY) && + !process.env.CI && + !['dumb', 'emacs-color'].includes(process.env.TERM!) && + '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 */ + +export class Config { + outputLevel: Levels = 'info' + + action: ActionBase = new Action() + + prideAction: ActionBase = new PrideAction() + + errorsHandled = false + + showStackTrace = true + + get debug(): boolean { + return globals.debug || process.env.DEBUG === '*' + } + + set debug(v: boolean) { + globals.debug = v + } + + get context(): any { + return globals.context || {} + } + + set context(v: any) { + globals.context = v + } +} + +function fetch() { + if (globals[version.major]) return globals[version.major] + globals[version.major] = new Config() + return globals[version.major] +} + +export const config: Config = fetch() +export default config diff --git a/src/cli-ux/deps.ts b/src/cli-ux/deps.ts new file mode 100644 index 000000000..329486e84 --- /dev/null +++ b/src/cli-ux/deps.ts @@ -0,0 +1,45 @@ +/* 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/exit.ts b/src/cli-ux/exit.ts new file mode 100644 index 000000000..4f036df30 --- /dev/null +++ b/src/cli-ux/exit.ts @@ -0,0 +1,17 @@ +export class ExitError extends Error { + public 'cli-ux': { + exit: number; + } + + public code: 'EEXIT' + + public error?: Error + + constructor(status: number, error?: Error) { + const code = 'EEXIT' + super(error ? error.message : `${code}: ${status}`) + this.error = error + this['cli-ux'] = {exit: status} + this.code = code + } +} diff --git a/src/cli-ux/global.d.ts b/src/cli-ux/global.d.ts new file mode 100644 index 000000000..9b45c3a36 --- /dev/null +++ b/src/cli-ux/global.d.ts @@ -0,0 +1,7 @@ +// tslint:disable + +declare namespace NodeJS { + interface Global { + 'cli-ux': any; + } +} diff --git a/src/cli-ux/index.ts b/src/cli-ux/index.ts new file mode 100644 index 000000000..da5a9e59f --- /dev/null +++ b/src/cli-ux/index.ts @@ -0,0 +1,155 @@ +import * as Errors from '../errors' +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' + +const hyperlinker = require('hyperlinker') + +function timeout(p: Promise, ms: number) { + function wait(ms: number, unref = false) { + return new Promise(resolve => { + const t: any = setTimeout(() => resolve(null), ms) + if (unref) t.unref() + }) + } + + return Promise.race([p, wait(ms, true).then(() => ux.error('timed out'))]) +} + +async function flush() { + const p = new Promise(resolve => { + process.stdout.once('drain', () => resolve(null)) + }) + process.stdout.write('') + return p +} + +export const ux = { + config, + + warn: Errors.warn, + error: Errors.error, + exit: Errors.exit, + + get prompt() { + return deps.prompt.prompt + }, + /** + * "press anykey to continue" + */ + get anykey() { + return deps.prompt.anykey + }, + get confirm() { + return deps.prompt.confirm + }, + get action() { + return config.action + }, + get prideAction() { + return config.prideAction + }, + styledObject(obj: any, keys?: string[]) { + ux.info(deps.styledObject(obj, keys)) + }, + get styledHeader() { + return deps.styledHeader + }, + get styledJSON() { + return deps.styledJSON + }, + get table() { + return deps.table + }, + get tree() { + return deps.tree + }, + get open() { + return deps.open + }, + get wait() { + return deps.wait + }, + get progress() { + return deps.progress + }, + + async done() { + config.action.stop() + // await flushStdout() + }, + + trace(format: string, ...args: string[]) { + if (this.config.outputLevel === 'trace') { + process.stdout.write(util.format(format, ...args) + '\n') + } + }, + + debug(format: string, ...args: string[]) { + if (['trace', 'debug'].includes(this.config.outputLevel)) { + process.stdout.write(util.format(format, ...args) + '\n') + } + }, + + info(format: string, ...args: string[]) { + process.stdout.write(util.format(format, ...args) + '\n') + }, + + log(format?: string, ...args: string[]) { + this.info(format || '', ...args) + }, + + url(text: string, uri: string, params = {}) { + const supports = require('supports-hyperlinks') + if (supports.stdout) { + this.log(hyperlinker(text, uri, params)) + } else { + this.log(uri) + } + }, + + annotation(text: string, annotation: string) { + const supports = require('supports-hyperlinks') + if (supports.stdout) { + // \u001b]8;;https://google.com\u0007sometext\u001b]8;;\u0007 + this.log(`\u001B]1337;AddAnnotation=${text.length}|${annotation}\u0007${text}`) + } else { + this.log(text) + } + }, + + async flush() { + await timeout(flush(), 10_000) + }, +} + +export { + config, + ActionBase, + Config, + ExitError, + IPromptOptions, + Table, +} + +const cliuxProcessExitHandler = async () => { + try { + await ux.done() + } catch (error) { + // tslint:disable no-console + console.error(error) + process.exitCode = 1 + } +} + +// to avoid MaxListenersExceededWarning +// only attach named listener once +const cliuxListener = process.listeners('exit').find(fn => fn.name === cliuxProcessExitHandler.name) +if (!cliuxListener) { + process.once('exit', cliuxProcessExitHandler) +} diff --git a/src/cli-ux/list.ts b/src/cli-ux/list.ts new file mode 100644 index 000000000..ee9d72e94 --- /dev/null +++ b/src/cli-ux/list.ts @@ -0,0 +1,34 @@ +// tslint:disable + +import maxBy from 'lodash/maxBy' + +import deps from './deps' + +function linewrap(length: number, s: string): string { + const lw = require('@oclif/linewrap') + return lw(length, deps.screen.stdtermwidth, { + skipScheme: 'ansi-color', + })(s).trim() +} + +export type IListItem = [string, string | undefined] +export type IList = IListItem[] +export function renderList(items: IListItem[]): string { + if (items.length === 0) { + return '' + } + + const maxLength = (maxBy(items, '[0].length') as any)[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/cli-ux/open.ts b/src/cli-ux/open.ts new file mode 100644 index 000000000..9a4de57d5 --- /dev/null +++ b/src/cli-ux/open.ts @@ -0,0 +1,88 @@ +// this code is largely taken from opn +import * as childProcess from 'child_process' +import _ from 'lodash' +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 (_.isNumber(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 new file mode 100644 index 000000000..ab4958622 --- /dev/null +++ b/src/cli-ux/prompt.ts @@ -0,0 +1,167 @@ +import * as Errors from '../../src/errors' +import * as chalk from 'chalk' + +import config from './config' +import deps from './deps' + +export interface IPromptOptions { + prompt?: string; + type?: 'normal' | 'mask' | 'hide' | 'single'; + timeout?: number; + /** + * Requires user input if true, otherwise allows empty input + */ + required?: boolean; + default?: string; +} + +interface IPromptConfig { + name: string; + prompt: string; + type: 'normal' | 'mask' | 'hide' | 'single'; + isTTY: boolean; + required: boolean; + default?: string; + timeout?: number; +} + +function normal(options: IPromptConfig, retries = 100): Promise { + if (retries < 0) throw new Error('no input') + return new Promise((resolve, reject) => { + let timer: NodeJS.Timer + if (options.timeout) { + timer = setTimeout(() => { + process.stdin.pause() + reject(new Error('Prompt timeout')) + }, options.timeout) + timer.unref() + } + + process.stdin.setEncoding('utf8') + process.stderr.write(options.prompt) + process.stdin.resume() + process.stdin.once('data', b => { + if (timer) clearTimeout(timer) + process.stdin.pause() + const data: string = (typeof b === 'string' ? b : b.toString()).trim() + if (!options.default && options.required && data === '') { + resolve(normal(options, retries - 1)) + } else { + resolve(data || options.default as string) + } + }) + }) +} + +function getPrompt(name: string, type?: string, defaultValue?: string) { + let prompt = '> ' + + if (defaultValue && type === 'hide') { + defaultValue = '*'.repeat(defaultValue.length) + } + + if (name && defaultValue) prompt = name + ' ' + chalk.yellow('[' + defaultValue + ']') + ': ' + else if (name) prompt = `${name}: ` + + return prompt +} + +async function single(options: IPromptConfig): Promise { + const raw = process.stdin.isRaw + if (process.stdin.setRawMode) process.stdin.setRawMode(true) + options.required = options.required ?? false + const response = await normal(options) + if (process.stdin.setRawMode) process.stdin.setRawMode(Boolean(raw)) + return response +} + +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) +} + +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), + name, + prompt, + type: 'normal', + required: true, + default: '', + ...inputOptions, + } + switch (options.type) { + case 'normal': + return normal(options) + case 'single': + return single(options) + case 'mask': + return deps.passwordPrompt(options.prompt, { + method: options.type, + required: options.required, + default: options.default, + }).then((value: string) => { + replacePrompt(getPrompt(name, 'hide', inputOptions.default)) + return value + }) + case 'hide': + return deps.passwordPrompt(options.prompt, { + method: options.type, + required: options.required, + default: options.default, + }) + default: + throw new Error(`unexpected type ${options.type}`) + } +} + +/** + * prompt for input + * @param name - prompt text + * @param options - @see IPromptOptions + * @returns void + */ +export function prompt(name: string, options: IPromptOptions = {}) { + return config.action.pauseAsync(() => { + return _prompt(name, options) + }, chalk.cyan('?')) +} + +/** + * confirmation prompt (yes/no) + * @param message - confirmation text + * @returns Promise + */ +export function confirm(message: string): Promise { + return config.action.pauseAsync(async () => { + const confirm = async (): Promise => { + const response = (await _prompt(message)).toLowerCase() + if (['n', 'no'].includes(response)) return false + if (['y', 'yes'].includes(response)) return true + return confirm() + } + + return confirm() + }, chalk.cyan('?')) +} + +/** + * "press anykey to continue" + * @param message - optional message to display to user + * @returns Promise + */ +export async function anykey(message?: string): Promise { + const tty = Boolean(process.stdin.setRawMode) + if (!message) { + message = tty ? + `Press any key to continue or ${chalk.yellow('q')} to exit` : + `Press enter to continue or ${chalk.yellow('q')} to exit` + } + + const char = await prompt(message, {type: 'single', required: false}) + if (tty) process.stderr.write('\n') + if (char === 'q') Errors.error('quit') + if (char === '\u0003') Errors.error('ctrl-c') + return char +} diff --git a/src/cli-ux/styled/header.ts b/src/cli-ux/styled/header.ts new file mode 100644 index 000000000..ecc2c8bac --- /dev/null +++ b/src/cli-ux/styled/header.ts @@ -0,0 +1,7 @@ +// tslint:disable restrict-plus-operands + +import * as chalk from 'chalk' + +export default function styledHeader(header: string) { + process.stdout.write(chalk.dim('=== ') + chalk.bold(header) + '\n') +} diff --git a/src/cli-ux/styled/json.ts b/src/cli-ux/styled/json.ts new file mode 100644 index 000000000..bc8109ab8 --- /dev/null +++ b/src/cli-ux/styled/json.ts @@ -0,0 +1,17 @@ +// tslint:disable restrict-plus-operands + +import * as chalk from 'chalk' + +import {CliUx} from '../../index' + +export default function styledJSON(obj: any) { + const json = JSON.stringify(obj, null, 2) + if (!chalk.level) { + CliUx.ux.info(json) + return + } + + const cardinal = require('cardinal') + const theme = require('cardinal/themes/jq') + CliUx.ux.info(cardinal.highlight(json, {json: true, theme})) +} diff --git a/src/cli-ux/styled/object.ts b/src/cli-ux/styled/object.ts new file mode 100644 index 000000000..87b827141 --- /dev/null +++ b/src/cli-ux/styled/object.ts @@ -0,0 +1,40 @@ +// tslint:disable + +import * as chalk from 'chalk' +import * as util from 'util' + +export default function styledObject(obj: any, keys?: string[]): string { + const output: string[] = [] + const keyLengths = Object.keys(obj).map(key => key.toString().length) + const maxKeyLength = Math.max(...keyLengths) + 2 + function pp(obj: any) { + if (typeof obj === 'string' || typeof obj === 'number') return obj + if (typeof obj === 'object') { + return Object.keys(obj) + .map(k => k + ': ' + util.inspect(obj[k])) + .join(', ') + } + + return util.inspect(obj) + } + + const logKeyValue = (key: string, value: any): string => { + return `${chalk.blue(key)}:` + ' '.repeat(maxKeyLength - key.length - 1) + pp(value) + } + + for (const key of keys || Object.keys(obj).sort()) { + const value = obj[key] + if (Array.isArray(value)) { + if (value.length > 0) { + output.push(logKeyValue(key, value[0])) + for (const e of value.slice(1)) { + output.push(' '.repeat(maxKeyLength) + pp(e)) + } + } + } else if (value !== null && value !== undefined) { + output.push(logKeyValue(key, value)) + } + } + + return output.join('\n') +} diff --git a/src/cli-ux/styled/progress.ts b/src/cli-ux/styled/progress.ts new file mode 100644 index 000000000..4184c81c6 --- /dev/null +++ b/src/cli-ux/styled/progress.ts @@ -0,0 +1,14 @@ +// 3pp +import * as cliProgress from 'cli-progress' + +export default function progress(options?: any): any { + // if no options passed, create empty options + if (!options) { + options = {} + } + + // set noTTYOutput for options + options.noTTYOutput = Boolean(process.env.TERM === 'dumb' || !process.stdin.isTTY) + + return new cliProgress.SingleBar(options) +} diff --git a/src/cli-ux/styled/table.ts b/src/cli-ux/styled/table.ts new file mode 100644 index 000000000..08da2dc8a --- /dev/null +++ b/src/cli-ux/styled/table.ts @@ -0,0 +1,384 @@ +import * as Interfaces from '../../interfaces' +import * as F from '../../flags' +import {stdtermwidth} from '@oclif/screen' +import * as chalk from 'chalk' +import {capitalize, sumBy} from 'lodash' +import {safeDump} from 'js-yaml' +import {inspect} from 'util' + +const sw = require('string-width') +const {orderBy} = require('natural-orderby') + +class Table> { + options: table.Options & { printLine(s: any): any } + + columns: (table.Column & { key: string; width?: number; maxWidth?: number })[] + + constructor(private data: T[], columns: table.Columns, options: table.Options = {}) { + // assign columns + this.columns = Object.keys(columns).map((key: string) => { + const col = columns[key] + const extended = col.extended || false + const get = col.get || ((row: any) => row[key]) + const header = typeof col.header === 'string' ? col.header : capitalize(key.replace(/_/g, ' ')) + const minWidth = Math.max(col.minWidth || 0, sw(header) + 1) + + return { + extended, + get, + header, + key, + minWidth, + } + }) + + // assign options + const {columns: cols, filter, csv, output, extended, sort, title, printLine} = options + this.options = { + columns: cols, + output: csv ? 'csv' : output, + extended, + filter, + 'no-header': options['no-header'] || false, + 'no-truncate': options['no-truncate'] || false, + printLine: printLine || ((s: any) => process.stdout.write(s + '\n')), + rowStart: ' ', + sort, + title, + } + } + + display() { + // build table rows from input array data + let rows = this.data.map(d => { + const row: any = {} + for (const col of this.columns) { + let val = col.get(d) + if (typeof val !== 'string') val = inspect(val, {breakLength: Number.POSITIVE_INFINITY}) + row[col.key] = val + } + + return row + }) + + // filter rows + if (this.options.filter) { + /* eslint-disable-next-line prefer-const */ + let [header, regex] = this.options.filter!.split('=') + const isNot = header[0] === '-' + if (isNot) header = header.slice(1) + const col = this.findColumnFromHeader(header) + if (!col || !regex) throw new Error('Filter flag has an invalid value') + rows = rows.filter((d: any) => { + const re = new RegExp(regex) + const val = d[col!.key] + const match = val.match(re) + return isNot ? !match : match + }) + } + + // sort rows + if (this.options.sort) { + const sorters = this.options.sort!.split(',') + const sortHeaders = sorters.map(k => k[0] === '-' ? k.slice(1) : k) + const sortKeys = this.filterColumnsFromHeaders(sortHeaders).map(c => { + return ((v: any) => v[c.key]) + }) + const sortKeysOrder = sorters.map(k => k[0] === '-' ? 'desc' : 'asc') + rows = orderBy(rows, sortKeys, sortKeysOrder) + } + + // and filter columns + if (this.options.columns) { + const filters = this.options.columns!.split(',') + this.columns = this.filterColumnsFromHeaders(filters) + } else if (!this.options.extended) { + // show extented columns/properties + this.columns = this.columns.filter(c => !c.extended) + } + + this.data = rows + + switch (this.options.output) { + case 'csv': + this.outputCSV() + break + case 'json': + this.outputJSON() + break + case 'yaml': + this.outputYAML() + break + default: + this.outputTable() + } + } + + private findColumnFromHeader(header: string): (table.Column & { key: string; width?: number; maxWidth?: number }) | undefined { + return this.columns.find(c => c.header.toLowerCase() === header.toLowerCase()) + } + + private filterColumnsFromHeaders(filters: string[]): (table.Column & { key: string; width?: number; maxWidth?: number })[] { + // unique + filters = [...(new Set(filters))] + const cols: (table.Column & {key: string; width?: number; maxWidth?: number})[] = [] + for (const f of filters) { + const c = this.columns.find(c => c.header.toLowerCase() === f.toLowerCase()) + if (c) cols.push(c) + } + + return cols + } + + private getCSVRow(d: any): string[] { + const values = this.columns.map(col => d[col.key] || '') + const lineToBeEscaped = values.find((e: string) => e.includes('"') || e.includes('\n') || e.includes('\r\n') || e.includes('\r') || e.includes(',')) + return values.map(e => lineToBeEscaped ? `"${e.replace('"', '""')}"` : e) + } + + 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 + return columns.reduce((obj, col) => { + return { + ...obj, + [col.key]: d[col.key] || '', + } + }, {}) + }) + } + + private outputJSON() { + this.options.printLine(JSON.stringify(this.resolveColumnsToObjectArray(), undefined, 2)) + } + + private outputYAML() { + this.options.printLine(safeDump(this.resolveColumnsToObjectArray())) + } + + private outputCSV() { + // tslint:disable-next-line:no-this-assignment + const {data, columns, options} = this + + if (!options['no-header']) { + options.printLine(columns.map(c => c.header).join(',')) + } + + for (const d of data) { + const row = this.getCSVRow(d) + options.printLine(row.join(',')) + } + } + + private outputTable() { + // tslint:disable-next-line:no-this-assignment + const {data, columns, options} = this + + // column truncation + // + // find max width for each column + for (const col of columns) { + // convert multi-line cell to single longest line + // for width calculations + const widthData = data.map((row: any) => { + const d = row[col.key] + const manyLines = d.split('\n') + if (manyLines.length > 1) { + return '*'.repeat(Math.max(...manyLines.map((r: string) => sw(r)))) + } + + return d + }) + const widths = ['.'.padEnd(col.minWidth! - 1), col.header, ...widthData.map((row: any) => row)].map(r => sw(r)) + col.maxWidth = Math.max(...widths) + 1 + col.width = col.maxWidth! + } + + // terminal width + const maxWidth = stdtermwidth - 2 + // truncation logic + const shouldShorten = () => { + // don't shorten if full mode + if (options['no-truncate'] || (!process.stdout.isTTY && !process.env.CLI_UX_SKIP_TTY_CHECK)) return + + // don't shorten if there is enough screen width + const dataMaxWidth = sumBy(columns, c => c.width!) + const overWidth = dataMaxWidth - maxWidth + if (overWidth <= 0) return + + // not enough room, short all columns to minWidth + for (const col of columns) { + col.width = col.minWidth + } + + // if sum(minWidth's) is greater than term width + // nothing can be done so + // display all as minWidth + const dataMinWidth = sumBy(columns, c => c.minWidth!) + if (dataMinWidth >= maxWidth) return + + // some wiggle room left, add it back to "needy" columns + let wiggleRoom = maxWidth - dataMinWidth + const needyCols = columns.map(c => ({key: c.key, needs: c.maxWidth! - c.width!})).sort((a, b) => a.needs - b.needs) + for (const {key, needs} of needyCols) { + if (!needs) continue + const col = columns.find(c => key === c.key) + if (!col) continue + if (wiggleRoom > needs) { + col.width = col.width! + needs + wiggleRoom -= needs + } else if (wiggleRoom) { + col.width = col.width! + wiggleRoom + wiggleRoom = 0 + } + } + } + + shouldShorten() + + // print table title + if (options.title) { + options.printLine(options.title) + // print title divider + options.printLine(''.padEnd(columns.reduce((sum, col) => sum + col.width!, 1), '=')) + + options.rowStart = '| ' + } + + // print headers + if (!options['no-header']) { + let headers = options.rowStart + for (const col of columns) { + const header = col.header! + headers += header.padEnd(col.width!) + } + + options.printLine(chalk.bold(headers)) + + // print header dividers + let dividers = options.rowStart + for (const col of columns) { + const divider = ''.padEnd(col.width! - 1, '─') + ' ' + dividers += divider.padEnd(col.width!) + } + + options.printLine(chalk.bold(dividers)) + } + + // print rows + for (const row of data) { + // find max number of lines + // for all cells in a row + // with multi-line strings + let numOfLines = 1 + for (const col of columns) { + const d = (row as any)[col.key] + const lines = d.split('\n').length + if (lines > numOfLines) numOfLines = lines + } + + // eslint-disable-next-line unicorn/no-new-array + const linesIndexess = [...new Array(numOfLines).keys()] + + // print row + // including multi-lines + for (const i of linesIndexess) { + let l = options.rowStart + for (const col of columns) { + const width = col.width! + let d = (row as any)[col.key] + d = d.split('\n')[i] || '' + const visualWidth = sw(d) + const colorWidth = (d.length - visualWidth) + let cell = d.padEnd(width + colorWidth) + if ((cell.length - colorWidth) > width || visualWidth === width) { + cell = cell.slice(0, width - 2) + '… ' + } + + l += cell + } + + options.printLine(l) + } + } + } +} + +export function table>(data: T[], columns: table.Columns, options: table.Options = {}) { + new Table(data, columns, options).display() +} + +export namespace table { + export const Flags: { + columns: Interfaces.OptionFlag; + sort: Interfaces.OptionFlag; + filter: Interfaces.OptionFlag; + csv: Interfaces.Flag; + output: Interfaces.OptionFlag; + extended: Interfaces.Flag; + 'no-truncate': Interfaces.Flag; + 'no-header': Interfaces.Flag; + } = { + columns: F.string({exclusive: ['extended'], description: 'only show provided columns (comma-separated)'}), + sort: F.string({description: 'property to sort by (prepend \'-\' for descending)'}), + filter: F.string({description: 'filter property by partial string matching, ex: name=foo'}), + csv: F.boolean({exclusive: ['no-truncate'], description: 'output is csv format [alias: --output=csv]'}), + output: F.string({ + exclusive: ['no-truncate', 'csv'], + description: 'output in a more machine friendly format', + options: ['csv', 'json', 'yaml'], + }), + extended: F.boolean({exclusive: ['columns'], char: 'x', description: 'show extra columns'}), + 'no-truncate': F.boolean({exclusive: ['csv'], description: 'do not truncate output to fit screen'}), + 'no-header': F.boolean({exclusive: ['csv'], description: 'hide table header from output'}), + } + + type IFlags = typeof Flags + type ExcludeFlags = Pick> + type IncludeFlags = Pick + + export function flags(): IFlags + export function flags(opts: { except: Z | Z[] }): ExcludeFlags + export function flags(opts: { only: K | K[] }): IncludeFlags + export function flags(opts?: any): any { + if (opts) { + const f = {} + const o = (opts.only && typeof opts.only === 'string' ? [opts.only] : opts.only) || Object.keys(Flags) + const e = (opts.except && typeof opts.except === 'string' ? [opts.except] : opts.except) || [] + for (const key of o) { + if (!(e as any[]).includes(key)) { + (f as any)[key] = (Flags as any)[key] + } + } + + return f + } + + return Flags + } + + export interface Column> { + header: string; + extended: boolean; + minWidth: number; + get(row: T): any; + } + + export type Columns> = { [key: string]: Partial> } + + // export type OutputType = 'csv' | 'json' | 'yaml' + + export interface Options { + [key: string]: any; + sort?: string; + filter?: string; + columns?: string; + extended?: boolean; + 'no-truncate'?: boolean; + output?: string; + 'no-header'?: boolean; + printLine?(s: any): any; + } +} diff --git a/src/cli-ux/styled/tree.ts b/src/cli-ux/styled/tree.ts new file mode 100644 index 000000000..d14e98494 --- /dev/null +++ b/src/cli-ux/styled/tree.ts @@ -0,0 +1,40 @@ +const treeify = require('object-treeify') + +export class Tree { + nodes: { [key: string]: Tree } = {} + + insert(child: string, value: Tree = new Tree()): Tree { + this.nodes[child] = value + return this + } + + search(key: string): Tree | undefined { + for (const child of Object.keys(this.nodes)) { + if (child === key) { + return this.nodes[child] + } + + const c = this.nodes[child].search(key) + if (c) return c + } + } + + // tslint:disable-next-line:no-console + display(logger: any = console.log) { + const addNodes = function (nodes: any) { + const tree: { [key: string]: any } = {} + for (const p of Object.keys(nodes)) { + tree[p] = addNodes(nodes[p].nodes) + } + + return tree + } + + const tree = addNodes(this.nodes) + logger(treeify(tree)) + } +} + +export default function tree() { + return new Tree() +} diff --git a/src/cli-ux/wait.ts b/src/cli-ux/wait.ts new file mode 100644 index 000000000..769321a42 --- /dev/null +++ b/src/cli-ux/wait.ts @@ -0,0 +1,6 @@ +// tslint:disable no-string-based-set-timeout +export default (ms = 1000) => { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} diff --git a/src/command.ts b/src/command.ts index 42b1296d9..d1cfee868 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,7 +1,7 @@ import {fileURLToPath} from 'url' import {format, inspect} from 'util' -import {cli} from 'cli-ux' +import {CliUx} from './index' import {Config} from './config' import * as Interfaces from './interfaces' import * as Errors from './errors' @@ -163,7 +163,7 @@ export default abstract class Command { } if (result && this.jsonEnabled()) { - cli.styledJSON(this.toSuccessJson(result)) + CliUx.ux.styledJSON(this.toSuccessJson(result)) } return result @@ -227,13 +227,12 @@ export default abstract class Command { protected async catch(err: Record): Promise { process.exitCode = process.exitCode ?? err.exitCode ?? 1 if (this.jsonEnabled()) { - cli.styledJSON(this.toErrorJson(err)) + CliUx.ux.styledJSON(this.toErrorJson(err)) } else { if (!err.message) throw err try { - const {cli} = require('cli-ux') const chalk = require('chalk') - cli.action.stop(chalk.bold.red('!')) + CliUx.ux.action.stop(chalk.bold.red('!')) } catch {} throw err diff --git a/src/index.ts b/src/index.ts index fc6012890..05e1f8859 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ 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' const flush = require('../flush') @@ -40,6 +41,7 @@ export { settings, Settings, flush, + cliUx as CliUx, } function checkCWD() { diff --git a/test/cli-ux/export.test.ts b/test/cli-ux/export.test.ts new file mode 100644 index 000000000..8e3038769 --- /dev/null +++ b/test/cli-ux/export.test.ts @@ -0,0 +1,24 @@ +import {CliUx} 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 = {} + +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') + }) +}) + diff --git a/test/cli-ux/fancy.ts b/test/cli-ux/fancy.ts new file mode 100644 index 000000000..7718368c2 --- /dev/null +++ b/test/cli-ux/fancy.ts @@ -0,0 +1,24 @@ +import {expect, fancy as base, FancyTypes} from 'fancy-test' +import * as fs from 'fs-extra' +import * as path from 'path' + +import {CliUx} from '../../src' + +export { + expect, + FancyTypes, +} + +let count = 0 + +export const fancy = base +.do(async (ctx: {count: number; base: string}) => { + ctx.count = count++ + ctx.base = path.join(__dirname, '../tmp', `test-${ctx.count}`) + await fs.remove(ctx.base) + const chalk = require('chalk') + chalk.level = 0 +}) +.finally(async () => { + await CliUx.ux.done() +}) diff --git a/test/cli-ux/helpers/init.js b/test/cli-ux/helpers/init.js new file mode 100644 index 000000000..cd57a3d48 --- /dev/null +++ b/test/cli-ux/helpers/init.js @@ -0,0 +1,3 @@ +const path = require('path') +process.env.TS_NODE_PROJECT = path.resolve('test/tsconfig.json') +global.columns = '80' diff --git a/test/cli-ux/index.test.ts b/test/cli-ux/index.test.ts new file mode 100644 index 000000000..2488b0778 --- /dev/null +++ b/test/cli-ux/index.test.ts @@ -0,0 +1,24 @@ +import {CliUx} from '../../src' + +import {expect, fancy} from './fancy' +const hyperlinker = require('hyperlinker') + +describe('url', () => { + fancy + .env({FORCE_HYPERLINK: '1'}, {clear: true}) + .stdout() + .do(() => CliUx.ux.url('sometext', 'https://google.com')) + .it('renders hyperlink', async ({stdout}) => { + expect(stdout).to.equal('sometext\n') + }) +}) + +describe('hyperlinker', () => { + fancy + .it('renders hyperlink', async () => { + const link = hyperlinker('sometext', 'https://google.com', {}) + // eslint-disable-next-line unicorn/escape-case + const expected = '\u001b]8;;https://google.com\u0007sometext\u001b]8;;\u0007' + expect(link).to.equal(expected) + }) +}) diff --git a/test/cli-ux/prompt.test.ts b/test/cli-ux/prompt.test.ts new file mode 100644 index 000000000..72498d9ad --- /dev/null +++ b/test/cli-ux/prompt.test.ts @@ -0,0 +1,75 @@ +import * as chai from 'chai' + +const expect = chai.expect + +import {CliUx} from '../../src' + +import {fancy} from './fancy' + +describe('prompt', () => { + fancy + .stdout() + .stderr() + .end('requires input', async () => { + const promptPromise = CliUx.ux.prompt('Require input?') + process.stdin.emit('data', '') + process.stdin.emit('data', 'answer') + const answer = await promptPromise + await CliUx.ux.done() + expect(answer).to.equal('answer') + }) + + fancy + .stdout() + .stderr() + .stdin('y') + .end('confirm', async () => { + const promptPromise = CliUx.ux.confirm('yes/no?') + const answer = await promptPromise + await CliUx.ux.done() + expect(answer).to.equal(true) + }) + + fancy + .stdout() + .stderr() + .stdin('n') + .end('confirm', async () => { + const promptPromise = CliUx.ux.confirm('yes/no?') + const answer = await promptPromise + await CliUx.ux.done() + expect(answer).to.equal(false) + }) + + fancy + .stdout() + .stderr() + .stdin('x') + .end('gets anykey', async () => { + const promptPromise = CliUx.ux.anykey() + const answer = await promptPromise + await CliUx.ux.done() + expect(answer).to.equal('x') + }) + + fancy + .stdout() + .stderr() + .end('does not require input', async () => { + const promptPromise = CliUx.ux.prompt('Require input?', { + required: false, + }) + process.stdin.emit('data', '') + const answer = await promptPromise + await CliUx.ux.done() + expect(answer).to.equal('') + }) + + fancy + .stdout() + .stderr() + .it('timeouts with no input', async () => { + await expect(CliUx.ux.prompt('Require input?', {timeout: 1})) + .to.eventually.be.rejectedWith('Prompt timeout') + }) +}) diff --git a/test/cli-ux/styled/object.test.ts b/test/cli-ux/styled/object.test.ts new file mode 100644 index 000000000..27db22a81 --- /dev/null +++ b/test/cli-ux/styled/object.test.ts @@ -0,0 +1,19 @@ +import {expect, fancy} from 'fancy-test' + +import {CliUx} from '../../../src' + +describe('styled/object', () => { + fancy + .stdout() + .end('shows a table', output => { + CliUx.ux.styledObject([ + {foo: 1, bar: 1}, + {foo: 2, bar: 2}, + {foo: 3, bar: 3}, + ]) + expect(output.stdout).to.equal(`0: foo: 1, bar: 1 +1: foo: 2, bar: 2 +2: foo: 3, bar: 3 +`) + }) +}) diff --git a/test/cli-ux/styled/progress.test.ts b/test/cli-ux/styled/progress.test.ts new file mode 100644 index 000000000..c2f09bc17 --- /dev/null +++ b/test/cli-ux/styled/progress.test.ts @@ -0,0 +1,27 @@ +import {expect, fancy} from 'fancy-test' +import {CliUx} from '../../../src' + +describe('progress', () => { + // single bar + fancy + .end('single bar has default settings', _ => { + const b1 = CliUx.ux.progress({format: 'Example 1: Progress {bar} | {percentage}%'}) + expect(b1.options.format).to.contain('Example 1: Progress') + 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({}) + expect(b1.options.format).to.contain('progress') + expect(b1.bars).to.not.have + 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}%'}) + expect(typeof (b1)).to.equal('object') + }) +}) diff --git a/test/cli-ux/styled/table.test.ts b/test/cli-ux/styled/table.test.ts new file mode 100644 index 000000000..ef4c0ac37 --- /dev/null +++ b/test/cli-ux/styled/table.test.ts @@ -0,0 +1,321 @@ +import {expect, fancy} from 'fancy-test' + +import {CliUx} from '../../../src' + +/* eslint-disable camelcase */ +const apps = [ + { + build_stack: { + id: '123', + name: 'heroku-16', + }, + created_at: '2000-01-01T22:34:46Z', + id: '123', + git_url: 'https://git.heroku.com/supertable-test-1.git', + name: 'supertable-test-1', + owner: { + email: 'example@heroku.com', + id: '1', + }, + region: {id: '123', name: 'us'}, + released_at: '2000-01-01T22:34:46Z', + stack: { + id: '123', + name: 'heroku-16', + }, + updated_at: '2000-01-01T22:34:46Z', + web_url: 'https://supertable-test-1.herokuapp.com/', + }, + { + build_stack: { + id: '321', + name: 'heroku-16', + }, + created_at: '2000-01-01T22:34:46Z', + id: '321', + git_url: 'https://git.heroku.com/phishing-demo.git', + name: 'supertable-test-2', + owner: { + email: 'example@heroku.com', + id: '1', + }, + region: {id: '321', name: 'us'}, + released_at: '2000-01-01T22:34:46Z', + stack: { + id: '321', + name: 'heroku-16', + }, + updated_at: '2000-01-01T22:34:46Z', + web_url: 'https://supertable-test-2.herokuapp.com/', + }, +] + +const columns = { + id: {header: 'ID'}, + name: {}, + web_url: {extended: true}, + stack: {extended: true, get: (r: any) => r.stack && r.stack.name}, +} +/* eslint-enable camelcase */ + +const ws = ' ' + +// ignore me +// stored up here for line wrapping reasons +const extendedHeader = `ID Name${ws.padEnd(14)}Web url${ws.padEnd(34)}Stack${ws.padEnd(5)}` + +// tests to-do: +// no-truncate +// truncation rules? + +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') + }) + + fancy + .end('has optional flags', _ => { + const flags = CliUx.ux.table.flags() + expect(flags.columns).to.exist + expect(flags.sort).to.exist + expect(flags.filter).to.exist + expect(flags.csv).to.exist + expect(flags.output).to.exist + expect(flags.extended).to.exist + expect(flags['no-truncate']).to.exist + expect(flags['no-header']).to.exist + }) + + fancy + .stdout() + .end('displays table', output => { + CliUx.ux.table(apps, columns) + expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} + ─── ─────────────────${ws} + 123 supertable-test-1${ws} + 321 supertable-test-2${ws}\n`) + }) + + describe('columns', () => { + fancy + .stdout() + .end('use header value for id', output => { + CliUx.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}) + expect(output.stdout).to.equal(`${ws}${extendedHeader} + ─── ───────────────── ──────────────────────────────────────── ─────────${ws} + 123 supertable-test-1 https://supertable-test-1.herokuapp.com/ heroku-16${ws} + 321 supertable-test-2 https://supertable-test-2.herokuapp.com/ heroku-16${ws}\n`) + }) + }) + + describe('options', () => { + fancy + .stdout() + .end('shows extended columns', output => { + CliUx.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'}) + expect(output.stdout).to.equal(`testing +======================= +| ID Name${ws.padEnd(14)} +| ─── ─────────────────${ws} +| 123 supertable-test-1${ws} +| 321 supertable-test-2${ws}\n`) + }) + + fancy + .stdout() + .end('skips header', output => { + CliUx.ux.table(apps, columns, {'no-header': true}) + expect(output.stdout).to.equal(` 123 supertable-test-1${ws} + 321 supertable-test-2${ws}\n`) + }) + + fancy + .stdout() + .end('only displays given columns', output => { + CliUx.ux.table(apps, columns, {columns: 'id'}) + expect(output.stdout).to.equal(` ID${ws}${ws} + ───${ws} + 123${ws} + 321${ws}\n`) + }) + + fancy + .stdout() + .end('outputs in csv', output => { + CliUx.ux.table(apps, columns, {output: 'csv'}) + expect(output.stdout).to.equal(`ID,Name +123,supertable-test-1 +321,supertable-test-2\n`) + }) + + fancy + .stdout() + .end('outputs in csv with escaped values', output => { + CliUx.ux.table([ + { + id: '123\n2', + name: 'supertable-test-1', + }, + { + id: '12"3', + name: 'supertable-test-2', + }, + { + id: '123', + name: 'supertable-test-3,comma', + }, + { + id: '123', + name: 'supertable-test-4', + }, + ], columns, {output: 'csv'}) + expect(output.stdout).to.equal(`ID,Name +"123\n2","supertable-test-1" +"12""3","supertable-test-2" +"123","supertable-test-3,comma" +123,supertable-test-4\n`) + }) + + fancy + .stdout() + .end('outputs in csv without headers', output => { + CliUx.ux.table(apps, columns, {output: 'csv', 'no-header': true}) + expect(output.stdout).to.equal(`123,supertable-test-1 +321,supertable-test-2\n`) + }) + + fancy + .stdout() + .end('outputs in csv with alias flag', output => { + CliUx.ux.table(apps, columns, {csv: true}) + expect(output.stdout).to.equal(`ID,Name +123,supertable-test-1 +321,supertable-test-2\n`) + }) + + fancy + .stdout() + .end('outputs in json', output => { + CliUx.ux.table(apps, columns, {output: 'json'}) + expect(output.stdout).to.equal(`[ + { + "id": "123", + "name": "supertable-test-1" + }, + { + "id": "321", + "name": "supertable-test-2" + } +] +`) + }) + + fancy + .stdout() + .end('outputs in yaml', output => { + CliUx.ux.table(apps, columns, {output: 'yaml'}) + expect(output.stdout).to.equal(`- id: '123' + name: supertable-test-1 +- id: '321' + name: supertable-test-2 + +`) + }) + + fancy + .stdout() + .end('sorts by property', output => { + CliUx.ux.table(apps, columns, {sort: '-name'}) + expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} + ─── ─────────────────${ws} + 321 supertable-test-2${ws} + 123 supertable-test-1${ws}\n`) + }) + + fancy + .stdout() + .end('filters by property & value (partial string match)', output => { + CliUx.ux.table(apps, columns, {filter: 'id=123'}) + expect(output.stdout).to.equal(` ID Name${ws.padEnd(14)} + ─── ─────────────────${ws} + 123 supertable-test-1${ws}\n`) + }) + + fancy + .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}) + 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`) + }) + }) + + describe('#flags', () => { + fancy + .end('includes only flags', _ => { + const flags = CliUx.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'}) + expect((flags as any).columns).to.be.undefined + expect(flags.sort).to.be.a('object') + }) + }) + + describe('edge cases', () => { + fancy + .stdout() + .end('ignores header case', output => { + CliUx.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} + 123 supertable-test-1${ws}\n`) + }) + + fancy + .stdout() + .end('displays multiline cell', output => { + /* eslint-disable camelcase */ + const app3 = { + build_stack: { + name: 'heroku-16', + }, + id: '456', + name: 'supertable-test\n3', + web_url: 'https://supertable-test-1.herokuapp.com/', + } + /* eslint-enable camelcase */ + + CliUx.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)} + 3${ws.padEnd(17)} + 321 supertable-test-2${ws} + 123 supertable-test-1${ws}\n`) + }) + }) +}) diff --git a/test/cli-ux/styled/tree.test.ts b/test/cli-ux/styled/tree.test.ts new file mode 100644 index 000000000..c42cd03fb --- /dev/null +++ b/test/cli-ux/styled/tree.test.ts @@ -0,0 +1,24 @@ +import {expect, fancy} from 'fancy-test' + +import {CliUx} from '../../../src' + +describe('styled/tree', () => { + fancy + .stdout() + .end('shows the tree', output => { + const tree = CliUx.ux.tree() + tree.insert('foo') + tree.insert('bar') + + const subtree = CliUx.ux.tree() + subtree.insert('qux') + tree.nodes.bar.insert('baz', subtree) + + tree.display() + expect(output.stdout).to.equal(`├─ foo +└─ bar + └─ baz + └─ qux +`) + }) +}) diff --git a/test/helpers/init.js b/test/helpers/init.js index dce826a39..1b68eb65c 100644 --- a/test/helpers/init.js +++ b/test/helpers/init.js @@ -9,3 +9,4 @@ chai.use(chaiAsPromised) global.oclif = global.oclif || {} global.oclif.columns = 80 +global.columns = '80' diff --git a/test/integration/sf.e2e.ts b/test/integration/sf.e2e.ts index 0b984a830..bcd2efa77 100644 --- a/test/integration/sf.e2e.ts +++ b/test/integration/sf.e2e.ts @@ -1,6 +1,15 @@ import * as os from 'os' import {expect} from 'chai' import {Executor, setup} from './util' +import StripAnsi = require('strip-ansi') +const stripAnsi: typeof StripAnsi = require('strip-ansi') + +const chalk = require('chalk') +chalk.level = 0 + +function parseJson(json: string) { + return JSON.parse(stripAnsi(json)) +} describe('Salesforce CLI (sf)', () => { let executor: Executor @@ -41,7 +50,7 @@ describe('Salesforce CLI (sf)', () => { * ENVIRONMENT VARIABLES * */ - const regex = /^[A-Z].*\n\nUSAGE[\S\s]*\n\nFLAGS[\S\s]*\n\nGLOBAL FLAGS[\S\s]*\n\nDESCRIPTION[\S\s]*\n\nEXAMPLES[\S\s]*\n\nFLAG DESCRIPTIONS[\S\s]*\n\nCONFIGURATION VARIABLES[\S\s]*\n\nENVIRONMENT VARIABLES[\S\s]*$/g + const regex = /^.*?USAGE.*?FLAGS.*?GLOBAL FLAGS.*?DESCRIPTION.*?EXAMPLES.*?FLAG DESCRIPTIONS.*?CONFIGURATION VARIABLES.*?ENVIRONMENT VARIABLES.*$/gs expect(regex.test(help.output!)).to.be.true }) @@ -62,7 +71,7 @@ describe('Salesforce CLI (sf)', () => { * GLOBAL FLAGS * */ - const regex = /^[A-Z].*\n\nUSAGE[\S\s]*\n\nFLAGS[\S\s]*\n\nGLOBAL FLAGS[\S\s]*$/g + const regex = /^.*?USAGE.*?FLAGS.*?GLOBAL FLAGS.*?(?!DESCRIPTION).*?(?!EXAMPLES).*?(?!FLAG DESCRIPTIONS).*?(?!CONFIGURATION VARIABLES).*?(?!ENVIRONMENT VARIABLES).*$/gs expect(regex.test(help.output!)).to.be.true }) @@ -76,7 +85,7 @@ describe('Salesforce CLI (sf)', () => { it('should have formatted json success output', async () => { const config = await executor.executeCommand('config list --json') - const result = JSON.parse(config.output!) + const result = parseJson(config.output!) expect(result).to.have.property('status') expect(result).to.have.property('result') expect(result).to.have.property('warnings') @@ -84,7 +93,7 @@ describe('Salesforce CLI (sf)', () => { it('should have formatted json error output', async () => { const config = await executor.executeCommand('config set DOES_NOT_EXIST --json') - const result = JSON.parse(config.output!) + const result = parseJson(config.output!) expect(result).to.have.property('status') expect(result).to.have.property('stack') expect(result).to.have.property('name') @@ -94,7 +103,7 @@ describe('Salesforce CLI (sf)', () => { it('should handle varags', async () => { const config = await executor.executeCommand('config set disableTelemetry=true restDeploy=true --global --json') - const parsed = JSON.parse(config.output!) + const parsed = parseJson(config.output!) expect(parsed.status).to.equal(0) const results = parsed.result as Array<{success: boolean}> for (const result of results) { diff --git a/tsconfig.json b/tsconfig.json index 24c61a738..4e44a525b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ ], "strict": true, "target": "es2019", - "lib": ["es2019"] + "lib": ["es2019"], + "allowSyntheticDefaultImports": true }, "include": [ "./src/**/*" diff --git a/yarn.lock b/yarn.lock index 4bcd24df1..3887df093 100644 --- a/yarn.lock +++ b/yarn.lock @@ -444,29 +444,6 @@ is-wsl "^2.1.1" tslib "^2.0.0" -"@oclif/core@1.0.10": - version "1.0.10" - resolved "https://registry.npmjs.org/@oclif/core/-/core-1.0.10.tgz#5fd01d572e44d372b7279ee0f49b4860e14b6e4e" - integrity sha512-L+IcNU3NoYxwz5hmHfcUlOJ3dpgHRsIj1kAmI9CKEJHq5gBVKlP44Ot179Jke1jKRKX2g9N42izbmlh0SNpkkw== - dependencies: - "@oclif/linewrap" "^1.0.0" - chalk "^4.1.2" - clean-stack "^3.0.1" - cli-ux "6.0.5" - debug "^4.3.3" - fs-extra "^9.1.0" - get-package-type "^0.1.0" - globby "^11.0.4" - indent-string "^4.0.0" - is-wsl "^2.2.0" - lodash "^4.17.21" - semver "^7.3.5" - string-width "^4.2.3" - strip-ansi "^6.0.1" - tslib "^2.3.1" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - "@oclif/core@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@oclif/core/-/core-1.0.4.tgz#79ba3ed554441c3c08de38c3109275f3d9a1c188" @@ -536,29 +513,6 @@ widest-line "^3.1.0" wrap-ansi "^7.0.0" -"@oclif/core@^1.0.8": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@oclif/core/-/core-1.0.9.tgz#5b9db438f5f1f458f9b54114983d73c1f52c9ef0" - integrity sha512-1YQLYWlMdpxHE2W70WrUSgBkWyEsyI8SNST4FAq7kJ+wQCym/XSQeZ1TF+jInHl9aq6DZkG0MAA19hXn4sIIqw== - dependencies: - "@oclif/linewrap" "^1.0.0" - chalk "^4.1.2" - clean-stack "^3.0.1" - cli-ux "^6.0.4" - debug "^4.3.3" - fs-extra "^9.1.0" - get-package-type "^0.1.0" - globby "^11.0.4" - indent-string "^4.0.0" - is-wsl "^2.2.0" - lodash "^4.17.21" - semver "^7.3.5" - string-width "^4.2.3" - strip-ansi "^6.0.1" - tslib "^2.3.1" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - "@oclif/errors@^1.2.1", "@oclif/errors@^1.2.2", "@oclif/errors@^1.3.3", "@oclif/errors@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@oclif/errors/-/errors-1.3.5.tgz#a1e9694dbeccab10fe2fe15acb7113991bed636c" @@ -626,11 +580,16 @@ tslib "^2.0.0" yarn "^1.21.1" -"@oclif/screen@^1.0.3", "@oclif/screen@^1.0.4 ": +"@oclif/screen@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@oclif/screen/-/screen-1.0.4.tgz#b740f68609dfae8aa71c3a6cab15d816407ba493" integrity sha512-60CHpq+eqnTxLZQ4PGHYNwUX572hgpMHGPtTWMjdTMsAvlm69lZV/4ly6O3sAYkomo4NggGcomrDpBe34rxUqw== +"@oclif/screen@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@oclif/screen/-/screen-3.0.2.tgz#969054308fe98d130c02844a45cc792199b75670" + integrity sha512-S/SF/XYJeevwIgHFmVDAFRUvM3m+OjhvCAYMk78ZJQCYCQ5wS7j+LTt1ZEv2jpEEGg2tx/F6TYYWxddNAYHrFQ== + "@oclif/test@^1.2.8": version "1.2.8" resolved "https://registry.yarnpkg.com/@oclif/test/-/test-1.2.8.tgz#a5b2ebd747832217d9af65ac30b58780c4c17c5e" @@ -666,6 +625,13 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== +"@types/ansi-styles@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/ansi-styles/-/ansi-styles-3.2.1.tgz#49e996bb6e0b7957ca831205df31eb9a0702492c" + integrity sha512-UFa7mfKgSutXdT+elzJo8Ulr7FHgLNAyglVIOZYXFNJVQERm8DPrcwPret5BYk66LBE7fwm1XoVGi76MJkQ6ow== + dependencies: + "@types/color-name" "*" + "@types/chai-as-promised@^7.1.4": version "7.1.4" resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.4.tgz#caf64e76fb056b8c8ced4b761ed499272b737601" @@ -690,6 +656,18 @@ dependencies: clean-stack "*" +"@types/cli-progress@^3.9.2": + version "3.9.2" + resolved "https://registry.yarnpkg.com/@types/cli-progress/-/cli-progress-3.9.2.tgz#6ca355f96268af39bee9f9307f0ac96145639c26" + integrity sha512-VO5/X5Ij+oVgEVjg5u0IXVe3JQSKJX+Ev8C5x+0hPy0AuWyW+bF8tbajR7cPFnDGhs7pidztcac+ccrDtk5teA== + dependencies: + "@types/node" "*" + +"@types/color-name@*": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + "@types/ejs@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.1.0.tgz#ab8109208106b5e764e5a6c92b2ba1c625b73020" @@ -717,6 +695,11 @@ dependencies: indent-string "*" +"@types/js-yaml@^3.12.1": + version "3.12.7" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.7.tgz#330c5d97a3500e9c903210d6e49f02964af04a0e" + integrity sha512-S6+8JAYTE1qdsc9HMVsfY7+SgSuUU/Tp6TYTmITW0PZxiyIMvol3Gy//y69Wkhs0ti4py5qgR3uZH6uz/DNzJQ== + "@types/json-schema@^7.0.7": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -727,6 +710,16 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a" integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw== +"@types/lodash@^4.14.117": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + +"@types/minimatch@*": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" + integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== + "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -803,6 +796,11 @@ dependencies: strip-ansi "*" +"@types/supports-color@^8.1.1": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@types/supports-color/-/supports-color-8.1.1.tgz#1b44b1b096479273adf7f93c75fc4ecc40a61ee4" + integrity sha512-dPWnWsf+kzIG140B8z2w3fr5D03TLWbOAFQl45xUpI3vcizeXriNR5VYkWZ+WTMsUHqZ9Xlt3hrxGNANFyNQfw== + "@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" @@ -1221,6 +1219,13 @@ clean-stack@^3.0.0, clean-stack@^3.0.1: dependencies: escape-string-regexp "4.0.0" +cli-progress@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.10.0.tgz#63fd9d6343c598c93542fdfa3563a8b59887d78a" + integrity sha512-kLORQrhYCAtUPLZxqsAt2YJGOvRdt34+O6jl5cQGb7iF3dM55FQZlTR+rQyIK9JUcO9bBMwZsTlND+3dmFU2Cw== + dependencies: + string-width "^4.2.0" + cli-progress@^3.4.0: version "3.9.0" resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.9.0.tgz#25db83447deb812e62d05bac1af9aec5387ef3d4" @@ -1237,37 +1242,6 @@ cli-progress@^3.9.1: colors "^1.1.2" string-width "^4.2.0" -cli-ux@6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cli-ux/-/cli-ux-6.0.5.tgz#5461dffb6c29e4a727e071f8b74bbebcc6b7be08" - integrity sha512-q2pvzDiXMNISMqCBh0P2dkofQ/8OiWlEAjl6MDNk5oUZ6p54Fnk1rOaXxohYm+YkLX5YNUonGOrwkvuiwVreIg== - dependencies: - "@oclif/core" "^1.0.8" - "@oclif/linewrap" "^1.0.0" - "@oclif/screen" "^1.0.4 " - ansi-escapes "^4.3.0" - ansi-styles "^4.2.0" - cardinal "^2.1.1" - chalk "^4.1.0" - clean-stack "^3.0.0" - cli-progress "^3.9.1" - extract-stack "^2.0.0" - fs-extra "^8.1" - hyperlinker "^1.0.0" - indent-string "^4.0.0" - is-wsl "^2.2.0" - js-yaml "^3.13.1" - lodash "^4.17.21" - natural-orderby "^2.0.1" - object-treeify "^1.1.4" - password-prompt "^1.1.2" - semver "^7.3.2" - string-width "^4.2.0" - strip-ansi "^6.0.0" - supports-color "^8.1.0" - supports-hyperlinks "^2.1.0" - tslib "^2.0.0" - cli-ux@^5.1.0: version "5.6.3" resolved "https://registry.yarnpkg.com/cli-ux/-/cli-ux-5.6.3.tgz#eecdb2e0261171f2b28f2be6b18c490291c3a287" @@ -1362,68 +1336,6 @@ cli-ux@^6.0.3: supports-hyperlinks "^2.1.0" tslib "^2.0.0" -cli-ux@^6.0.4: - version "6.0.4" - resolved "https://registry.yarnpkg.com/cli-ux/-/cli-ux-6.0.4.tgz#36acb0a30fda27a6c59686a44d783b7c92a3dad0" - integrity sha512-9B7pLM1kPXQTHHvZtEKHLpJc9BDHIHZOWuPmJX/O+ZSO8zKmfV9A1rRiq8HNOMG7+Yuy3rtCgFqFl/KB7ZnYiQ== - dependencies: - "@oclif/core" "^1.0.7" - "@oclif/linewrap" "^1.0.0" - "@oclif/screen" "^1.0.3" - ansi-escapes "^4.3.0" - ansi-styles "^4.2.0" - cardinal "^2.1.1" - chalk "^4.1.0" - clean-stack "^3.0.0" - cli-progress "^3.9.1" - extract-stack "^2.0.0" - fs-extra "^8.1" - hyperlinker "^1.0.0" - indent-string "^4.0.0" - is-wsl "^2.2.0" - js-yaml "^3.13.1" - lodash "^4.17.21" - natural-orderby "^2.0.1" - object-treeify "^1.1.4" - password-prompt "^1.1.2" - semver "^7.3.2" - string-width "^4.2.0" - strip-ansi "^6.0.0" - supports-color "^8.1.0" - supports-hyperlinks "^2.1.0" - tslib "^2.0.0" - -cli-ux@^6.0.6: - version "6.0.6" - resolved "https://registry.npmjs.org/cli-ux/-/cli-ux-6.0.6.tgz#00536bf6038f195b0a1a2589f61ce5e625e75870" - integrity sha512-CvL4qmV78VhnbyHTswGjpDSQtU+oj3hT9DP9L6yMOwiTiNv0nMjMEV/8zou4CSqO6PtZ2A8qnlZDgAc07Js+aw== - dependencies: - "@oclif/core" "1.0.10" - "@oclif/linewrap" "^1.0.0" - "@oclif/screen" "^1.0.4 " - ansi-escapes "^4.3.0" - ansi-styles "^4.2.0" - cardinal "^2.1.1" - chalk "^4.1.0" - clean-stack "^3.0.0" - cli-progress "^3.9.1" - extract-stack "^2.0.0" - fs-extra "^8.1" - hyperlinker "^1.0.0" - indent-string "^4.0.0" - is-wsl "^2.2.0" - js-yaml "^3.13.1" - lodash "^4.17.21" - natural-orderby "^2.0.1" - object-treeify "^1.1.4" - password-prompt "^1.1.2" - semver "^7.3.2" - string-width "^4.2.0" - strip-ansi "^6.0.0" - supports-color "^8.1.0" - supports-hyperlinks "^2.1.0" - tslib "^2.0.0" - cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -2749,7 +2661,7 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -natural-orderby@^2.0.1: +natural-orderby@^2.0.1, natural-orderby@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/natural-orderby/-/natural-orderby-2.0.3.tgz#8623bc518ba162f8ff1cdb8941d74deb0fdcc016" integrity sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q== @@ -3408,7 +3320,7 @@ strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1. resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -supports-color@8.1.1, supports-color@^8.1.0: +supports-color@8.1.1, supports-color@^8.1.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -3429,7 +3341,7 @@ supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: dependencies: has-flag "^4.0.0" -supports-hyperlinks@^2.1.0: +supports-hyperlinks@^2.1.0, supports-hyperlinks@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== @@ -3576,10 +3488,10 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -typescript@4.5.2: - version "4.5.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" - integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== +typescript@4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.4.tgz#2cd01a1a1f160704d3101fd5a58ff0f9fcb8030c" + integrity sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA== universalify@^0.1.0: version "0.1.2"