diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index c119b32..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,38 +0,0 @@ -# Contributing - -We love pull requests. And following this guidelines will make your pull request easier to merge - -## Prerequisites - -- Install [EditorConfig](http://editorconfig.org/) plugin for your code editor to make sure it uses correct settings. -- Fork the repository and clone your fork. -- Install dependencies: `npm install`. - -## Coding style - -We make use of [standard](https://standardjs.com/) to lint our code. Standard does not need a config file and comes with set of non-configurable rules. - -## Development work-flow - -Always make sure to lint and test your code before pushing it to the GitHub. - -```bash -npm test -``` - -Just lint the code - -```bash -npm run lint -``` - -**Make sure you add sufficient tests for the change**. - -## Other notes - -- Do not change version number inside the `package.json` file. -- Do not update `CHANGELOG.md` file. - -## Need help? - -Feel free to ask. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b77924c..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Description - -If this is a feature request, explain why it should be added. Specific use-cases are best. - -For bug reports, please provide as much *relevant* info as possible. - -## Package version - - -## Error Message & Stack Trace - -## Relevant Information diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 888266a..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/poppinss/cliui/blob/master/.github/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..3536d96 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,14 @@ +name: checks +on: + - push + - pull_request + +jobs: + test: + uses: poppinss/.github/.github/workflows/test.yml@main + + lint: + uses: poppinss/.github/.github/workflows/lint.yml@main + + typecheck: + uses: poppinss/.github/.github/workflows/typecheck.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index e01be89..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - linux: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: - - 16.13.1 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test - windows: - runs-on: windows-latest - strategy: - matrix: - node-version: - - 16.13.1 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..4002db7 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 54532b0..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -npx doctoc README.md --title='## Table of contents' -git add README.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/README.md b/README.md index 0d9376f..461c60e 100644 --- a/README.md +++ b/README.md @@ -1,353 +1,391 @@ -![](./assets/logger.png) - -# CLI UI - -> Command line UI Kit used by AdonisJS - -This repo is a command line UI Kit used by the AdonisJS framework to design its command line interfaces. - -The kit is highly opinionated and we will not allow configurable settings in the near future. We want to be consistent with our UI's without worrying about the configuration. - -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] - - - -## Table of contents +# @poppinss/cliui +> Opinionated UI KIT for Command Line apps -- [Installation](#installation) -- [Usage](#usage) -- [Logger](#logger) - - [`success(message, prefix?, suffix?)`](#successmessage-prefix-suffix) - - [`error(message, prefix?, suffix?)`](#errormessage-prefix-suffix) - - [`fatal(message, prefix?, suffix?)`](#fatalmessage-prefix-suffix) - - [`warning(message, prefix?, suffix?)`](#warningmessage-prefix-suffix) - - [`info(message, prefix?, suffix?)`](#infomessage-prefix-suffix) - - [`debug(message, prefix?, suffix?)`](#debugmessage-prefix-suffix) - - [`log(message)`](#logmessage) - - [`logError(message)`](#logerrormessage) - - [`logUpdate(message)`](#logupdatemessage) -- [Action](#action) - - [`action.succeeded(message)`](#actionsucceededmessage) - - [`action.‌skipped(message)`](#action%E2%80%8Cskippedmessage) - - [`action.failed(message, errorMessage)`](#actionfailedmessage-errormessage) -- [Instructions](#instructions) -- [Sticker](#sticker) -- [Tasks](#tasks) - - [Task Renderers](#task-renderers) - - [Running tasks](#running-tasks) - - [Verbose renderer](#verbose-renderer) +[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] - +## Why this package exists? +CLI UI is an opinionated UI Kit to **log messages**, **render tables**, **display spinners**, and much more. Following are some of the reasons for creating this package. -## Installation +- First-class support for testing the UI kit output. +- Using a standard set of design elements without allowing them to be configurable. Choosing consistency over configurability. -Install the package from the npm registry by running following command. +## Basic usage +Install the package from the npm registry as follows: ```sh npm i @poppinss/cliui -# Yarn users +# yarn lovers yarn add @poppinss/cliui ``` -## Usage - -Import the components you want to use from the package. - -```ts -import { logger, instructions, sticker, tasks, table } from '@poppinss/cliui' -``` +Import the `cliui` and create an instance of it. ```ts -logger.info('hello world') - -const spinner = logger.await('downloading') +import { cliui } from '@poppinss/cliui' +const ui = cliui() -await someTimeConsumingTask() -spinner.stop() +ui.logger.info('This is an info message') +// [ info ] This is an info message ``` -## Logger +Now, let's say you are testing a command and want to assert that an info message is logged during the command's execution. -![](./assets/logger.png) - -The logger exposes the following methods. - -#### `success(message, prefix?, suffix?)` +Usually, you will have to hack into the `process.stdout` stream and collect messages within memory (for assertion) and strip any colors (aka ansi sequences) from the output. -Log success message. The message is printed to `stdout`. +However, with this package, you can turn on the `raw` mode to collect the logger messages within memory and turn off all color transformations. ```ts -logger.success('Account created') - -// [ success ] Account created -``` +// RAW MODE ON +const ui = cliui({ raw: true }) -Optional prefix +ui.logger.info('This is an info message') -```ts -logger.success('Account created', 'ap-south-1') - -// [ap-south-1] [ success ] Account created +const logs = ui.logger.getRenderer().getLogs() +assert.deepEqual(logs, [ + { + stream: 'stdout', + message: '[ cyan(info) ] This is an info message' + } +]) ``` -Optional suffix +Similarly, you can assert that an error message is logged to `stderr`. ```ts -logger.success('Account created', undefined, 'ap-south-1') +const ui = cliui({ raw: true }) -// [ success ] Account created (ap-south-1) -``` +ui.logger.error('Something went wrong') -> The prefix and suffix are support on all logger methods except `logger.action` +const logs = ui.logger.getRenderer().getLogs() +assert.deepEqual(logs, [ + { + stream: 'stderr', + message: '[ red(error) ] Something went wrong' + } +]) +``` -### `error(message, prefix?, suffix?)` +## Logger +The logger displays all the log messages with consistent styling. Following are the available logging methods. -Log an error message. The message is printed to `stderr`. +![](./assets/logger.png) ```ts -logger.error('Unable to write. Disk full') +import { cliui } from '@poppinss/cliui' +const ui = cliui() -// Or log error object -logger.error(new Error('Unable to write. Disk full')) +// Writes to stdout +logger.debug('Something just happened') +logger.info('This is an info message') +logger.success('Account created') +logger.warning('Running out of disk space') -// [ error ] Unable to write. Disk full +// Writes to stderr +logger.error(new Error('Unable to write. Disk full')) +logger.fatal(new Error('Unable to write. Disk full')) ``` -### `fatal(message, prefix?, suffix?)` - -The `logger.error` does not print the error stack. You must use `logger.fatal` to print the error stack. +You can also define the `prefix` and `suffix` for the log message. The prefix and suffix are displayed with lower opacity (the dim color transformation is used). ```ts -logger.fatal(new Error('Unable to write. Disk full')) +logger.info('Install packages', { suffix: 'npm i --production' }) ``` -![](./assets/stack.png) +You can display current time as a prefix using the `%time%` keyword. -### `warning(message, prefix?, suffix?)` +```ts +logger.info('Message with time prefix', { prefix: '%time%' }) +``` -Print a warning message. Message is written to `stdout`. +### Loading animation +You can display a log message with a loading animation using the `logger.await` method. The method accepts the initial message to display alongside an optional `prefix` or `suffix`. ```ts -logger.warning('Running out of disk space') +const loader = logger.await('installing packages', { suffix: 'npm i' }) -// [ warn ] Running out of disk space -``` +// Start animation +loader.start() + +// Update the message +loader.update('unpacking packages', { suffix: undefined }) -### `info(message, prefix?, suffix?)` +// Stop loader +loader.stop() +``` -Print an info message. Message is again written to `stdout`. +### Preparing messages without writing them +You can also use the logger to just prepare the message (with colors and formatting) without writing it to the output stream. Just prefix the log message with `prepare` and it will return a string value. ```ts -logger.info('Your account is has been updated') - -// [ info ] Your account is has been updated +const debugMessage = logger.prepareDebug('Something just happened') +const infoMessage = logger.prepareInfo('This is an info message') +const successMessage = logger.prepareSuccess('Account created') +const warningMessage = logger.prepareWarning('Running out of disk space') ``` -### `debug(message, prefix?, suffix?)` - -Print a debug message. Message is printed to `stdout`. +### Testing logger output +First, you must instantiate the `cliui` in raw mode to collect all logs messages within memory. And then you can access the logs using `logger.getRenderer()` to write assertions. ```ts -logger.debug('Something just happened') +const ui = cliui({ raw: true }) + +ui.logger.info('Hello world') -// [ debug ] Something just happened +const logs = ui.logger.getRenderer().getLogs() +console.log(logs) ``` -### `log(message)` +## Logger actions +Logger actions are pre-styled logs to display the outcome of an action. For example, the action can be to create/update or delete a file. -Similar to `console.log`, but instead uses the Logger renderer. +![](./assets/actions.png) -> We will talk about renderers later in this document, since they make testing of log message little bit easier. +You can create an action by calling the `logger.action` method and pass the message to display. Once, done perfoming the underlying operation, you can either mark the action as `succeeded`, `skipped`, or `failed`. ```ts -logger.log('hello world') +logger + .action('Creating config/auth.ts') + .displayDuration() + .succeeded() + +logger + .action('Updating .tsconfig.json') + .succeeded() + +logger + .action('Creating app/Models/User.ts') + .skipped('File already exists') + +logger + .action('Creating server.ts') + .failed(new Error('File already exists')) ``` -### `logError(message)` +## Table +You can create a table using the `ui.table` method. Under the hood, we are using [cli-table3](https://www.npmjs.com/package/cli-table3) but only expose some of its configuration options for consistency. -Similar to `console.error`, but instead use the Logger renderer. +![](./assets/table.png) ```ts -log.logError('this is an error message') +const ui = cliui() +const table = ui.table() + +table + .head([ + 'Migration', + 'Duration', + 'Status', + ]) + .row([ + '1590591892626_tenants.ts', + '2ms', + 'DONE' + ]) + .row([ + '1590595949171_entities.ts', + '2ms', + 'DONE' + ]) + .render() ``` -### `logUpdate(message)` - -Log a message that overwrites the previously logged message. The method is helpful for building progress bars or animations. +You can apply color transforms to any value when rendering the table. For example: ```ts -logger.logUpdate(`downloading ${i}%`) - -// Once completed, persist the message on console -logger.logUpdatePersist() +table.row([ + '1590595949171_entities.ts', + '2', + ui.colors.green('DONE') +]) ``` -Here is a complete example of showing the downloading progress. +### Right-align columns +You can right-align the columns by defining them as objects and using the `hAlign` property. Also, make sure to align the header column right as well. ```ts -const sleep = () => new Promise((resolve) => setTimeout(resolve, 50)) - -async function run() { - for (let i = 0; i <= 100; i = i + 2) { - await sleep() - logger.logUpdate(`downloading ${i}%`) +table + .head([ + 'Migration', + 'Batch' + { + content: 'Status', + hAlign: 'right' + }, + ]) + +table.row([ + '1590595949171_entities.ts', + '2', + { + content: ui.colors.green('DONE'), + hAlign: 'right' } - - logger.logUpdatePersist() -} - -run() +]) ``` -![](./assets/logger-update.gif) - -## Action - -![](./assets/actions.png) +### Render full width +You can render tables in full width (taking all the space of the terminal) by calling the `table.fullWidth` method. In full-width mode: -In order to log results of an action/task, we make use of the `action` method. +- We will render all columns as per the size of the content. +- Except for the first column, which takes all the available space. ```ts -const action = logger.action('create') -action.succeeded('config/auth.ts') +table.fullWidth() ``` -An action can end in one of the following states. - -### `action.succeeded(message)` - -Action completed successfully +You can also change the column index for the fluid column (the one that takes all the space) by calling the `table.fluidColumnIndex` method. ```ts -const action = logger.action('create') -action.succeeded('config/auth.ts') +table + .fullWidth() + .fluidColumnIndex(1) ``` -### `action.‌skipped(message)` - -Skipped action +### Testing table output +First, you must instantiate the `cliui` in raw mode to collect all logs messages within memory. And then you can access the table output using `logger.getRenderer()` to write assertions. ```ts -const action = logger.action('create') -action.skipped('app/Models/User.ts') -``` - -### `action.failed(message, errorMessage)` +const ui = cliui({ raw: true }) +const table = ui.table() -Action failed, an error message is required to share more context +table + .head(['Migration','Duration', 'Status']) + .row([ '1590591892626_tenants.ts', '2ms', 'DONE']) + .render() -```ts -const action = logger.action('create') -action.failed('server.ts', 'File already exists') +const logs = table.getRenderer().getLogs() +console.log(logs) ``` ## Instructions +The instructions widget allows you to render a box with steps. Each step gets prefixed with an arrow `>`. ![](./assets/instructions.png) -Instructions are mainly the steps we want someone to perform in order to achieve something. For example: - -- Display instructions to start the development -- Or display instructions to bundle the code for production - ```ts -import { instructions, logger } from '@poppinss/cliui' +const ui = cliui() +const instructions = ui.instructions() -instructions() - .add(`cd ${logger.colors.cyan('hello-world')}`) - .add(`Run ${logger.colors.cyan('node ace serve --watch')} to start the server`) +instructions + .add(`cd ${colors.cyan('hello-world')}`) + .add(`Run ${colors.cyan('node ace serve --watch')} to start the server`) .render() ``` -- Calling the `instructions()` begins a new instructions block -- Next, you can add new lines by using the `.add()` method. -- Finally, call the `render()` method to render it on the console. - ## Sticker +The sticker widget is the same as the `instructions` widget. But it does not prefix each line with an arrow `>`. ![](./assets/sticker.png) -Similar to the **instructions**, but a sticker does not prefix the lines with a pointer `>` arrow. Rest is all same. - -It is helpful for displaying a message that needs the most attention. For example: - -- Update the CLI version -- Or, the address to access the local server - ```ts -import { sticker, logger } from '@poppinss/cliui' +const ui = cliui() +const sticker = ui.instructions() -sticker() +sticker .add('Started HTTP server') .add('') - .add(`Local address: ${logger.colors.cyan('http://localhost:3333')}`) - .add(`Network address: ${logger.colors.cyan('http://localhost:3333')}`) + .add(`Local address: ${colors.cyan('http://localhost:3333')}`) + .add(`Network address: ${colors.cyan('http://192.168.1.2:3333')}`) .render() ``` -## Tasks +### Testing instructions and sticker output +First, you must instantiate the `cliui` in raw mode to collect all logs messages within memory. And then you can access the instructions/sticker output using `logger.getRenderer()` to write assertions. -We make use of tasks when performing multiple actions in respond to a command. For example: +```ts +const ui = cliui({ raw: true }) +const instructions = ui.instructions() -- Create a new AdonisJS app -- Or, Setup packages after installation +instructions + .add(`cd ${colors.cyan('hello-world')}`) + .add(`Run ${colors.cyan('node ace serve --watch')} to start the server`) + .render() -The UI for the tasks is designed to only handle tasks running in sequence. +const logs = instructions.getRenderer().getLogs() +console.log(logs) +``` -### Task Renderers +## Tasks +The tasks widget allows rendering a list of tasks to perform. Each task has an associated async callback to **perform the task**, **report its progress**, and also **mark it as succeeded or failed**. -Task has two renderers `minimal` and `verbose`. The minimal renderer is the default choice and switch to `verbose` in one of the following situations. +![](./assets/tasks-minimal.gif) -- Command line is not interactive (no tty) -- Or someone has explicitly opted for verbose output. +- The return value of the callback function is used as the success message. +- You can throw an Error to mark the task as failed. Or, call the `task.error` method to prepare an error from a string value. -### Running tasks +```ts +const ui = cliui() +const tasks = ui.tasks() -Following is a very simple example of creating and running multiple tasks. +await tasks + .add('clone repo', async (task) => { + return 'Completed' + }) + .add('update package file', async (task) => { + return task.error('Unable to update package file') + }) + .add('install dependencies', async (task) => { + return 'Installed' + }) + .run() +``` -```ts -import { tasks } from '@poppinss/cliui' +### Reporting task progress +Instead of writing the task progress messages to the console directly, you recommend you call the `task.update` method. -await tasks() - .add('clone repo', async (logger, task) => { - logger.info(`cloning ${someRepoUrl}`) +The method ensures to display of the latest log message only when using the `minimal` renderer and logs all messages when using the `verbose` renderer. - await performClone() - await task.complete() - }) - .add('install dependencies', async (logger, task) => { - const spinner = logger.await('running npm install') +```ts +const sleep = () => new Promise((resolve) => setTimeout(resolve, 50)) - await performInstall() - spinner.stop() +tasks + .add('clone repo', async (task) => { + for (let i = 0; i <= 100; i = i + 2) { + await sleep() + task.update(`Downloaded ${i}%`) + } - await task.complete() + return 'Completed' }) - .run() ``` -- The `add` method accepts the **task title** and the callback function to invoke in order to perform the task -- Once, you are done with the task jobs, you must call `await task.complete()` to complete the task. The `await` is important here. -- In order to **mark task as failed**, you can call the `task.fail` method. All upcoming tasks will be stopped in case of a failure. - `ts await task.fail(new Error('Network error'))` +### Using verbose renderer +The `verbose` renderer displays all the log messages instead of just the latest one. Also, the task output is rendered differently from the minimal renderer. Please, check the [example](./example/tasks.ts) file for the same. -By default, the `minimal` renderer is used and pivots to the verbose renderer only when terminal is not interactive. +![](./assets/tasks-verbose.gif) -![](./assets/tasks-minimal.gif) +You can create the tasks instance with a `verbose` renderer as follows. The rest of the API is the same. -### Verbose renderer +```ts +const tasks = ui.tasks({ verbose: true }) +``` -In order to run tasks explicitly in the verbose mode, you can create the tasks instance using `tasks.verbose()` method. +### Testing tasks output +First, you must instantiate the `cliui` in raw mode to collect all logs messages within memory. And then you can access the tasks output using `logger.getRenderer()` to write assertions. ```ts -tasks.verbose().add().add().run() -``` +const ui = cliui({ raw: true }) +const tasks = ui.tasks() -![](./assets/tasks-verbose.gif) +await tasks + .add('clone repo', async (task) => { + return 'Completed' + }) + .add('update package file', async (task) => { + return task.error('Unable to update package file') + }) + .add('install dependencies', async (task) => { + return 'Installed' + }) + .run() -[gh-workflow-image]: https://img.shields.io/github/workflow/status/poppinss/cliui/test?style=for-the-badge -[gh-workflow-url]: https://github.com/poppinss/cliui/actions/workflows/test.yml "Github action" +const logs = tasks.getRenderer().getLogs() +console.log(logs) +``` + +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/poppinss/cliui/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/poppinss/cliui/actions/workflows/checks.yml "Github action" [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript [typescript-url]: "typescript" @@ -357,6 +395,3 @@ tasks.verbose().add().add().run() [license-image]: https://img.shields.io/npm/l/@poppinss/cliui?color=blueviolet&style=for-the-badge [license-url]: LICENSE.md 'license' - -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/poppinss/cliui?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/poppinss/cliui?targetFile=package.json 'synk' diff --git a/api.ts b/api.ts deleted file mode 100644 index 33d8a93..0000000 --- a/api.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import colorSupport from 'color-support' - -import { icons } from './src/Icons' -import { Table } from './src/Table' -import { Logger } from './src/Logger' -import { TaskManager } from './src/Task/Manager' -import { Instructions } from './src/Instructions' -import { MemoryRenderer } from './src/Renderer/Memory' -import { ConsoleRenderer } from './src/Renderer/Console' - -export function instantiate(testing: boolean) { - /** - * Is terminal interactive or not. The code is copied from - * https://github.com/sindresorhus/is-interactive/blob/master/index.js. - * - * Yes, we can install it as a dependency, but decided to copy/paste 4 - * lines. NO STRONG REASONS BEHIND IT - */ - const isInteractive = Boolean( - process.stdout && process.stdout.isTTY && process.env.TERM !== 'dumb' && !('CI' in process.env) - ) - - /** - * Whether or not colors are enabled. They are enabled by default, - * unless the terminal doesn't support color. Also "FORCE_COLOR" - * env variable enables them forcefully. - */ - const supportsColors = !!process.env.FORCE_COLOR || colorSupport.level > 0 - - /** - * The renderer used in the testing mode. One can access it to listen - * for the log messages. Also, the memory renderer only works when - * the "CLI_UI_IS_TESTING" flag is set - */ - const testingRenderer = new MemoryRenderer() - - /** - * Console renderer outputs to the console. We do not export it, since one - * cannot do much by having an access to it. - */ - const consoleRenderer = new ConsoleRenderer() - - /** - * Logger - */ - const logger = new Logger({ colors: supportsColors, interactive: isInteractive }, testing) - logger.useRenderer(testing ? testingRenderer : consoleRenderer) - - /** - * Reference to the instructions block to render a set of lines inside - * a box. - */ - const instructions = () => { - const instructionsInstance = new Instructions({ colors: supportsColors, icons: true }, testing) - instructionsInstance.useRenderer(testing ? testingRenderer : consoleRenderer) - return instructionsInstance - } - - /** - * Similar to instructions. But the lines are not prefix with a pointer `>` - */ - const sticker = () => { - const stickerInstance = new Instructions({ colors: supportsColors, icons: false }, testing) - stickerInstance.useRenderer(testing ? testingRenderer : consoleRenderer) - return stickerInstance - } - - /** - * Initiates a group of tasks - */ - const tasks = () => { - const manager = new TaskManager({ colors: supportsColors, interactive: isInteractive }, testing) - manager.useRenderer(testing ? testingRenderer : consoleRenderer) - return manager - } - - /** - * Initiate tasks in verbose mode - */ - tasks.verbose = () => { - const manager = new TaskManager( - { colors: supportsColors, interactive: isInteractive, verbose: true }, - testing - ) - manager.useRenderer(testing ? testingRenderer : consoleRenderer) - return manager - } - - /** - * Instantiate a new table - */ - const table = () => { - const tableInstance = new Table({ colors: supportsColors }, testing) - tableInstance.useRenderer(testing ? testingRenderer : consoleRenderer) - return tableInstance - } - - return { - table, - tasks, - icons, - logger, - sticker, - instructions, - isInteractive, - supportsColors, - consoleRenderer, - testingRenderer, - } -} diff --git a/assets/actions.png b/assets/actions.png index 4aebc38..6c47fc1 100644 Binary files a/assets/actions.png and b/assets/actions.png differ diff --git a/assets/instructions.png b/assets/instructions.png index 0de8a18..81de095 100644 Binary files a/assets/instructions.png and b/assets/instructions.png differ diff --git a/assets/logger-update.gif b/assets/logger-update.gif deleted file mode 100644 index f6f1c74..0000000 Binary files a/assets/logger-update.gif and /dev/null differ diff --git a/assets/logger.png b/assets/logger.png index 82b35a6..0171b98 100644 Binary files a/assets/logger.png and b/assets/logger.png differ diff --git a/assets/stack.png b/assets/stack.png deleted file mode 100644 index a099c02..0000000 Binary files a/assets/stack.png and /dev/null differ diff --git a/assets/sticker.png b/assets/sticker.png index 5220403..764f9d7 100644 Binary files a/assets/sticker.png and b/assets/sticker.png differ diff --git a/assets/table.png b/assets/table.png new file mode 100644 index 0000000..c198cbd Binary files /dev/null and b/assets/table.png differ diff --git a/assets/tasks-minimal.gif b/assets/tasks-minimal.gif index bd280a7..297585f 100644 Binary files a/assets/tasks-minimal.gif and b/assets/tasks-minimal.gif differ diff --git a/assets/tasks-verbose.gif b/assets/tasks-verbose.gif index fef4f80..2ec4af8 100644 Binary files a/assets/tasks-verbose.gif and b/assets/tasks-verbose.gif differ diff --git a/bin/japaTypes.ts b/bin/japaTypes.ts deleted file mode 100644 index d42cac6..0000000 --- a/bin/japaTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Assert } from '@japa/assert' - -declare module '@japa/runner' { - interface TestContext { - assert: Assert - } -} diff --git a/bin/test.ts b/bin/test.ts index 5aba7ce..87d9bf0 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,7 +1,5 @@ import { assert } from '@japa/assert' -import { specReporter } from '@japa/spec-reporter' -import { runFailedTests } from '@japa/run-failed-tests' -import { processCliArgs, configure, run } from '@japa/runner' +import { processCLIArgs, configure, run } from '@japa/runner' /* |-------------------------------------------------------------------------- @@ -16,14 +14,10 @@ import { processCliArgs, configure, run } from '@japa/runner' | | Please consult japa.dev/runner-config for the config docs. */ +processCLIArgs(process.argv.slice(2)) configure({ - ...processCliArgs(process.argv.slice(2)), - ...{ - files: ['test/**/*.spec.ts'], - plugins: [assert(), runFailedTests()], - reporters: [specReporter()], - importer: (filePath: string) => import(filePath), - }, + files: ['tests/**/*.spec.ts'], + plugins: [assert()], }) /* diff --git a/example/action.ts b/example/action.ts deleted file mode 100644 index a0760ae..0000000 --- a/example/action.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { logger } from '../index' - -console.log(logger.colors.dim('-----------------------------------------------')) -console.log(logger.colors.yellow(' ACTIONS ')) -console.log(logger.colors.dim('-----------------------------------------------')) -console.log() - -logger.action('create').succeeded('config/auth.ts') -logger.action('update').succeeded('.tsconfig.json') -logger.action('create').skipped('app/Models/User.ts') -logger.action('create').failed('server.ts', 'File already exists') diff --git a/example/instructions.ts b/example/instructions.ts deleted file mode 100644 index 8bdbb3f..0000000 --- a/example/instructions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { instructions, logger } from '../index' - -instructions() - .add(`cd ${logger.colors.cyan('hello-world')}`) - .add(`Run ${logger.colors.cyan('node ace serve --watch')} to start the server`) - .render() diff --git a/example/logger.ts b/example/logger.ts deleted file mode 100644 index 3a53dd6..0000000 --- a/example/logger.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { logger } from '../index' - -console.log(logger.colors.dim('-----------------------------------------------')) -console.log(logger.colors.yellow(' LOGGER ')) -console.log(logger.colors.dim('-----------------------------------------------')) - -logger.info('This is an info message') -logger.warning('Running out of disk space') -logger.error(new Error('Unable to write. Disk full')) -logger.fatal(new Error('Unable to write. Disk full')) -logger.debug('Something just happened') -logger.success('Account created') -logger.info('Message with time prefix', '%time%') -logger.await('installing dependencies', undefined, 'npm install --production') diff --git a/example/sticker.ts b/example/sticker.ts deleted file mode 100644 index 4095237..0000000 --- a/example/sticker.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { sticker, logger } from '../index' - -sticker() - .add('Started HTTP server') - .add('') - .add(`Local address: ${logger.colors.cyan('http://localhost:3333')}`) - .add(`Network address: ${logger.colors.cyan('http://localhost:3333')}`) - .render() diff --git a/example/table.ts b/example/table.ts deleted file mode 100644 index ce630bf..0000000 --- a/example/table.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { table, logger } from '../index' - -table() - .head(['migration', 'status']) - .row(['1590591892626_tenants.ts', logger.colors.green('completed')]) - .row(['1590595949171_entities.ts', logger.colors.green('completed')]) - .row(['1590848460221_entity_settings.ts', logger.colors.green('completed')]) - .row(['1591340552017_entity_tds_credentials.ts', logger.colors.green('completed')]) - .row(['1591340559270_entity_esic_credentials.ts', logger.colors.green('completed')]) - .row(['1591340566450_entity_epfo_credentials.ts', logger.colors.green('completed')]) - .row(['1592217725114_entity_job_titles.ts', logger.colors.green('completed')]) - .row(['1592217725115_employees.ts', logger.colors.green('completed')]) - .row(['1592218021413_employee_profiles.ts', logger.colors.green('completed')]) - .row(['1592726938920_employee_emails.ts', logger.colors.yellow('pending')]) - .row(['1592726949565_employee_phone_numbers.ts', logger.colors.yellow('pending')]) - .row(['1592728640792_employee_invites.ts', logger.colors.yellow('pending')]) - .render() diff --git a/example/tasks-minimal.ts b/example/tasks-minimal.ts deleted file mode 100644 index bbd612b..0000000 --- a/example/tasks-minimal.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { tasks } from '../index' - -const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) - -tasks() - .add('clone repo', async (logger, task) => { - await sleep(200) - logger.log(`cloning repo ${logger.colors.cyan('https://github.com/adonisjs/core')}`) - - await sleep(1000) - logger.log(`downloaded ${logger.colors.cyan('10%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('20%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('30%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('40%')}`) - - await sleep(500) - logger.log(`downloaded ${logger.colors.cyan('80%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('90%')}`) - await task.complete() - }) - .add('update package file', async (logger, task) => { - await sleep(200) - logger.action('update').succeeded('package.json') - await sleep(500) - await task.complete() - }) - .add('install dependencies', async (logger, task) => { - await sleep(200) - const spinner = logger.await('installing') - await sleep(1000) - - spinner.update('updating') - - await sleep(3000) - spinner.stop() - await task.fail(new Error('ahuh')) - }) - .run() diff --git a/example/tasks-verbose.ts b/example/tasks-verbose.ts deleted file mode 100644 index 694faf3..0000000 --- a/example/tasks-verbose.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { tasks } from '../index' - -const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) - -tasks - .verbose() - .add('clone repo', async (logger, task) => { - await sleep(200) - logger.log(`cloning repo ${logger.colors.cyan('https://github.com/adonisjs/core')}`) - - await sleep(1000) - logger.log(`downloaded ${logger.colors.cyan('10%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('20%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('30%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('40%')}`) - - await sleep(500) - logger.log(`downloaded ${logger.colors.cyan('80%')}`) - - await sleep(200) - logger.log(`downloaded ${logger.colors.cyan('90%')}`) - await task.complete() - }) - .add('update package file', async (logger, task) => { - await sleep(200) - logger.action('update').succeeded('package.json') - await sleep(500) - await task.complete() - }) - .add('install dependencies', async (logger, task) => { - await sleep(200) - const spinner = logger.await('installing') - - await sleep(3000) - spinner.stop() - await task.complete() - }) - .run() diff --git a/examples/action.ts b/examples/action.ts new file mode 100644 index 0000000..ef86294 --- /dev/null +++ b/examples/action.ts @@ -0,0 +1,11 @@ +import { cliui } from '../index.js' +const ui = cliui() +const logger = ui.logger + +console.log() + +logger.action('Creating config/auth.ts').displayDuration().succeeded() +logger.action('Updating .tsconfig.json').succeeded() +logger.action('Creating app/Models/User.ts').skipped('File already exists') +logger.action('Creating server.ts').failed('File already exists') +logger.action('Creating server.ts').failed(new Error('File already exists')) diff --git a/examples/instructions.ts b/examples/instructions.ts new file mode 100644 index 0000000..aafebca --- /dev/null +++ b/examples/instructions.ts @@ -0,0 +1,10 @@ +import { cliui } from '../index.js' +const ui = cliui() +const instructions = ui.instructions() + +console.log('') + +instructions + .add(`cd ${ui.colors.cyan('hello-world')}`) + .add(`Run ${ui.colors.cyan('node ace serve --watch')} to start the server`) + .render() diff --git a/examples/logger.ts b/examples/logger.ts new file mode 100644 index 0000000..65140a3 --- /dev/null +++ b/examples/logger.ts @@ -0,0 +1,19 @@ +import { cliui } from '../index.js' +const ui = cliui() +const logger = ui.logger + +console.log('') + +logger.info('This is an info message') +logger.info('Message with time prefix', { prefix: '%time%' }) +logger.warning('Running out of disk space') +logger.error(new Error('Unable to write. Disk full')) +logger.fatal(new Error('Unable to write. Disk full')) +logger.debug('Something just happened') +logger.success('Account created') + +const spinner = logger.await('installing dependencies', { suffix: 'npm install --production' }) +spinner.start() +setTimeout(() => { + spinner.stop() +}, 2000) diff --git a/example/logger-update.ts b/examples/logger_update.ts similarity index 55% rename from example/logger-update.ts rename to examples/logger_update.ts index bce1c23..d390ec8 100644 --- a/example/logger-update.ts +++ b/examples/logger_update.ts @@ -1,11 +1,15 @@ -import { logger } from '../index' +import { cliui } from '../index.js' +const ui = cliui() +const logger = ui.logger + +console.log('') const sleep = () => new Promise((resolve) => setTimeout(resolve, 50)) async function run() { for (let i = 0; i <= 100; i = i + 2) { await sleep() - logger.logUpdate(`downloading ${i}%`) + logger.logUpdate(`downloading ${ui.colors.yellow(`${i}%`)}`) } logger.logUpdatePersist() diff --git a/examples/sticker.ts b/examples/sticker.ts new file mode 100644 index 0000000..550079c --- /dev/null +++ b/examples/sticker.ts @@ -0,0 +1,14 @@ +import { cliui } from '../index.js' +const ui = cliui() +const sticker = ui.sticker() + +console.log('') + +sticker + .fullScreen() + .drawBorder((borderChar, colors) => colors.cyan(borderChar)) + .add('Started HTTP server') + .add('') + .add(`Local address: ${ui.colors.cyan('http://localhost:3333')}`) + .add(`Network address: ${ui.colors.cyan('http://192.168.1.2:3333')}`) + .render() diff --git a/examples/table.ts b/examples/table.ts new file mode 100644 index 0000000..72633a1 --- /dev/null +++ b/examples/table.ts @@ -0,0 +1,66 @@ +import { cliui } from '../index.js' +const ui = cliui() + +const table = ui.table() + +console.log('') + +table + .head([ + ui.colors.dim('Migration'), + { content: ui.colors.dim('Duration'), hAlign: 'right' }, + { content: ui.colors.dim('Status'), hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1590591892626_tenants.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1590595949171_entities.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1590848460221_entity_settings.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1591340566450_entity_epfo_credentials.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592217725114_entity_job_titles.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592217725115_employees.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592218021413_employee_profiles.ts'), + { content: `${ui.colors.dim('2ms')}`, hAlign: 'right' }, + { content: `${ui.colors.green('DONE')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592726938920_employee_emails.ts'), + '', + { content: `${ui.colors.yellow('PENDING')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592726949565_employee_phone_numbers.ts'), + '', + { content: `${ui.colors.yellow('PENDING')}`, hAlign: 'right' }, + ]) + .row([ + ui.colors.reset('1592728640792_employee_invites.ts'), + '', + { content: `${ui.colors.yellow('PENDING')}`, hAlign: 'right' }, + ]) + .fullWidth() + .fluidColumnIndex(0) + .render() diff --git a/examples/tasks.ts b/examples/tasks.ts new file mode 100644 index 0000000..7511775 --- /dev/null +++ b/examples/tasks.ts @@ -0,0 +1,51 @@ +import { cliui } from '../index.js' +const ui = cliui() + +const tasks = ui.tasks() + +const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) + +await tasks + .add('clone repo', async (task) => { + await sleep(200) + task.update(`cloning repo ${ui.colors.cyan('https://github.com/adonisjs/core')}`) + + await sleep(200) + task.update(`downloaded ${ui.colors.cyan('10%')}`) + + await sleep(200) + task.update(`downloaded ${ui.colors.cyan('20%')}`) + + await sleep(200) + task.update(`downloaded ${ui.colors.cyan('30%')}`) + + await sleep(200) + task.update(`downloaded ${ui.colors.cyan('40%')}`) + + await sleep(500) + task.update(`downloaded ${ui.colors.cyan('80%')}`) + + await sleep(200) + task.update(`downloaded ${ui.colors.cyan('90%')}`) + return ui.logger.action('Downloading').prepareSucceeded() + }) + .add('update package file', async (task) => { + await sleep(200) + task.update(ui.logger.action('Update package.json').prepareSucceeded()) + await sleep(500) + return 'Updated' + }) + .add('install dependencies', async (task) => { + await sleep(200) + const spinner = ui.logger.await('installing') + spinner.tap((line) => task.update(line)) + spinner.start() + await sleep(1000) + + spinner.update('updating') + + await sleep(3000) + spinner.stop() + return new Error('ahuh') + }) + .run() diff --git a/index.ts b/index.ts index c3f0fcd..882ee72 100644 --- a/index.ts +++ b/index.ts @@ -1,71 +1,142 @@ /* * @poppinss/cliui * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { instantiate } from './api' -const ui = instantiate(!!process.env.CLI_UI_IS_TESTING) +import supportsColor from 'supports-color' +import { Colors } from '@poppinss/colors/types' +import { default as poppinssColors } from '@poppinss/colors' + +import { icons } from './src/icons.js' +import { Table } from './src/table.js' +import { useColors } from './src/colors.js' +import { Logger } from './src/logger/main.js' +import { Instructions } from './src/instructions.js' +import { TaskManager } from './src/tasks/manager.js' +import { MemoryRenderer } from './src/renderers/memory.js' +import { ConsoleRenderer } from './src/renderers/console.js' +import type { RendererContract, TableOptions, TaskManagerOptions } from './src/types.js' + +export { + icons, + Table, + Logger, + TaskManager, + Instructions, + MemoryRenderer, + ConsoleRenderer, + poppinssColors as colors, +} /** - * Is terminal interactive or not. The code is copied from - * https://github.com/sindresorhus/is-interactive/blob/master/index.js. + * Create a new CLI UI instance. * - * Yes, we can install it as a dependency, but decided to copy/paste 4 - * lines. NO STRONG REASONS BEHIND IT + * - The "raw" mode is tailored for testing + * - The "silent" mode should be used when the terminal does not support colors. We + * automatically perform the detection */ -export const isInteractive = ui.isInteractive +export function cliui(options: Partial<{ mode: 'raw' | 'silent' | 'normal' }> = {}) { + let mode = options.mode -/** - * Whether or not colors are enabled. They are enabled by default, - * unless the terminal doesn't support color. Also "FORCE_COLOR" - * env variable enables them forcefully. - */ -export const supportsColors = ui.supportsColors + /** + * Use silent mode when not explicit mode is defined + */ + if (!mode && !supportsColor.stdout) { + mode = 'silent' + } -/** - * The renderer used in the testing mode. One can access it to listen - * for the log messages. Also, the memory renderer only works when - * the "CLI_UI_IS_TESTING" flag is set - */ -export const testingRenderer = ui.testingRenderer + /** + * Renderer to use + */ + let renderer: RendererContract = mode === 'raw' ? new MemoryRenderer() : new ConsoleRenderer() -/** - * Console renderer outputs to the console. We do not export it, since one - * cannot do much by having an access to it. - */ -export const consoleRenderer = ui.consoleRenderer + /** + * Colors instance in use + */ + let colors = useColors({ silent: mode === 'silent', raw: mode === 'raw' }) -/** - * Logger - */ -export const logger = ui.logger + /** + * Logger + */ + const logger = new Logger() + logger.useRenderer(renderer) + logger.useColors(colors) -/** - * Icons - */ -export const icons = ui.icons + /** + * Render instructions inside a box + */ + const instructions = () => { + const instructionsInstance = new Instructions({ icons: true, raw: mode === 'raw' }) + instructionsInstance.useRenderer(renderer) + instructionsInstance.useColors(colors) + return instructionsInstance + } -/** - * Reference to the instructions block to render a set of lines inside - * a box. - */ -export const instructions = ui.instructions + /** + * Similar to instructions. But without the `pointer` icon + */ + const sticker = () => { + const instructionsInstance = new Instructions({ icons: false, raw: mode === 'raw' }) + instructionsInstance.useRenderer(renderer) + instructionsInstance.useColors(colors) + return instructionsInstance + } -/** - * Similar to instructions. But the lines are not prefix with a pointer `>` - */ -export const sticker = ui.sticker + /** + * Initiates a group of tasks + */ + const tasks = (tasksOptions?: Partial) => { + const manager = new TaskManager({ raw: mode === 'raw', ...tasksOptions }) + manager.useRenderer(renderer) + manager.useColors(colors) + return manager + } -/** - * Initiates a group of tasks - */ -export const tasks = ui.tasks + /** + * Instantiate a new table + */ + const table = (tableOptions?: Partial) => { + const tableInstance = new Table({ raw: mode === 'raw', ...tableOptions }) + tableInstance.useRenderer(renderer) + tableInstance.useColors(colors) + return tableInstance + } -/** - * Instantiate a new table - */ -export const table = ui.table + return { + colors, + logger, + table, + tasks, + icons, + sticker, + instructions, + switchMode(modeToUse: 'raw' | 'silent' | 'normal') { + mode = modeToUse + + /** + * Use memory renderer in raw mode, otherwise switch to + * console renderer + */ + if (mode === 'raw') { + this.useRenderer(new MemoryRenderer()) + } else { + this.useRenderer(new ConsoleRenderer()) + } + + this.useColors(useColors({ silent: mode === 'silent', raw: mode === 'raw' })) + }, + useRenderer(rendererToUse: RendererContract) { + renderer = rendererToUse + logger.useRenderer(renderer) + }, + useColors(colorsToUse: Colors) { + colors = colorsToUse + logger.useColors(colors) + this.colors = colors + }, + } +} diff --git a/package.json b/package.json index bc277db..a5b3d3c 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,34 @@ { "name": "@poppinss/cliui", - "version": "3.0.5", - "description": "Highly opinionated command line UI KIT", + "version": "6.1.1-4", + "description": "Opinionated UI KIT for Command Line apps", "main": "build/index.js", + "type": "module", "files": [ - "build/src", - "build/index.d.ts", - "build/index.js", - "build/api.d.ts", - "build/api.js" + "build" ], + "exports": { + ".": "./build/index.js", + "./helpers": "./build/src/helpers.js", + "./types": "./build/src/types.js" + }, + "engines": { + "node": ">=18.16.0" + }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node -r @adonisjs/require-ts/build/register ./bin/test.ts", + "test": "npm run vscode:test", "clean": "del-cli build", - "compile": "npm run lint && npm run clean && tsc", + "typecheck": "tsc --noEmit", + "compile": "npm run lint && npm run clean && tsup-node", "build": "npm run compile", "prepublishOnly": "npm run build", "format": "prettier --write .", - "commit": "git-cz", - "release": "np --message=\"chore(release): %s\"", + "release": "np", "version": "npm run build", "lint": "eslint . --ext=.ts", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json poppinss/cliui" + "sync-labels": "github-label-sync --labels .github/labels.json poppinss/cliui", + "vscode:test": "cross-env FORCE_COLOR=true node --loader=ts-node/esm bin/test.ts" }, "keywords": [ "cliui", @@ -33,57 +38,39 @@ "author": "virk,poppinss", "license": "MIT", "devDependencies": { - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/require-ts": "^2.0.13", - "@japa/assert": "^1.3.6", - "@japa/run-failed-tests": "^1.1.0", - "@japa/runner": "^2.2.2", - "@japa/spec-reporter": "^1.3.2", - "@types/node": "^18.11.5", - "commitizen": "^4.2.5", - "cz-conventional-changelog": "^3.3.0", - "del-cli": "^5.0.0", - "doctoc": "^2.2.1", - "eslint": "^8.26.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.1", - "eslint-plugin-prettier": "^4.2.1", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "mrm": "^4.1.13", - "np": "^7.6.2", - "prettier": "^2.7.1", - "typescript": "^4.8.4" - }, - "nyc": { - "exclude": [ - "test" - ], - "extension": [ - ".ts" - ] - }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } - }, - "np": { - "contents": ".", - "anyBranch": false + "@adonisjs/eslint-config": "^1.1.8", + "@adonisjs/prettier-config": "^1.1.8", + "@adonisjs/tsconfig": "^1.1.8", + "@commitlint/cli": "^17.7.2", + "@commitlint/config-conventional": "^17.7.0", + "@japa/assert": "^2.0.0", + "@japa/runner": "^3.0.1", + "@swc/core": "1.3.82", + "@types/node": "^20.8.6", + "@types/pretty-hrtime": "^1.0.1", + "@types/wordwrap": "^1.0.1", + "cross-env": "^7.0.3", + "del-cli": "^5.1.0", + "eslint": "^8.51.0", + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^8.0.4", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "tsup": "^7.2.0", + "typescript": "^5.2.2" }, "dependencies": { - "@poppinss/colors": "^3.0.3", + "@poppinss/colors": "^4.1.0", "cli-boxes": "^3.0.0", "cli-table3": "^0.6.3", - "color-support": "^1.1.3", - "log-update": "^4.0.0", + "cli-truncate": "^3.1.0", + "log-update": "^5.0.1", "pretty-hrtime": "^1.0.3", - "string-width": "^4.2.2" - }, - "directories": { - "example": "example", - "test": "test" + "string-width": "^6.1.0", + "supports-color": "^9.4.0", + "term-size": "^3.0.2", + "wordwrap": "^1.0.0" }, "repository": { "type": "git", @@ -93,47 +80,35 @@ "url": "https://github.com/poppinss/cliui/issues" }, "homepage": "https://github.com/poppinss/cliui#readme", - "mrmConfig": { - "core": false, - "license": "MIT", - "services": [ - "github-actions" - ], - "minNodeVersion": "16.13.1", - "probotApps": [ - "stale", - "lock" - ], - "runGhActionsOnWindows": true + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "np": { + "message": "chore(release): %s", + "tag": "latest", + "branch": "main", + "anyBranch": false }, "eslintConfig": { - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" - ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } + "extends": "@adonisjs/eslint-config/package" }, - "eslintIgnore": [ - "build" - ], - "prettier": { - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 + "prettier": "@adonisjs/prettier-config", + "tsup": { + "entry": [ + "./index.ts", + "./src/helpers.ts", + "./src/types.ts" + ], + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": true, + "target": "esnext" } } diff --git a/src/Colors/index.ts b/src/Colors/index.ts deleted file mode 100644 index 4971d51..0000000 --- a/src/Colors/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Colors, FakeColors, Raw } from '@poppinss/colors' -import { Colors as BaseColors } from '@poppinss/colors/build/src/Base' - -/** - * Returns the colors instance based upon the environment - */ -export function getBest(testing: boolean, enabled: boolean): BaseColors { - if (!enabled) { - return new Raw() - } - - if (testing) { - return new FakeColors() - } - - return new Colors() -} diff --git a/src/Contracts/index.ts b/src/Contracts/index.ts deleted file mode 100644 index 12045bf..0000000 --- a/src/Contracts/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Logger } from '../Logger' - -/** - * Shape of the renderer contract. Except the spinner, every - * interface accepts a renderer - */ -export interface RendererContract { - log(message: string): void - logError(message: string): void - logUpdate(message: string): void - logUpdateDone(): void -} - -/** - * Task update listener. Mainly used by the task renderers - */ -export type UpdateListener = (task: TaskContract) => void - -/** - * Shape of a task - */ -export interface TaskContract { - title: string - state: 'idle' | 'running' | 'failed' | 'succeeded' - duration?: string - completionMessage?: string | { message: string; stack?: string } - start(): this - onUpdate(callback: UpdateListener): this - complete(message?: string): this - fail(error: string | { message: string; stack?: string }): this -} - -/** - * Callback passed while registering task with the task - * manager - */ -export type TaskCallback = ( - logger: Logger, - task: { - fail: (error: string | { message: string; stack?: string }) => Promise - complete: (message?: string) => Promise - } -) => void | Promise - -/** - * Options accepted by the tasks renderers - */ -export type TaskRendererOptions = { - colors: boolean - interactive: boolean -} - -/** - * Options accepted by the tasks manager - */ -export type TaskManagerOptions = TaskRendererOptions & { - verbose: boolean -} - -/** - * Logging types - */ -export type LoggingTypes = 'success' | 'error' | 'fatal' | 'warning' | 'info' | 'debug' | 'await' - -/** - * Options accepted by the logger - */ -export type LoggerOptions = { - colors: boolean - labelColors: boolean - dim: boolean - dimLabels: boolean - interactive: boolean -} - -/** - * Options accepted by table - */ -export type TableOptions = { - colors: boolean -} - -export type TableRow = - | ( - | string - | { colSpan?: number; hAlign?: 'left' | 'center' | 'right'; content: string } - | { rowSpan?: number; vAlign?: 'top' | 'center' | 'bottom'; content: string } - )[] - | { [key: string]: string[] } - -/** - * Options accepted by instructions - */ -export type InstructionsOptions = { - icons: boolean - colors: boolean -} - -/** - * Shape of the instructions line - */ -export type InstructionsLine = { - text: string - width: number -} diff --git a/src/Instructions/index.ts b/src/Instructions/index.ts deleted file mode 100644 index 0fae27c..0000000 --- a/src/Instructions/index.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import boxes from 'cli-boxes' -import stringWidth from 'string-width' - -import { icons } from '../Icons' -import { getBest } from '../Colors' -import { ConsoleRenderer } from '../Renderer/Console' -import { InstructionsLine, InstructionsOptions, RendererContract } from '../Contracts' - -/** - * The box styling used by the instructions - */ -const BOX = boxes.round - -/** - * Default config options - */ -const DEFAULTS: InstructionsOptions = { - icons: true, - colors: true, -} - -/** - * The API to render instructions wrapped inside a box - */ -export class Instructions { - private state: { - heading?: InstructionsLine - content: InstructionsLine[] - } = { - content: [], - } - - /** - * Renderer to use for rendering instructions - */ - private renderer?: RendererContract - - /** - * Line of the widest line inside instructions content - */ - private widestLineLength = 0 - - /** - * Number of white spaces on the left of the box - */ - private leftPadding = 4 - - /** - * Number of white spaces on the right of the box - */ - private rightPadding = 8 - - /** - * Number of empty lines at the top - */ - private paddingTop = 1 - - /** - * Number of empty lines at the bottom - */ - private paddingBottom = 1 - - /** - * Reference to the colors - */ - private colors: ReturnType - - /** - * Options - */ - public options: InstructionsOptions - - constructor(options?: Partial, private testing: boolean = false) { - this.options = { ...DEFAULTS, ...options } - this.colors = getBest(this.testing, this.options.colors) - } - - /** - * Returns the renderer for rendering the messages - */ - private getRenderer() { - if (!this.renderer) { - this.renderer = new ConsoleRenderer() - } - return this.renderer - } - - /** - * Repeats text for given number of times - */ - private repeat(text: string, times: number) { - return new Array(times + 1).join(text) - } - - /** - * Adds dim transformation - */ - private dim(text: string): string { - return this.colors.dim(text) - } - - /** - * Wraps content inside the left and right vertical lines - */ - private wrapInVerticalLines(content: string, leftWhitespace: string, rightWhitespace: string) { - return `${this.dim(BOX.left)}${leftWhitespace}${content}${rightWhitespace}${this.dim( - BOX.right - )}` - } - - /** - * Returns the top line for the box - */ - private getTopLine(): string { - const horizontalLength = this.widestLineLength + this.leftPadding + this.rightPadding - const horizontalLine = this.repeat(this.dim(BOX.top), horizontalLength) - return `${this.dim(BOX.topLeft)}${horizontalLine}${this.dim(BOX.topRight)}` - } - - /** - * Returns the bottom line for the box - */ - private getBottomLine(): string { - const horizontalLength = this.widestLineLength + this.leftPadding + this.rightPadding - const horizontalLine = this.repeat(this.dim(BOX.bottom), horizontalLength) - return `${this.dim(BOX.bottomLeft)}${horizontalLine}${this.dim(BOX.bottomRight)}` - } - - /** - * Decorates the instruction line by wrapping it inside the box - * lines - */ - private getContentLine(line: InstructionsLine): string { - const rightWhitespace = this.repeat(' ', this.widestLineLength - line.width + this.rightPadding) - const leftWhitespace = this.repeat(' ', this.leftPadding) - return this.wrapInVerticalLines(line.text, leftWhitespace, rightWhitespace) - } - - /** - * Returns the heading line with the border bottom - */ - private getHeading(): string | undefined { - if (!this.state.heading) { - return - } - - /** - * Creating the header text - */ - const leftWhitespace = this.repeat(' ', this.leftPadding) - const rightWhitespace = this.repeat( - ' ', - this.widestLineLength - this.state.heading.width + this.rightPadding - ) - - const headingContent = this.wrapInVerticalLines( - this.state.heading.text, - leftWhitespace, - rightWhitespace - ) - - /** - * Creating the heading border bottom - */ - const horizontalLength = this.widestLineLength + this.leftPadding + this.rightPadding - const borderLine = this.repeat(this.dim(boxes.single.top), horizontalLength) - const border = this.wrapInVerticalLines(borderLine, '', '') - - return `${headingContent}\n${border}` - } - - /** - * Returns node for a empty line - */ - private getEmptyLineNode() { - return { text: '', width: 0 } - } - - /** - * Returns instructions lines with the padding - */ - private getLinesWithPadding() { - const top = new Array(this.paddingTop).fill('').map(this.getEmptyLineNode) - const bottom = new Array(this.paddingBottom).fill('').map(this.getEmptyLineNode) - return top.concat(this.state.content).concat(bottom) - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer = renderer - return this - } - - /** - * Define heading for instructions - */ - public heading(text: string): this { - const width = stringWidth(text) - if (width > this.widestLineLength) { - this.widestLineLength = width - } - - this.state.heading = { text, width } - return this - } - - /** - * Add new instruction. Each instruction is rendered - * in a new line inside a box - */ - public add(text: string): this { - text = this.options.icons ? `${this.dim(icons.pointer)} ${text}` : `${text}` - - const width = stringWidth(text) - if (width > this.widestLineLength) { - this.widestLineLength = width - } - - this.state.content.push({ text, width }) - return this - } - - /** - * Render instructions - */ - public render() { - const renderer = this.getRenderer() - - /** - * Render content as it is in testing mode - */ - if (this.testing) { - this.state.heading && renderer.log(this.state.heading.text) - this.state.content.forEach(({ text }) => renderer.log(text)) - return - } - - const top = this.getTopLine() - const heading = this.getHeading() - const body = this.getLinesWithPadding() - .map((line) => this.getContentLine(line)) - .join('\n') - const bottom = this.getBottomLine() - - let output = `${top}\n` - if (heading) { - output = `${output}${heading}\n` - } - - renderer.log(`${output}${body}\n${bottom}`) - } -} diff --git a/src/Logger/Action/index.ts b/src/Logger/Action/index.ts deleted file mode 100644 index 42748eb..0000000 --- a/src/Logger/Action/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Colors } from '@poppinss/colors' - -import { Logger } from '../index' -import { RendererContract } from '../../Contracts' - -/** - * Exposes the API to print actions in one of the following three states - * - * - failed - * - succeeded - * - skipped - */ -export class Action { - constructor(private label: string, private logger: Logger) {} - - /** - * Returns the label - */ - private getLabel(label: string, color: keyof Colors) { - if (!this.logger.options.colors) { - return `${label.toUpperCase()}:` - } - - return `${this.logger.colors[color](`${label.toUpperCase()}:`)}` - } - - private formatMessage(message: string) { - if (this.logger.options.dim) { - return this.logger.colors.dim(message) - } - return message - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.logger.useRenderer(renderer) - return this - } - - /** - * Mark action as successful - */ - public succeeded(message: string) { - const label = this.getLabel(this.label, 'green') - this.logger.log(this.formatMessage(`${label} ${message}`)) - } - - /** - * Mark action as skipped - */ - public skipped(message: string, skipReason?: string) { - let logMessage = this.formatMessage(`${this.getLabel('skip', 'cyan')} ${message}`) - - if (skipReason) { - logMessage = `${logMessage} ${this.logger.colors.dim(`(${skipReason})`)}` - } - - this.logger.log(logMessage) - } - - /** - * Mark action as failed - */ - public failed(message: string, errorMessage: string) { - let logMessage = this.formatMessage(`${this.getLabel('error', 'red')} ${message}`) - this.logger.logError(`${logMessage} ${this.logger.colors.dim(`(${errorMessage})`)}`) - } -} diff --git a/src/Logger/Spinner/index.ts b/src/Logger/Spinner/index.ts deleted file mode 100644 index 0c55158..0000000 --- a/src/Logger/Spinner/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Logger } from '../index' - -/** - * The most simplest spinner to log message with a progress indicator - */ -export class Spinner { - /** - * Frames to loop over - */ - private frames = ['. ', '.. ', '...', ' ..', ' .', ' '] - - /** - * Animation duration - */ - private interval = 200 - - /** - * The state of the spinner - */ - private state: 'idle' | 'running' | 'stopped' = 'idle' - - /** - * The current index for the frames - */ - private currentIndex = 0 - - /** - * Builds the message to the print from the text - */ - private messageBuilder: { - suffix?: string - prefix?: string - render: (text: string) => string - } = { - render(text: string) { - if (this.prefix) { - text = `[${this.prefix}] ${text}` - } - - if (this.suffix) { - text = `${text} ${this.suffix}` - } - - return text - }, - } - - constructor(private text: string, private logger: Logger, private testing: boolean = false) {} - - /** - * Increment index. Also, handles the index overflow - */ - private incrementIndex() { - this.currentIndex = this.frames.length === this.currentIndex + 1 ? 0 : this.currentIndex + 1 - } - - /** - * Loop over the message and animate the spinner - */ - private loop() { - if (this.state !== 'running') { - return - } - - /** - * Print the message as it is in testing mode or when the TTY is - * not interactive - */ - if (this.testing || !this.logger.options.interactive) { - this.logger.logUpdate(`${this.messageBuilder.render(this.text)} ${this.frames[2]}`) - return - } - - /** - * Otherwise log the current frame and re-run the function - * with some delay - */ - const frame = this.frames[this.currentIndex] - this.logger.logUpdate(`${this.messageBuilder.render(this.text)} ${frame}`) - - setTimeout(() => { - this.incrementIndex() - this.loop() - }, this.interval) - } - - /** - * Star the spinner - */ - public start(): this { - this.state = 'running' - this.loop() - return this - } - - /** - * Define a custom message builder - */ - public useMessageBuilder(messageBuilder: { render: (text: string) => string }): this { - this.messageBuilder = messageBuilder - return this - } - - /** - * Update spinner - */ - public update(text: string, prefix?: string, suffix?: string): this { - if (this.state !== 'running') { - return this - } - - this.text = text - - if (prefix !== undefined) { - this.messageBuilder.prefix = prefix - } - - if (suffix !== undefined) { - this.messageBuilder.suffix = suffix - } - - /** - * Print the message as it is in testing mode or when the TTY is - * not interactive - */ - if (this.testing || !this.logger.options.interactive) { - this.logger.logUpdate(`${this.messageBuilder.render(this.text)} ${this.frames[2]}`) - return this - } - - return this - } - - /** - * Stop spinner - */ - public stop() { - this.state = 'stopped' - this.currentIndex = 0 - this.logger.logUpdatePersist() - } -} diff --git a/src/Logger/index.ts b/src/Logger/index.ts deleted file mode 100644 index 39bd7d9..0000000 --- a/src/Logger/index.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* - * @poppinss/clui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Colors } from '@poppinss/colors' - -import { Action } from './Action' -import { getBest } from '../Colors' -import { Spinner } from './Spinner' -import { ConsoleRenderer } from '../Renderer/Console' -import { LoggerOptions, RendererContract, LoggingTypes } from '../Contracts' - -/** - * Default config options - */ -const DEFAULTS: LoggerOptions = { - dim: false, - dimLabels: false, - colors: true, - labelColors: true, - interactive: true, -} - -/** - * Logger exposes the API to log messages with consistent styles - * and colors - */ -export class Logger { - /** - * Logger configuration options - */ - public options: LoggerOptions - - /** - * The colors reference - */ - public colors: ReturnType - - /** - * The label colors reference - */ - private labelColors: ReturnType - - /** - * The renderer to use to output logs - */ - private renderer?: RendererContract - - constructor(options?: Partial, private testing: boolean = false) { - this.options = { ...DEFAULTS, ...options } - this.colors = getBest(this.testing, this.options.colors) - this.labelColors = getBest(this.testing, this.options.labelColors && this.options.colors) - } - - /** - * Colors the logger label - */ - private colorizeLabel(color: keyof Colors, text: string): string { - if (this.options.dim || this.options.dimLabels) { - return `[ ${this.labelColors.dim()[color](text)} ]` - } - - return `[ ${this.labelColors[color](text)} ]` - } - - /** - * Returns the label for a given logging type - */ - private getLabel(type: LoggingTypes): string { - switch (type) { - case 'success': - return this.colorizeLabel('green', 'success') - case 'error': - case 'fatal': - return this.colorizeLabel('red', type) - case 'warning': - return this.colorizeLabel('yellow', 'warn') - case 'info': - return this.colorizeLabel('blue', 'info') - case 'debug': - return this.colorizeLabel('cyan', 'debug') - case 'await': - return this.colorizeLabel('cyan', 'wait') - } - } - - /** - * Appends the suffix to the message - */ - private addSuffix(message: string, suffix?: string): string { - if (!suffix) { - return message - } - return `${message} ${this.colors.dim().yellow(`(${suffix})`)}` - } - - /** - * Prepends the prefix to the message. We do not DIM the prefix, since - * gray doesn't have much brightness already - */ - private addPrefix(message: string, prefix?: string): string { - if (!prefix) { - return message - } - - prefix = prefix.replace(/%time%/, new Date().toISOString()) - return `${this.colors.dim(`[${prefix}]`)} ${message}` - } - - /** - * Prepends the prefix to the message - */ - private prefixLabel(message: string, label: string) { - return `${label} ${message}` - } - - /** - * Decorate message string - */ - private decorateMessage(message: string): string { - if (this.options.dim) { - return this.colors.dim(message) as string - } - return message - } - - /** - * Decorate message string - */ - private formatStack(stack?: string): string { - if (!stack) { - return '' - } - - return `\n${stack - .split('\n') - .splice(1) - .map((line) => { - return `${this.colors.dim(line)}` - }) - .join('\n')}` - } - - /** - * Returns the renderer for rendering the messages - */ - private getRenderer() { - if (!this.renderer) { - this.renderer = new ConsoleRenderer() - } - return this.renderer - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer = renderer - return this - } - - /** - * Log message using the renderer. It is similar to `console.log` - * but uses the underlying renderer instead - */ - public log(message: string) { - this.getRenderer().log(message) - } - - /** - * Log message by overwriting the existing one - */ - public logUpdate(message: string) { - this.getRenderer().logUpdate(message) - } - - /** - * Persist the message logged using [[this.logUpdate]] - */ - public logUpdatePersist() { - this.getRenderer().logUpdateDone() - } - - /** - * Log error message using the renderer. It is similar to `console.error` - * but uses the underlying renderer instead - */ - public logError(message: string) { - this.getRenderer().logError(message) - } - - /** - * Log success message - */ - public success(message: string, prefix?: string, suffix?: string) { - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('success')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.log(message) - } - - /** - * Log error message - */ - public error(message: string | { message: string }, prefix?: string, suffix?: string) { - message = typeof message === 'string' ? message : message.message - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('error')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.logError(message) - } - - /** - * Log fatal message - */ - public fatal( - message: string | { message: string; stack?: string }, - prefix?: string, - suffix?: string - ) { - const stack = this.formatStack(typeof message === 'string' ? undefined : message.stack) - - message = typeof message === 'string' ? message : message.message - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('error')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.logError(`${message}${stack}`) - } - - /** - * Log warning message - */ - public warning(message: string, prefix?: string, suffix?: string) { - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('warning')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.log(message) - } - - /** - * Log info message - */ - public info(message: string, prefix?: string, suffix?: string) { - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('info')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.log(message) - } - - /** - * Log debug message - */ - public debug(message: string, prefix?: string, suffix?: string) { - message = this.decorateMessage(message) - message = this.prefixLabel(message, this.getLabel('debug')) - message = this.addPrefix(message, prefix) - message = this.addSuffix(message, suffix) - - this.log(message) - } - - /** - * Log a message with a spinner - */ - public await(message: string, prefix?: string, suffix?: string) { - const messageBuilder = { - prefix: prefix, - suffix: suffix, - logger: this, - render(text: string) { - text = this.logger.decorateMessage(text) - text = this.logger.prefixLabel(text, this.logger.getLabel('await')) - text = this.logger.addPrefix(text, this.prefix) - text = this.logger.addSuffix(text, this.suffix) - return text - }, - } - - return new Spinner(message, this, this.testing).useMessageBuilder(messageBuilder).start() - } - - /** - * Initiates a new action - */ - public action(title: string) { - return new Action(title, this) - } -} diff --git a/src/Table/index.ts b/src/Table/index.ts deleted file mode 100644 index 7d503ab..0000000 --- a/src/Table/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import CliTable from 'cli-table3' -import { getBest } from '../Colors' -import { ConsoleRenderer } from '../Renderer/Console' -import { RendererContract, TableOptions, TableRow } from '../Contracts' - -/** - * Default config options - */ -const DEFAULTS: TableOptions = { - colors: true, -} - -/** - * Exposes the API to represent a table - */ -export class Table { - private state: { - colWidths?: number[] - head: string[] - rows: TableRow[] - } = { - head: [], - rows: [], - } - - /** - * The renderer to use to output logs - */ - private renderer?: RendererContract - - /** - * Logger configuration options - */ - public options: TableOptions - - /** - * The colors reference - */ - public colors: ReturnType - - constructor(options?: Partial, private testing: boolean = false) { - this.options = { ...DEFAULTS, ...options } - this.colors = getBest(this.testing, this.options.colors) - } - - /** - * Returns the renderer for rendering the messages - */ - private getRenderer() { - if (!this.renderer) { - this.renderer = new ConsoleRenderer() - } - return this.renderer - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer = renderer - return this - } - - /** - * Define table head - */ - public head(headColumns: string[]): this { - this.state.head = headColumns.map((col) => this.colors.cyan(col)) - return this - } - - /** - * Add a new table row - */ - public row(row: TableRow): this { - this.state.rows.push(row) - return this - } - - /** - * Define custom column widths - */ - public columnWidths(widths: number[]): this { - this.state.colWidths = widths - return this - } - - /** - * Render table - */ - public render() { - if (this.testing) { - this.getRenderer().log(this.state.head.join('|')) - this.state.rows.forEach((row) => { - const content = Array.isArray(row) - ? row.map((cell) => { - if (typeof cell === 'string') { - return cell - } - return cell.content - }) - : Object.keys(row) - - this.getRenderer().log(content.join('|')) - }) - return - } - - const cliTable = new CliTable({ - head: this.state.head, - style: { head: [], border: ['dim'] }, - ...(this.state.colWidths ? { colWidths: this.state.colWidths } : {}), - }) - this.state.rows.forEach((row) => cliTable.push(row)) - this.getRenderer().log(cliTable.toString()) - } -} diff --git a/src/Task/Manager.ts b/src/Task/Manager.ts deleted file mode 100644 index 945e082..0000000 --- a/src/Task/Manager.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Task } from './index' -import { VerboseRenderer } from './Renderers/Verbose' -import { MinimalRenderer } from './Renderers/Minimal' -import { TaskManagerOptions, TaskCallback, TaskContract, RendererContract } from '../Contracts' - -/** - * Default set of options - */ -const DEFAULTS: TaskManagerOptions = { - colors: true, - interactive: true, - verbose: false, -} - -/** - * Exposes the API to create a group of tasks and run them in sequence - */ -export class TaskManager { - /** - * Options - */ - private options: TaskManagerOptions - - /** - * The renderer to use for rendering tasks. Automatically decided - */ - private renderer: MinimalRenderer | VerboseRenderer - - /** - * A set of created tasks - */ - private tasks: { task: TaskContract; callback: TaskCallback }[] = [] - - /** - * State of the tasks manager - */ - public state: 'idle' | 'running' | 'succeeded' | 'failed' = 'idle' - - /** - * Reference to the error raised by the task callback (if any) - */ - public error?: any - - constructor(options?: Partial, private testing: boolean = false) { - this.options = { ...DEFAULTS, ...options } - this.instantiateRenderer() - } - - /** - * Instantiates the tasks renderer - */ - private instantiateRenderer() { - const rendererOptions = { - colors: this.options.colors, - interactive: this.options.interactive, - } - - /** - * Using verbose render when verbose option is true or terminal is not - * interactive - */ - if (this.options.verbose || this.testing || !this.options.interactive) { - this.renderer = new VerboseRenderer(rendererOptions, this.testing) - return - } - - /** - * Otheriwse using the minimal renderer - */ - this.renderer = new MinimalRenderer(rendererOptions, this.testing) - } - - /** - * Run a given task. The underlying code assumes that tasks are - * executed in sequence. - */ - private async runTask(index: number) { - const task = this.tasks[index] - if (!task) { - return - } - - /** - * Start the underlying task - */ - task.task.start() - - /** - * Method to invoke when callback has been completed - */ - const complete = async (message: any) => { - if (task.task.state !== 'running') { - return - } - - task.task.complete(message) - await this.runTask(index + 1) - } - - /** - * Method to invoke when callback has been failed - */ - const fail = async (message: any) => { - if (task.task.state !== 'running') { - return - } - - this.error = message - this.state = 'failed' - task.task.fail(message) - } - - /** - * Invoke callback - */ - try { - await task.callback(this.renderer.logger, { complete, fail }) - } catch (error) { - await fail(error) - } - } - - /** - * Register a new task - */ - public add(title: string, callback: TaskCallback): this { - this.tasks.push({ task: new Task(title), callback }) - return this - } - - /** - * Define a custom logging renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer.useRenderer(renderer) - return this - } - - /** - * Run tasks - */ - public async run() { - if (this.state !== 'idle') { - return - } - - this.state = 'running' - this.renderer.tasks(this.tasks.map(({ task }) => task)).render() - await this.runTask(0) - - if (this.state === 'running') { - this.state = 'succeeded' - } - } -} diff --git a/src/Task/Renderers/Minimal.ts b/src/Task/Renderers/Minimal.ts deleted file mode 100644 index 2e66eec..0000000 --- a/src/Task/Renderers/Minimal.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { icons } from '../../Icons' -import { Logger } from '../../Logger' -import { ConsoleRenderer } from '../../Renderer/Console' -import { TaskContract, TaskRendererOptions, RendererContract } from '../../Contracts' - -/** - * As the name suggests, render tasks in minimal UI for better viewing - * experience. - */ -export class MinimalRenderer { - /** - * The renderer to use to output logs - */ - private renderer?: RendererContract - - /** - * List of registered tasks - */ - private registeredTasks: TaskContract[] - - /** - * Reference to the logger. We will capture logger messages - * and show them next to the task - */ - public logger: Logger - - constructor(private options: TaskRendererOptions, private testing: boolean = false) {} - - /** - * Returns the renderer for rendering the messages - */ - private getRenderer() { - if (!this.renderer) { - this.renderer = new ConsoleRenderer() - } - return this.renderer - } - - /** - * Instantiates the logger and defines a custom renderer - * to log messages in context with the currently running - * task - */ - private instantiateLogger() { - /** - * The minimal renderer must always be used when term - * has support for colors and is tty - */ - this.logger = new Logger({ ...this.options, dim: true }, this.testing) - - this.logger.useRenderer({ - log: (message: string) => this.renderTasks(message), - logError: (message: string) => this.renderTasks(message), - logUpdate: (message: string) => this.renderTasks(message), - logUpdateDone: () => {}, - }) - } - - /** - * Returns the presentation string for an idle task - */ - private presentIdleTask(task: TaskContract) { - return `${this.logger.colors.dim(icons.pointer)} ${this.logger.colors.dim(task.title)}` - } - - /** - * Returns the presentation string for a running task. The log line is - * updated when logger recieves the message. - */ - private presentRunningTask(task: TaskContract, logLine?: string) { - let message = `${icons.pointer} ${task.title}` - if (!logLine) { - return message - } - - const lines = logLine.trim().split('\n') - return `${message}\n ${lines[0]}` - } - - /** - * Returns the presentation string for a failed task - */ - private presentFailedTask(task: TaskContract) { - const pointer = this.logger.colors.red(icons.pointer) - const duration = this.logger.colors.dim(task.duration!) - - let message = `${pointer} ${task.title} ${duration}` - if (!task.completionMessage) { - return message - } - - const errorMessage = - typeof task.completionMessage === 'string' - ? task.completionMessage - : task.completionMessage.message - - message = `${message}\n ${this.logger.colors.red(errorMessage)}` - return message - } - - /** - * Returns the presentation string for a succeeded task - */ - private presentSucceededTask(task: TaskContract) { - const pointer = this.logger.colors.green(icons.pointer) - const duration = this.logger.colors.dim(task.duration!) - - let message = `${pointer} ${task.title} ${duration}` - if (!task.completionMessage) { - return message - } - - message = `${message}\n ${this.logger.colors.dim(task.completionMessage as string)}` - return message - } - - /** - * Renders a given task - */ - private renderTask(task: TaskContract, logLine?: string): string { - switch (task.state) { - case 'idle': - return this.presentIdleTask(task) - case 'running': - return this.presentRunningTask(task, logLine) - case 'succeeded': - return this.presentSucceededTask(task) - case 'failed': - return this.presentFailedTask(task) - } - } - - /** - * Re-renders all tasks by inspecting their current state - */ - private renderTasks(logLine?: string) { - this.getRenderer().logUpdate( - this.registeredTasks.map((task) => this.renderTask(task, logLine)).join('\n') - ) - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer = renderer - return this - } - - /** - * Register tasks to render - */ - public tasks(tasks: TaskContract[]): this { - this.registeredTasks = tasks - return this - } - - /** - * Render all tasks - */ - public render() { - this.instantiateLogger() - this.registeredTasks.forEach((task) => task.onUpdate(() => this.renderTasks())) - this.renderTasks() - } -} diff --git a/src/Task/Renderers/Verbose.ts b/src/Task/Renderers/Verbose.ts deleted file mode 100644 index bc4e161..0000000 --- a/src/Task/Renderers/Verbose.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Logger } from '../../Logger' -import { ConsoleRenderer } from '../../Renderer/Console' -import { TaskContract, TaskRendererOptions, RendererContract } from '../../Contracts' - -/** - * Verbose renderer shows a detailed output of the tasks and the - * messages logged by a given task - */ -export class VerboseRenderer { - /** - * The renderer to use to output logs - */ - private renderer?: RendererContract - - /** - * List of registered tasks - */ - private registeredTasks: TaskContract[] - - /** - * Reference to the logger. We will capture logger messages - * and show them next to the task - */ - public logger: Logger - - constructor(private options: TaskRendererOptions, private testing: boolean = false) {} - - /** - * Returns the renderer for rendering the messages - */ - private getRenderer() { - if (!this.renderer) { - this.renderer = new ConsoleRenderer() - } - return this.renderer - } - - /** - * Prefixes pipe to a line of text - */ - private prefixPipe(text: string) { - return text - .split('\n') - .map((line) => `${this.logger.colors.dim('│')} ${line}`) - .join('\n') - } - - /** - * Instantiates the logger and defines a custom renderer - * to log messages in context with the currently running - * task - */ - private instantiateLogger() { - this.logger = new Logger({ ...this.options, dim: true }, this.testing) - - this.logger.useRenderer({ - log: (message: string) => this.getRenderer().log(this.prefixPipe(message)), - logError: (message: string) => this.getRenderer().logError(this.prefixPipe(message)), - logUpdate: (message: string) => this.getRenderer().logUpdate(this.prefixPipe(message)), - logUpdateDone: () => this.getRenderer().logUpdateDone(), - }) - } - - /** - * Logs message based upon the state of the task - */ - private updateTask(task: TaskContract) { - /** - * Task started running - */ - if (task.state === 'running') { - this.getRenderer().log(`${this.logger.colors.dim('┌')} ${task.title}`) - return - } - - const pipe = this.logger.colors.dim('└') - const duration = this.logger.colors.dim(`(${task.duration})`) - - /** - * Task failed - */ - if (task.state === 'failed') { - task.completionMessage && this.logger.fatal(task.completionMessage) - this.getRenderer().logError(`${pipe} ${this.logger.colors.red('failed')} ${duration}`) - return - } - - /** - * Task succeeded - */ - if (task.state === 'succeeded') { - task.completionMessage && this.logger.colors.green(task.completionMessage as string) - this.getRenderer().log(`${pipe} ${this.logger.colors.green('completed')} ${duration}`) - return - } - } - - /** - * Define a custom renderer. Logs to "stdout" and "stderr" - * by default - */ - public useRenderer(renderer: RendererContract): this { - this.renderer = renderer - return this - } - - /** - * Register tasks to render - */ - public tasks(tasks: TaskContract[]): this { - this.registeredTasks = tasks - return this - } - - /** - * Render all tasks - */ - public render() { - this.instantiateLogger() - this.registeredTasks.forEach((task) => task.onUpdate(($task) => this.updateTask($task))) - } -} diff --git a/src/Task/index.ts b/src/Task/index.ts deleted file mode 100644 index c4ffbe4..0000000 --- a/src/Task/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import prettyHrtime from 'pretty-hrtime' -import { TaskContract, UpdateListener } from '../Contracts' - -/** - * Task exposes a very simple API to create tasks with states, along with a - * listener to listen for the task state updates. - * - * The task itself has does not render anything to the console. The task - * renderers does that. - */ -export class Task implements TaskContract { - private startTime: [number, number] - private onUpdateListener: UpdateListener = () => {} - - /** - * Duration of the task. Updated after the task is over - */ - public duration?: string - - /** - * Message set after completing the task. Can be an error or the - * a success message - */ - public completionMessage?: TaskContract['completionMessage'] - - /** - * Task current state - */ - public state: TaskContract['state'] = 'idle' - - constructor(public title: string) {} - - /** - * Bind a listener to listen to the state updates of the task - */ - public onUpdate(listener: UpdateListener): this { - this.onUpdateListener = listener - return this - } - - /** - * Start the task - */ - public start() { - this.state = 'running' - this.startTime = process.hrtime() - this.onUpdateListener && this.onUpdateListener(this) - return this - } - - /** - * Mark task as completed - */ - public complete(message?: string): this { - this.state = 'succeeded' - this.duration = prettyHrtime(process.hrtime(this.startTime)) - this.completionMessage = message - this.onUpdateListener && this.onUpdateListener(this) - return this - } - - /** - * Mark task as failed - */ - public fail(error: TaskContract['completionMessage']): this { - this.state = 'failed' - this.duration = prettyHrtime(process.hrtime(this.startTime)) - this.completionMessage = error - this.onUpdateListener && this.onUpdateListener(this) - return this - } -} diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..7d36fa4 --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,31 @@ +/* + * @poppinss/utils + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import colors from '@poppinss/colors' +import type { Colors } from '@poppinss/colors/types' + +/** + * Returns the colors instance based upon the environment. + * + * - The "raw" option returns the colors instance that prefix the color + * transformations as raw text + * - The "silent" option returns the colors instance that performs no + * color transformations + */ +export function useColors(options: { raw?: boolean; silent?: boolean } = {}): Colors { + if (options.raw) { + return colors.raw() + } + + if (options.silent) { + return colors.silent() + } + + return colors.ansi() +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..f076a51 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,131 @@ +/* + * @poppinss/utils + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import wordwrap from 'wordwrap' +import terminalSize from 'term-size' +import stringWidth from 'string-width' +import cliTruncate from 'cli-truncate' + +/** + * Total number of columns for the terminal + */ +export const TERMINAL_SIZE = terminalSize().columns + +/** + * Applies padding to the left or the right of the string by repeating + * a given char. + * + * The method is not same as `padLeft` or `padRight` from JavaScript STD lib, + * since it repeats a char regardless of the max width. + */ +function applyPadding( + value: string, + options: { paddingLeft?: number; paddingRight?: number; paddingChar: string } +) { + if (options.paddingLeft) { + value = `${options.paddingChar.repeat(options.paddingLeft)}${value}` + } + + if (options.paddingRight) { + value = `${value}${options.paddingChar.repeat(options.paddingRight)}` + } + + return value +} + +/** + * Justify the columns to have the same width by filling + * the empty slots with a padding char. + * + * Optionally, the column can be aligned left or right. + */ +export function justify( + columns: string[], + options: { + maxWidth: number + align?: 'left' | 'right' + paddingChar?: string + } +) { + const normalizedOptions = { + align: 'left' as const, + paddingChar: ' ', + ...options, + } + + return columns.map((column) => { + const columnWidth = stringWidth(column) + + /** + * Column is already same or greater than the maxWidth + */ + if (columnWidth >= normalizedOptions.maxWidth) { + return column + } + + /** + * Fill empty space on the right + */ + if (normalizedOptions.align === 'left') { + return applyPadding(column, { + paddingChar: normalizedOptions.paddingChar, + paddingRight: normalizedOptions.maxWidth - columnWidth, + }) + } + + /** + * Fill empty space on the left + */ + return applyPadding(column, { + paddingChar: normalizedOptions.paddingChar, + paddingLeft: normalizedOptions.maxWidth - columnWidth, + }) + }) +} + +/** + * Wrap the text under the starting and the ending column. + * The first line will start at 1st column. However, from + * the 2nd line onwards, the columns before the start + * column are filled with white space. + */ +export function wrap( + columns: string[], + options: { + startColumn: number + endColumn: number + trimStart?: boolean + } +) { + const wrapper = wordwrap(options.startColumn, options.endColumn) + if (options.trimStart) { + return columns.map((column) => wrapper(column).trimStart()) + } + + return columns.map((column) => wrapper(column)) +} + +/** + * Truncates the text after a certain width. + */ +export function truncate( + columns: string[], + options: { + maxWidth: number + truncationChar?: string + position?: 'start' | 'middle' | 'end' + } +) { + return columns.map((column) => + cliTruncate(column, options.maxWidth, { + truncationCharacter: options.truncationChar || '…', + position: options.position || 'end', + }) + ) +} diff --git a/src/Icons/index.ts b/src/icons.ts similarity index 90% rename from src/Icons/index.ts rename to src/icons.ts index 19ec9e1..0beef07 100644 --- a/src/Icons/index.ts +++ b/src/icons.ts @@ -1,7 +1,7 @@ /* * @poppinss/utils * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -9,6 +9,9 @@ const { platform } = process +/** + * A collection of platform specific icons + */ export const icons = platform === 'win32' && !process.env.WT_SESSION ? { diff --git a/src/instructions.ts b/src/instructions.ts new file mode 100644 index 0000000..6ff82b1 --- /dev/null +++ b/src/instructions.ts @@ -0,0 +1,345 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import boxes from 'cli-boxes' +import stringWidth from 'string-width' +import type { Colors } from '@poppinss/colors/types' + +import { icons } from './icons.js' +import { useColors } from './colors.js' +import { TERMINAL_SIZE } from './helpers.js' +import { ConsoleRenderer } from './renderers/console.js' +import type { InstructionsOptions, RendererContract } from './types.js' + +/** + * The box styling used by the instructions + */ +const BOX = boxes.round + +/** + * The API to render instructions wrapped inside a box + */ +export class Instructions { + #state: { + heading?: { text: string; width: number } + content: { text: string; width: number }[] + } = { + content: [], + } + + /** + * Renderer to use for rendering instructions + */ + #renderer?: RendererContract + + /** + * Length of the widest line inside instructions content + */ + #widestLineLength = 0 + + /** + * Number of white spaces on the left of the box + */ + #leftPadding = 4 + + /** + * Number of white spaces on the right of the box + */ + #rightPadding = 8 + + /** + * Number of empty lines at the top + */ + #paddingTop = 1 + + /** + * Number of empty lines at the bottom + */ + #paddingBottom = 1 + + /** + * Reference to the colors + */ + #colors?: Colors + + /** + * Options + */ + #options: InstructionsOptions + + /** + * Draws the border + */ + #drawBorder: (border: string, colors: Colors) => string = (border, colors) => { + return colors.dim(border) + } + + constructor(options: Partial = {}) { + this.#options = { + icons: options.icons === undefined ? true : options.icons, + raw: options.raw === undefined ? false : options.raw, + } + } + + /** + * Returns the length of the horizontal line + */ + #getHorizontalLength() { + return this.#widestLineLength + this.#leftPadding + this.#rightPadding + } + + /** + * Repeats text for given number of times + */ + #repeat(text: string, times: number) { + return new Array(times + 1).join(text) + } + + /** + * Wraps content inside the left and right vertical lines + */ + #wrapInVerticalLines(content: string, leftWhitespace: string, rightWhitespace: string) { + return `${this.#drawBorder( + BOX.left, + this.getColors() + )}${leftWhitespace}${content}${rightWhitespace}${this.#drawBorder(BOX.right, this.getColors())}` + } + + /** + * Returns the top line for the box + */ + #getTopLine(): string { + const horizontalLength = this.#getHorizontalLength() + const horizontalLine = this.#repeat( + this.#drawBorder(BOX.top, this.getColors()), + horizontalLength + ) + + return `${this.#drawBorder(BOX.topLeft, this.getColors())}${horizontalLine}${this.#drawBorder( + BOX.topRight, + this.getColors() + )}` + } + + /** + * Returns the bottom line for the box + */ + #getBottomLine(): string { + const horizontalLength = this.#getHorizontalLength() + const horizontalLine = this.#repeat( + this.#drawBorder(BOX.bottom, this.getColors()), + horizontalLength + ) + return `${this.#drawBorder( + BOX.bottomLeft, + this.getColors() + )}${horizontalLine}${this.#drawBorder(BOX.bottomRight, this.getColors())}` + } + + /** + * Returns the heading border bottom + */ + #getHeadingBorderBottom(): string { + const horizontalLength = this.#getHorizontalLength() + const horizontalLine = this.#repeat( + this.#drawBorder(boxes.single.top, this.getColors()), + horizontalLength + ) + return this.#wrapInVerticalLines(horizontalLine, '', '') + } + + /** + * Decorates the instruction line by wrapping it inside the box + * lines + */ + #getContentLine(line: { text: string; width: number }): string { + const leftWhitespace = this.#repeat(' ', this.#leftPadding) + const rightWhitespace = this.#repeat( + ' ', + this.#widestLineLength - line.width + this.#rightPadding + ) + return this.#wrapInVerticalLines(line.text, leftWhitespace, rightWhitespace) + } + + /** + * Returns the heading line by applying padding + */ + #getHeading(): string | undefined { + if (!this.#state.heading) { + return + } + + return this.#getContentLine(this.#state.heading) + } + + /** + * Returns the formatted body + */ + #getBody(): string | undefined { + if (!this.#state.content || !this.#state.content.length) { + return + } + + const top = new Array(this.#paddingTop).fill('').map(this.#getEmptyLineNode) + const bottom = new Array(this.#paddingBottom).fill('').map(this.#getEmptyLineNode) + + return top + .concat(this.#state.content) + .concat(bottom) + .map((line) => this.#getContentLine(line)) + .join('\n') + } + + /** + * Returns node for a empty line + */ + #getEmptyLineNode() { + return { text: '', width: 0 } + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Draw the instructions box in fullscreen + */ + fullScreen(): this { + const borderWidth = 2 + this.#widestLineLength = TERMINAL_SIZE - (this.#leftPadding + this.#rightPadding) - borderWidth + + return this + } + + /** + * Attach a callback to self draw the borders + */ + drawBorder(callback: (borderChar: string, colors: Colors) => string) { + this.#drawBorder = callback + return this + } + + /** + * Define heading for instructions + */ + heading(text: string): this { + const width = stringWidth(text) + if (width > this.#widestLineLength) { + this.#widestLineLength = width + } + + this.#state.heading = { text, width } + return this + } + + /** + * Add new instruction. Each instruction is rendered + * in a new line inside a box + */ + add(text: string): this { + text = this.#options.icons ? `${this.getColors().dim(icons.pointer)} ${text}` : `${text}` + + const width = stringWidth(text) + if (width > this.#widestLineLength) { + this.#widestLineLength = width + } + + this.#state.content.push({ text, width }) + return this + } + + prepare(): string { + /** + * Render content as it is in raw mode + */ + if (this.#options.raw) { + let output: string[] = [] + if (this.#state.heading) { + output.push(this.#state.heading.text) + } + + output = output.concat(this.#state.content.map(({ text }) => text)) + return output.join('\n') + } + + const top = this.#getTopLine() + const heading = this.#getHeading() + const headingBorderBottom = this.#getHeadingBorderBottom() + const body = this.#getBody() + const bottom = this.#getBottomLine() + + let output = `${top}\n` + + /** + * Draw heading if it exists + */ + if (heading) { + output = `${output}${heading}` + } + + /** + * Draw the border between the heading and the body if + * both exists + */ + if (heading && body) { + output = `${output}\n${headingBorderBottom}\n` + } + + /** + * Draw body if it exists + */ + if (body) { + output = `${output}${body}` + } + + return `${output}\n${bottom}` + } + + /** + * Render instructions + */ + render() { + this.getRenderer().log(this.prepare()) + } +} diff --git a/src/logger/action.ts b/src/logger/action.ts new file mode 100644 index 0000000..128dcd0 --- /dev/null +++ b/src/logger/action.ts @@ -0,0 +1,222 @@ +/* + * @poppinss/utils + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import prettyHrtime from 'pretty-hrtime' +import type { Colors } from '@poppinss/colors/types' + +import { useColors } from '../colors.js' +import { ConsoleRenderer } from '../renderers/console.js' +import type { ActionOptions, RendererContract } from '../types.js' + +/** + * Exposes the API to print actions in one of the following three states + * + * - failed + * - succeeded + * - skipped + */ +export class Action { + #startTime?: [number, number] + + /** + * Action options + */ + #options: ActionOptions + + /** + * Action message + */ + #message: string + + /** + * Reference to the colors implementation + */ + #colors?: Colors + + /** + * The renderer to use for writing to the console + */ + #renderer?: RendererContract + + /** + * Whether or not to display duration of the action + */ + #displayDuration: boolean = false + + constructor(message: string, options: Partial = {}) { + this.#message = message + this.#startTime = process.hrtime() + this.#options = { + dim: options.dim === undefined ? false : options.dim, + } + } + + /** + * Format label + */ + #formatLabel(label: string, color: keyof Colors) { + label = this.getColors()[color](`${label.toUpperCase()}:`) as string + + if (this.#options.dim) { + return this.getColors().dim(label) + } + + return label + } + + /** + * Format message + */ + #formatMessage(message: string) { + if (this.#options.dim) { + return this.getColors().dim(message) + } + + return message + } + + /** + * Format the suffix + */ + #formatSuffix(message: string) { + message = `(${message})` + return this.getColors().dim(message) + } + + /** + * Format error + */ + #formatError(error: string | Error) { + let message = typeof error === 'string' ? error : error.stack || error.message + + return `\n ${message + .split('\n') + .map((line) => { + if (this.#options.dim) { + line = this.getColors().dim(line) + } + + return ` ${this.getColors().red(line)}` + }) + .join('\n')}` + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer(): RendererContract { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Toggle whether to display duration for completed + * tasks or not. + */ + displayDuration(displayDuration: boolean = true) { + this.#displayDuration = displayDuration + return this + } + + /** + * Prepares the message to mark action as successful + */ + prepareSucceeded() { + const formattedLabel = this.#formatLabel('done', 'green') + const formattedMessage = this.#formatMessage(this.#message) + + let logMessage = `${formattedLabel} ${formattedMessage}` + if (this.#displayDuration) { + logMessage = `${logMessage} ${this.#formatSuffix( + prettyHrtime(process.hrtime(this.#startTime)) + )}` + } + + return logMessage + } + + /** + * Mark action as successful + */ + succeeded() { + this.getRenderer().log(this.prepareSucceeded()) + } + + /** + * Prepares the message to mark action as skipped + */ + prepareSkipped(skipReason?: string) { + const formattedLabel = this.#formatLabel('skipped', 'cyan') + const formattedMessage = this.#formatMessage(this.#message) + + let logMessage = `${formattedLabel} ${formattedMessage}` + if (skipReason) { + logMessage = `${logMessage} ${this.#formatSuffix(skipReason)}` + } + + return logMessage + } + + /** + * Mark action as skipped. An optional skip reason can be + * supplied + */ + skipped(skipReason?: string) { + this.getRenderer().log(this.prepareSkipped(skipReason)) + } + + /** + * Prepares the message to mark action as failed + */ + prepareFailed(error: string | Error) { + const formattedLabel = this.#formatLabel('failed', 'red') + const formattedMessage = this.#formatMessage(this.#message) + const formattedError = this.#formatError(error) + + const logMessage = `${formattedLabel} ${formattedMessage} ${formattedError}` + return logMessage + } + + /** + * Mark action as failed. An error message is required + */ + failed(error: string | Error) { + this.getRenderer().logError(this.prepareFailed(error)) + } +} diff --git a/src/logger/main.ts b/src/logger/main.ts new file mode 100644 index 0000000..1edc877 --- /dev/null +++ b/src/logger/main.ts @@ -0,0 +1,379 @@ +/* + * @poppinss/clui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Colors } from '@poppinss/colors/types' + +import { Action } from './action.js' +import { Spinner } from './spinner.js' +import { useColors } from '../colors.js' +import { ConsoleRenderer } from '../renderers/console.js' + +import type { + LoggingTypes, + LoggerOptions, + RendererContract, + LoggerMessageOptions, +} from '../types.js' + +/** + * CLI logger to log messages to the console. The output is consistently + * formatted. + */ +export class Logger implements RendererContract { + /** + * Logger configuration options + */ + #options: LoggerOptions + + /** + * Reference to the colors implementation + */ + #colors?: Colors + + /** + * The renderer to use to output logs + */ + #renderer?: RendererContract + + getLogs(): { message: string; stream: 'stdout' | 'stderr' }[] { + return this.getRenderer().getLogs() + } + + constructor(options: Partial = {}) { + const dimOutput = options.dim === undefined ? false : options.dim + + this.#options = { + dim: dimOutput, + dimLabels: options.dimLabels === undefined ? dimOutput : options.dimLabels, + } + } + + /** + * Color the logger label + */ + #colorizeLabel(color: keyof Colors, text: string): string { + text = this.getColors()[color](text) as string + + if (this.#options.dimLabels) { + return `[ ${this.getColors().dim(text)} ]` + } + + return `[ ${text} ]` + } + + /** + * Returns the label for a given logging type + */ + #getLabel(type: LoggingTypes): string { + switch (type) { + case 'success': + return this.#colorizeLabel('green', type) + case 'error': + case 'fatal': + return this.#colorizeLabel('red', type) + case 'warning': + return this.#colorizeLabel('yellow', 'warn') + case 'info': + return this.#colorizeLabel('blue', type) + case 'debug': + return this.#colorizeLabel('cyan', type) + case 'await': + return this.#colorizeLabel('cyan', 'wait') + } + } + + /** + * Appends the suffix to the message + */ + #addSuffix(message: string, suffix?: string): string { + if (!suffix) { + return message + } + + return `${message} ${this.getColors().dim().yellow(`(${suffix})`)}` + } + + /** + * Prepends the prefix to the message. We do not DIM the prefix, since + * gray doesn't have much brightness already + */ + #addPrefix(message: string, prefix?: string): string { + if (!prefix) { + return message + } + + prefix = prefix.replace(/%time%/, new Date().toISOString()) + return `${this.getColors().dim(`[${prefix}]`)} ${message}` + } + + /** + * Prepends the prefix to the message + */ + #prefixLabel(message: string, label: string) { + return `${label} ${message}` + } + + /** + * Decorate message string + */ + #decorateMessage(message: string): string { + if (this.#options.dim) { + return this.getColors().dim(message) + } + + return message + } + + /** + * Decorate error stack + */ + #formatStack(stack?: string): string { + if (!stack) { + return '' + } + + return `\n${stack + .split('\n') + .splice(1) + .map((line) => { + if (this.#options.dim) { + line = this.getColors().dim(line) + } + + return ` ${this.getColors().red(line)}` + }) + .join('\n')}` + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer(): RendererContract { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer to output logos + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Log message + */ + log(message: string) { + this.getRenderer().log(message) + } + + /** + * Log message by updating the existing line + */ + logUpdate(message: string) { + this.getRenderer().logUpdate(message) + } + + /** + * Persist log line written using the `logUpdate` + * method. + */ + logUpdatePersist() { + this.getRenderer().logUpdatePersist() + } + + /** + * Log error message using the renderer. It is similar to `console.error` + * but uses the underlying renderer instead + */ + logError(message: string) { + this.getRenderer().logError(message) + } + + /** + * Prepares the success message + */ + prepareSuccess(message: string, options?: LoggerMessageOptions) { + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('success')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + return message + } + + /** + * Log success message + */ + success(message: string, options?: LoggerMessageOptions) { + this.log(this.prepareSuccess(message, options)) + } + + /** + * Prepares the error message + */ + prepareError(message: string | { message: string }, options?: LoggerMessageOptions) { + message = typeof message === 'string' ? message : message.message + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('error')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + + return message + } + + /** + * Log error message + */ + error(message: string | { message: string }, options?: LoggerMessageOptions) { + this.logError(this.prepareError(message, options)) + } + + /** + * Prepares the fatal message + */ + prepareFatal( + message: string | { message: string; stack?: string }, + options?: LoggerMessageOptions + ) { + const stack = this.#formatStack(typeof message === 'string' ? undefined : message.stack) + + message = typeof message === 'string' ? message : message.message + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('error')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + + return `${message}${stack}` + } + + /** + * Log fatal message + */ + fatal(message: string | { message: string; stack?: string }, options?: LoggerMessageOptions) { + this.logError(this.prepareFatal(message, options)) + } + + /** + * Prepares the warning message + */ + prepareWarning(message: string, options?: LoggerMessageOptions) { + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('warning')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + + return message + } + + /** + * Log warning message + */ + warning(message: string, options?: LoggerMessageOptions) { + this.log(this.prepareWarning(message, options)) + } + + /** + * Prepares the info message + */ + prepareInfo(message: string, options?: LoggerMessageOptions) { + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('info')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + + return message + } + + /** + * Log info message + */ + info(message: string, options?: LoggerMessageOptions) { + this.log(this.prepareInfo(message, options)) + } + + /** + * Prepares the debug message + */ + prepareDebug(message: string, options?: LoggerMessageOptions) { + message = this.#decorateMessage(message) + message = this.#prefixLabel(message, this.#getLabel('debug')) + message = this.#addPrefix(message, options?.prefix) + message = this.#addSuffix(message, options?.suffix) + + return message + } + + /** + * Log debug message + */ + debug(message: string, options?: LoggerMessageOptions) { + this.log(this.prepareDebug(message, options)) + } + + /** + * Log a message with a spinner + */ + await(text: string, options?: LoggerMessageOptions) { + const message = { + logger: this, + text, + ...options, + render() { + let decorated = this.logger.#decorateMessage(this.text) + decorated = this.logger.#prefixLabel(decorated, this.logger.#getLabel('await')) + decorated = this.logger.#addPrefix(decorated, this.prefix) + decorated = this.logger.#addSuffix(decorated, this.suffix) + return decorated + }, + } + + return new Spinner(message).useRenderer(this.getRenderer()) + } + + /** + * Initiates a new action + */ + action(title: string) { + return new Action(title, { dim: this.#options.dim }) + .useColors(this.getColors()) + .useRenderer(this.getRenderer()) + } + + /** + * Create a new child instance of self + */ + child(options?: Partial): Logger { + return new (this.constructor as typeof Logger)(options) + .useColors(this.getColors()) + .useRenderer(this.getRenderer()) + } +} diff --git a/src/logger/spinner.ts b/src/logger/spinner.ts new file mode 100644 index 0000000..129f8a1 --- /dev/null +++ b/src/logger/spinner.ts @@ -0,0 +1,138 @@ +/* + * @poppinss/utils + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { ConsoleRenderer } from '../renderers/console.js' +import type { LoggerMessageOptions, RendererContract, SpinnerMessage } from '../types.js' + +/** + * Textual spinner to print a message with dotted progress + * bar. + */ +export class Spinner { + #animator = { + frames: ['. ', '.. ', '...', ' ..', ' .', ' '], + interval: 200, + index: 0, + getFrame() { + return this.frames[this.index] + }, + advance() { + this.index = this.index + 1 === this.frames.length ? 0 : this.index + 1 + return this.index + }, + } + + /** + * The state of the spinner + */ + #state: 'idle' | 'running' | 'stopped' = 'idle' + + /** + * Spinner message + */ + #message: SpinnerMessage + + /** + * The renderer to use for writing to the console + */ + #renderer?: RendererContract + + /** + * Custom method to handle animation result + */ + #spinnerWriter?: (line: string) => void + + constructor(message: SpinnerMessage) { + this.#message = message + } + + /** + * Loop over the message and animate the spinner + */ + #animate() { + if (this.#state !== 'running') { + return + } + + const frame = this.#animator.getFrame() + + if (this.#spinnerWriter) { + this.#spinnerWriter(`${this.#message.render()} ${frame}`) + } else { + this.getRenderer().logUpdate(`${this.#message.render()} ${frame}`) + } + + setTimeout(() => { + this.#animator.advance() + this.#animate() + }, this.#animator.interval) + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer(): RendererContract { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define the custom renderer + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Star the spinner + */ + start(): this { + this.#state = 'running' + this.#animate() + return this + } + + /** + * Update spinner + */ + update(text: string, options?: LoggerMessageOptions): this { + if (this.#state !== 'running') { + return this + } + + Object.assign(this.#message, { text, ...options }) + return this + } + + /** + * Stop spinner + */ + stop() { + this.#state = 'stopped' + this.#animator.index = 0 + + if (!this.#spinnerWriter) { + this.getRenderer().logUpdate(`${this.#message.render()} ${this.#animator.frames[2]}`) + this.getRenderer().logUpdatePersist() + } + } + + /** + * Tap into spinner to manually write the + * output. + */ + tap(callback: (line: string) => void): this { + this.#spinnerWriter = callback + return this + } +} diff --git a/src/Renderer/Console.ts b/src/renderers/console.ts similarity index 71% rename from src/Renderer/Console.ts rename to src/renderers/console.ts index 52ca09f..9c2b4bf 100644 --- a/src/Renderer/Console.ts +++ b/src/renderers/console.ts @@ -1,41 +1,45 @@ /* * @poppinss/utils * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import logUpdate from 'log-update' -import { RendererContract } from '../Contracts' +import type { RendererContract } from '../types.js' /** * Renders messages to the "stdout" and "stderr" */ export class ConsoleRenderer implements RendererContract { - public log(message: string) { + getLogs() { + return [] + } + + log(message: string) { console.log(message) } /** * Log message by overwriting the existing one */ - public logUpdate(message: string) { + logUpdate(message: string) { logUpdate(message) } /** * Persist the last logged message */ - public logUpdateDone() { + logUpdatePersist() { logUpdate.done() } /** * Log error */ - public logError(message: string) { + logError(message: string) { console.error(message) } } diff --git a/src/Renderer/Memory.ts b/src/renderers/memory.ts similarity index 55% rename from src/Renderer/Memory.ts rename to src/renderers/memory.ts index 81d8bae..f8f6a85 100644 --- a/src/Renderer/Memory.ts +++ b/src/renderers/memory.ts @@ -1,43 +1,47 @@ /* * @poppinss/utils * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { RendererContract } from '../Contracts' +import type { RendererContract } from '../types.js' /** * Keeps log messages within memory. Useful for testing */ export class MemoryRenderer implements RendererContract { - public logs: { message: string; stream: 'stdout' | 'stderr' }[] = [] + #logs: { message: string; stream: 'stdout' | 'stderr' }[] = [] + + getLogs() { + return this.#logs + } /** * Log message */ - public log(message: string) { - this.logs.push({ message, stream: 'stdout' }) + log(message: string) { + this.#logs.push({ message, stream: 'stdout' }) } /** * For memory renderer the logUpdate is similar to log */ - public logUpdate(message: string) { + logUpdate(message: string) { this.log(message) } /** * Its a noop */ - public logUpdateDone() {} + logUpdatePersist() {} /** * Log message as error */ - public logError(message: string) { - this.logs.push({ message, stream: 'stderr' }) + logError(message: string) { + this.#logs.push({ message, stream: 'stderr' }) } } diff --git a/src/table.ts b/src/table.ts new file mode 100644 index 0000000..a312cc7 --- /dev/null +++ b/src/table.ts @@ -0,0 +1,257 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import CliTable from 'cli-table3' +import stringWidth from 'string-width' +import type { Colors } from '@poppinss/colors/types' + +import { useColors } from './colors.js' +import { TERMINAL_SIZE } from './helpers.js' +import { ConsoleRenderer } from './renderers/console.js' +import type { RendererContract, TableHead, TableOptions, TableRow } from './types.js' + +/** + * Exposes the API to represent a table + */ +export class Table { + #state: { + colWidths?: number[] + head: TableHead + rows: TableRow[] + } = { + head: [], + rows: [], + } + + /** + * Size of the largest row for a given + * column + */ + #columnSizes: number[] = [] + + /** + * The renderer to use to output logs + */ + #renderer?: RendererContract + + /** + * Logger configuration options + */ + #options: TableOptions + + /** + * The colors reference + */ + #colors?: Colors + + /** + * Whether or not to render full width + */ + #renderFullWidth: boolean = false + + /** + * The column index that should take remaining + * width. + */ + #fluidColumnIndex: number = 0 + + /** + * Padding for columns + */ + #padding: number = 2 + + constructor(options: Partial = {}) { + this.#options = { + raw: options.raw === undefined ? false : options.raw, + chars: options.chars, + } + } + + /** + * Tracking the column size and keeping on the largest + * one by tracking the content size + */ + #storeColumnSize(columns: string[]) { + columns.forEach((column, index) => { + const size = stringWidth(column) + const existingSize = this.#columnSizes[index] + if (!existingSize || existingSize < size) { + this.#columnSizes[index] = size + } + }) + } + + /** + * Computes the col widths based when in fullwidth mode + */ + #computeColumnsWidth() { + /** + * Do not compute columns size, when rendering in full-width + */ + if (!this.#renderFullWidth) { + return + } + + /** + * The terminal columns + */ + let columns = TERMINAL_SIZE - (this.#columnSizes.length + 1) + + this.#state.colWidths = this.#state.colWidths || [] + this.#columnSizes.forEach((column, index) => { + /** + * The column width will be the size of the biggest + * text + padding left + padding right + */ + this.#state.colWidths![index] = this.#state.colWidths![index] || column + this.#padding * 2 + + /** + * Compute remaining columns + */ + columns = columns - this.#state.colWidths![index] + }) + + /** + * If there are remaining columns, then assign them + * to the fluid column. + */ + if (columns) { + const index = + this.#fluidColumnIndex > this.#columnSizes.length - 1 ? 0 : this.#fluidColumnIndex + this.#state.colWidths![index] = this.#state.colWidths![index] + columns + } + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Define table head + */ + head(headColumns: TableHead): this { + this.#state.head = headColumns + this.#storeColumnSize( + headColumns.map((column) => (typeof column === 'string' ? column : column.content)) + ) + return this + } + + /** + * Add a new table row + */ + row(row: TableRow): this { + this.#state.rows.push(row) + + if (Array.isArray(row)) { + this.#storeColumnSize(row.map((cell) => (typeof cell === 'string' ? cell : cell.content))) + } + + return this + } + + /** + * Define custom column widths + */ + columnWidths(widths: number[]): this { + this.#state.colWidths = widths + return this + } + + /** + * Toggle whether or render in full width or not + */ + fullWidth(renderFullWidth: boolean = true): this { + this.#renderFullWidth = renderFullWidth + return this + } + + /** + * Define the column index that should take + * will remaining width when rendering in + * full-width + */ + fluidColumnIndex(index: number): this { + this.#fluidColumnIndex = index + return this + } + + /** + * Render table + */ + render() { + if (this.#options.raw) { + this.getRenderer().log( + this.#state.head.map((col) => (typeof col === 'string' ? col : col.content)).join('|') + ) + + this.#state.rows.forEach((row) => { + const content = Array.isArray(row) + ? row.map((cell) => (typeof cell === 'string' ? cell : cell.content)) + : Object.keys(row) + + this.getRenderer().log(content.join('|')) + }) + + return + } + + this.#computeColumnsWidth() + + /** + * Types of "cli-table3" are out of the sync from the + * implementation + */ + const cliTable = new CliTable({ + head: this.#state.head, + style: { 'head': [], 'border': ['dim'], 'padding-left': 2, 'padding-right': 2 }, + wordWrap: true, + ...(this.#state.colWidths ? { colWidths: this.#state.colWidths } : {}), + chars: this.#options.chars, + } as any) + + this.#state.rows.forEach((row) => cliTable.push(row)) + this.getRenderer().log(cliTable.toString()) + } +} diff --git a/src/tasks/manager.ts b/src/tasks/manager.ts new file mode 100644 index 0000000..d1fd49c --- /dev/null +++ b/src/tasks/manager.ts @@ -0,0 +1,182 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Colors } from '@poppinss/colors/types' + +import { Task } from './task.js' +import { VerboseRenderer } from './renderers/verbose.js' +import { MinimalRenderer } from './renderers/minimal.js' +import type { TaskManagerOptions, TaskCallback, RendererContract } from '../types.js' +import { RawRenderer } from './renderers/raw.js' + +/** + * Transforms error message + */ +function TRANSFORM_ERROR(error: T): { isError: true; message: string } +function TRANSFORM_ERROR(error: T): T +function TRANSFORM_ERROR(error: T) { + if (typeof error === 'string') { + return { isError: true, message: error } + } + + return error +} + +/** + * Exposes the API to create a group of tasks and run them in sequence + */ +export class TaskManager { + /** + * Options + */ + #options: TaskManagerOptions + + /** + * The renderer to use for rendering tasks. The verbose renderer is + * used When "raw" is set to true. + */ + #tasksRenderer: MinimalRenderer | VerboseRenderer | RawRenderer + + /** + * A set of created tasks + */ + #tasks: { task: Task; callback: TaskCallback }[] = [] + + /** + * State of the tasks manager + */ + #state: 'idle' | 'running' | 'succeeded' | 'failed' = 'idle' + + constructor(options: Partial = {}) { + this.#options = { + icons: options.icons === undefined ? true : options.icons, + raw: options.raw === undefined ? false : options.raw, + verbose: options.verbose === undefined ? false : options.verbose, + } + + /** + * Using verbose renderer when in raw mode + */ + if (this.#options.raw) { + this.#tasksRenderer = new RawRenderer() + } else if (this.#options.verbose) { + this.#tasksRenderer = new VerboseRenderer() + } else { + /** + * Otheriwse using the minimal renderer + */ + this.#tasksRenderer = new MinimalRenderer({ + icons: this.#options.icons, + }) + } + } + + /** + * Run a given task. The underlying code assumes that tasks are + * executed in sequence. + */ + async #runTask(index: number) { + const task = this.#tasks[index] + if (!task) { + return + } + + /** + * Start the underlying task + */ + task.task.start() + + /** + * Update task progress + */ + const update = (logMessage: string) => { + task.task.update(logMessage) + } + + /** + * Invoke callback + */ + try { + const response = await task.callback({ error: TRANSFORM_ERROR, update }) + if (typeof response === 'string') { + task.task.markAsSucceeded(response) + await this.#runTask(index + 1) + } else { + this.#state = 'failed' + task.task.markAsFailed(response) + } + } catch (error) { + this.#state = 'failed' + task.task.markAsFailed(error) + } + } + + /** + * Access the task state + */ + getState() { + return this.#state + } + + /** + * Register a new task + */ + add(title: string, callback: TaskCallback): this { + this.#tasks.push({ task: new Task(title), callback }) + return this + } + + /** + * Get access to registered tasks + */ + tasks() { + return this.#tasks.map(({ task }) => task) + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + return this.#tasksRenderer.getRenderer() + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#tasksRenderer.useRenderer(renderer) + return this + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#tasksRenderer.useColors(color) + return this + } + + /** + * Run tasks + */ + async run() { + if (this.#state !== 'idle') { + return + } + + this.#state = 'running' + this.#tasksRenderer.tasks(this.tasks()).render() + await this.#runTask(0) + + if (this.#state === 'running') { + this.#state = 'succeeded' + } + } +} diff --git a/src/tasks/renderers/minimal.ts b/src/tasks/renderers/minimal.ts new file mode 100644 index 0000000..26371b2 --- /dev/null +++ b/src/tasks/renderers/minimal.ts @@ -0,0 +1,206 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Colors } from '@poppinss/colors/types' + +import { Task } from '../task.js' +import { icons } from '../../icons.js' +import { useColors } from '../../colors.js' +import { ConsoleRenderer } from '../../renderers/console.js' +import type { TaskRendererOptions, RendererContract } from '../../types.js' + +/** + * As the name suggests, render tasks in minimal UI for better viewing + * experience. + */ +export class MinimalRenderer { + /** + * Renderer options + */ + #options: TaskRendererOptions + + /** + * Reference to the colors implementation + */ + #colors?: Colors + + /** + * The renderer to use to output logs + */ + #renderer?: RendererContract + + /** + * List of registered tasks + */ + #registeredTasks: Task[] = [] + + constructor(options: TaskRendererOptions) { + this.#options = { + icons: options.icons === undefined ? true : options.icons, + } + } + + /** + * Format error + */ + #formatError(error: string | { message: string; stack?: string }) { + let message = typeof error === 'string' ? error : error.stack || error.message + message = this.getColors().red(message) + + return `\n ${message + .split('\n') + .map((line) => `${line}`) + .join('\n')}` + } + + /** + * Returns the pointer icon, if icons are not disabled. + */ + #getPointerIcon(color: keyof Colors) { + const icon = this.#options.icons ? `${icons.pointer} ` : '' + if (!icon) { + return icon + } + + return this.getColors()[color](icon) + } + + /** + * Returns the display string for an idle task + */ + #renderIdleTask(task: Task) { + return `${this.#getPointerIcon('dim')}${this.getColors().dim(task.title)}` + } + + /** + * Returns the display string for a running task + */ + #renderRunningTask(task: Task) { + const lastLogLine = task.getLastLoggedLine() + const title = this.#options.icons ? `${icons.pointer} ${task.title}` : task.title + + return `${title}\n ${lastLogLine || ''}` + } + + /** + * Returns the display string for a failed task + */ + #renderFailedTask(task: Task) { + const pointer = this.#getPointerIcon('red') + const duration = this.getColors().dim(`(${task.getDuration()!})`) + + let message = `${pointer}${task.title} ${duration}` + + const error = task.getError() + if (!error) { + return `${message}\n` + } + + message = `${message}${this.#formatError(error)}` + return message + } + + /** + * Returns the display string for a succeeded task + */ + #renderSucceededTask(task: Task) { + const pointer = this.#getPointerIcon('green') + const duration = this.getColors().dim(`(${task.getDuration()!})`) + + let message = `${pointer}${task.title} ${duration}` + + const successMessage = task.getSuccessMessage() + if (!successMessage) { + return `${message}\n` + } + + message = `${message}\n ${this.getColors().green(successMessage)}` + return message + } + + /** + * Renders a given task + */ + #renderTask(task: Task): string { + switch (task.getState()) { + case 'idle': + return this.#renderIdleTask(task) + case 'running': + return this.#renderRunningTask(task) + case 'succeeded': + return this.#renderSucceededTask(task) + case 'failed': + return this.#renderFailedTask(task) + } + } + + /** + * Renders all tasks + */ + #renderTasks() { + this.getRenderer().logUpdate( + this.#registeredTasks.map((task) => this.#renderTask(task)).join('\n') + ) + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Register tasks to render + */ + tasks(tasks: Task[]): this { + this.#registeredTasks = tasks + return this + } + + /** + * Render all tasks + */ + render() { + this.#registeredTasks.forEach((task) => task.onUpdate(() => this.#renderTasks())) + this.#renderTasks() + } +} diff --git a/src/tasks/renderers/raw.ts b/src/tasks/renderers/raw.ts new file mode 100644 index 0000000..f33692d --- /dev/null +++ b/src/tasks/renderers/raw.ts @@ -0,0 +1,175 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Colors } from '@poppinss/colors/types' + +import { Task } from '../task.js' +import { useColors } from '../../colors.js' +import type { RendererContract } from '../../types.js' +import { ConsoleRenderer } from '../../renderers/console.js' + +/** + * Raw renderer shows a detailed output of the tasks without using any + * ansi characters + */ +export class RawRenderer { + /** + * Reference to the colors implementation + */ + #colors?: Colors + + /** + * The renderer to use to output logs + */ + #renderer?: RendererContract + + /** + * List of registered tasks + */ + #registeredTasks: Task[] = [] + + #notifiedTasks: Set = new Set() + + constructor() {} + + /** + * Format error + */ + #formatError(error: string | { message: string; stack?: string }) { + if (typeof error === 'string') { + return `${this.getColors().red(error)}` + } + + if (!error.stack) { + return `${this.getColors().red(error.message)}` + } + + return `${error.stack + .split('\n') + .map((line) => ` ${this.getColors().red(line)}`) + .join('\n')}` + } + + /** + * Renders message for a running task + */ + #renderRunningTask(task: Task) { + if (this.#notifiedTasks.has(task.title)) { + const lastLoggedLine = task.getLastLoggedLine() + if (lastLoggedLine) { + this.getRenderer().log(lastLoggedLine) + } + + return + } + + this.getRenderer().log(`${task.title}\n${new Array(task.title.length + 1).join('-')}`) + this.#notifiedTasks.add(task.title) + } + + /** + * Renders message for a succeeded task + */ + #renderSucceededTask(task: Task) { + const successMessage = task.getSuccessMessage() + const status = this.getColors().green(successMessage || 'completed') + const duration = this.getColors().dim(`(${task.getDuration()})`) + this.getRenderer().log(`${status} ${duration}\n`) + } + + /** + * Renders message for a failed task + */ + #renderFailedTask(task: Task) { + const error = task.getError() + if (error) { + this.getRenderer().logError(this.#formatError(error)) + } + + const status = this.getColors().red('failed') + const duration = this.getColors().dim(`(${task.getDuration()})`) + this.getRenderer().logError(`${status} ${duration}\n`) + } + + /** + * Renders a given task + */ + #renderTask(task: Task) { + switch (task.getState()) { + case 'running': + return this.#renderRunningTask(task) + case 'succeeded': + return this.#renderSucceededTask(task) + case 'failed': + return this.#renderFailedTask(task) + } + } + + /** + * Renders all tasks + */ + #renderTasks() { + this.#registeredTasks.forEach((task) => this.#renderTask(task)) + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Register tasks to render + */ + tasks(tasks: Task[]): this { + this.#registeredTasks = tasks + return this + } + + /** + * Render all tasks + */ + render() { + this.#registeredTasks.forEach((task) => task.onUpdate(($task) => this.#renderTask($task))) + this.#renderTasks() + } +} diff --git a/src/tasks/renderers/verbose.ts b/src/tasks/renderers/verbose.ts new file mode 100644 index 0000000..9062e83 --- /dev/null +++ b/src/tasks/renderers/verbose.ts @@ -0,0 +1,185 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Colors } from '@poppinss/colors/types' + +import { Task } from '../task.js' +import { useColors } from '../../colors.js' +import type { RendererContract } from '../../types.js' +import { ConsoleRenderer } from '../../renderers/console.js' + +/** + * Verbose renderer shows a detailed output of the tasks and the + * messages logged by a given task + */ +export class VerboseRenderer { + /** + * Reference to the colors implementation + */ + #colors?: Colors + + /** + * The renderer to use to output logs + */ + #renderer?: RendererContract + + /** + * List of registered tasks + */ + #registeredTasks: Task[] = [] + + #notifiedTasks: Set = new Set() + + constructor() {} + + /** + * Format error + */ + #formatError(error: string | { message: string; stack?: string }) { + if (typeof error === 'string') { + return `${this.#getAnsiIcon('│', 'dim')}${this.getColors().red(error)}` + } + + if (!error.stack) { + return `${this.#getAnsiIcon('│', 'dim')}${this.getColors().red(error.message)}` + } + + return `${error.stack + .split('\n') + .map((line) => `${this.#getAnsiIcon('│', 'dim')} ${this.getColors().red(line)}`) + .join('\n')}` + } + + /** + * Returns the ansi icon back when icons are enabled + * or an empty string + */ + #getAnsiIcon(icon: string, color: keyof Colors) { + return this.getColors()[color](`${icon} `) + } + + /** + * Renders message for a running task + */ + #renderRunningTask(task: Task) { + if (this.#notifiedTasks.has(task.title)) { + const lastLoggedLine = task.getLastLoggedLine() + if (lastLoggedLine) { + this.getRenderer().log(`${this.#getAnsiIcon('│', 'dim')}${lastLoggedLine}`) + } + + return + } + + this.getRenderer().log(`${this.#getAnsiIcon('┌', 'dim')}${task.title}`) + this.#notifiedTasks.add(task.title) + } + + /** + * Renders message for a succeeded task + */ + #renderSucceededTask(task: Task) { + const successMessage = task.getSuccessMessage() + const icon = this.#getAnsiIcon('└', 'dim') + const status = this.getColors().green(successMessage || 'Completed') + const duration = this.getColors().dim(`(${task.getDuration()})`) + this.getRenderer().log(`${icon}${status} ${duration}`) + } + + /** + * Renders message for a failed task + */ + #renderFailedTask(task: Task) { + const error = task.getError() + if (error) { + this.getRenderer().logError(this.#formatError(error)) + } + + const icon = this.#getAnsiIcon('└', 'dim') + const status = this.getColors().red('Failed') + const duration = this.getColors().dim(`(${task.getDuration()})`) + this.getRenderer().logError(`${icon}${status} ${duration}`) + } + + /** + * Renders a given task + */ + #renderTask(task: Task) { + switch (task.getState()) { + case 'running': + return this.#renderRunningTask(task) + case 'succeeded': + return this.#renderSucceededTask(task) + case 'failed': + return this.#renderFailedTask(task) + } + } + + /** + * Renders all tasks + */ + #renderTasks() { + this.#registeredTasks.forEach((task) => this.#renderTask(task)) + } + + /** + * Returns the renderer for rendering the messages + */ + getRenderer() { + if (!this.#renderer) { + this.#renderer = new ConsoleRenderer() + } + + return this.#renderer + } + + /** + * Define a custom renderer. Logs to "stdout" and "stderr" + * by default + */ + useRenderer(renderer: RendererContract): this { + this.#renderer = renderer + return this + } + + /** + * Returns the colors implementation in use + */ + getColors(): Colors { + if (!this.#colors) { + this.#colors = useColors() + } + + return this.#colors + } + + /** + * Define a custom colors implementation + */ + useColors(color: Colors): this { + this.#colors = color + return this + } + + /** + * Register tasks to render + */ + tasks(tasks: Task[]): this { + this.#registeredTasks = tasks + return this + } + + /** + * Render all tasks + */ + render() { + this.#registeredTasks.forEach((task) => task.onUpdate(($task) => this.#renderTask($task))) + this.#renderTasks() + } +} diff --git a/src/tasks/task.ts b/src/tasks/task.ts new file mode 100644 index 0000000..9290e1d --- /dev/null +++ b/src/tasks/task.ts @@ -0,0 +1,140 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import prettyHrtime from 'pretty-hrtime' + +/** + * Task exposes a very simple API to create tasks with states, along with a + * listener to listen for the task state updates. + * + * The task itself has does not render anything to the console. The task + * renderers does that. + */ +export class Task { + #startTime?: [number, number] + + /** + * Last logged line for the task + */ + #lastLogLine?: string + + /** + * Define one or more update listeners + */ + #updateListeners: ((task: this) => void)[] = [] + + /** + * Duration of the task. Updated after the task is over + */ + #duration?: string + + /** + * Message set after completing the task. Can be an error or the + * a success message + */ + #completionMessage?: string | { message: string; stack?: string } + + /** + * Task current state + */ + #state: 'idle' | 'running' | 'failed' | 'succeeded' = 'idle' + + constructor(public title: string) {} + + #notifyListeners() { + for (let listener of this.#updateListeners) { + listener(this) + } + } + + /** + * Access the task state + */ + getState() { + return this.#state + } + + /** + * Get the time spent in running the task + */ + getDuration() { + return this.#duration || null + } + + /** + * Get error occurred while running the task + */ + getError() { + return this.#completionMessage || null + } + + /** + * Get task completion success message + */ + getSuccessMessage() { + return typeof this.#completionMessage === 'string' ? this.#completionMessage : null + } + + /** + * Last logged line for the task + */ + getLastLoggedLine() { + return this.#lastLogLine || null + } + + /** + * Bind a listener to listen to the state updates of the task + */ + onUpdate(listener: (task: this) => void): this { + this.#updateListeners.push(listener) + return this + } + + /** + * Start the task + */ + start() { + this.#state = 'running' + this.#startTime = process.hrtime() + this.#notifyListeners() + return this + } + + /** + * Update task with log messages. Based upon the renderer + * in use, it may only display one line at a time. + */ + update(message: string): this { + this.#lastLogLine = message + this.#notifyListeners() + return this + } + + /** + * Mark task as completed + */ + markAsSucceeded(message?: string): this { + this.#state = 'succeeded' + this.#duration = prettyHrtime(process.hrtime(this.#startTime)) + this.#completionMessage = message + this.#notifyListeners() + return this + } + + /** + * Mark task as failed + */ + markAsFailed(error: string | { message: string; stack?: string }): this { + this.#state = 'failed' + this.#duration = prettyHrtime(process.hrtime(this.#startTime)) + this.#completionMessage = error + this.#notifyListeners() + return this + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..26c7d97 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,203 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { CharName } from 'cli-table3' +import type { Colors } from '@poppinss/colors/types' + +export { Colors } + +/** + * Shape of the renderer contract. Renderers are responsible for + * writing the logs to a destination. + */ +export interface RendererContract { + getLogs(): { message: string; stream: 'stdout' | 'stderr' }[] + + /** + * Log a message + */ + log(message: string): void + + /** + * Log an error message to stderr + */ + logError(message: string): void + + /** + * Log a message that overwrites the existing + * line + */ + logUpdate(message: string): void + + /** + * Persist log message written using "logUpdate" + */ + logUpdatePersist(): void +} + +/** + * Callback passed while registering task with the tasks manager + */ +export type TaskCallback = (task: { + /** + * Update task progress with a log message + */ + update(logMessage: string): void + + /** + * Build error to mark the task as failed + */ + error(error: T): T extends string ? { message: T; isError: true } : T +}) => + | Error + | Promise + | { isError: true; message: string } + | Promise<{ isError: true; message: string }> + | string + | Promise + +/** + * Options accepted by the tasks renderers + */ +export type TaskRendererOptions = { + /** + * Enable/disable icons. + * + * Defaults to "true" + */ + icons: boolean +} + +/** + * Options accepted by the tasks manager + */ +export type TaskManagerOptions = TaskRendererOptions & { + /** + * Display tasks output in raw mode. + * Defaults to "false". + * + * The raw mode is tailored for easy testing + */ + raw: boolean + + /** + * Display tasks output in verbose mode. + * Defaults to "false". + * + * The verbose mode displays all the task logs and not + * just the latest one + */ + verbose: boolean +} + +/** + * Options accepted by the logger + */ +export type LoggerOptions = { + /** + * Output message with dim transformation. + * + * Defaults to "false" + */ + dim: boolean + + /** + * Output message with dim transformation applied + * only on the labels. + * + * Defaults to "false" + */ + dimLabels: boolean +} + +/** + * Options accepted by the action + */ +export type ActionOptions = { + /** + * Output message with dim transformation. + * + * Defaults to "true" + */ + dim: boolean +} + +/** + * Options accepted by the table + */ +export type TableOptions = { + /** + * Disable ansi output + */ + raw: boolean + + /** + * Chars to configure the table output + */ + chars?: Partial> +} + +/** + * Options accepted by instructions + */ +export type InstructionsOptions = { + /** + * Enable/disable icons. + * + * Defaults to "true" + */ + icons: boolean + + /** + * Display instructions without any ansi output + */ + raw: boolean +} + +/** + * Logging types + */ +export type LoggingTypes = 'success' | 'error' | 'fatal' | 'warning' | 'info' | 'debug' | 'await' + +/** + * The data type to represent the table head + */ +export type TableHead = ( + | string + | { colSpan?: number; hAlign?: 'left' | 'center' | 'right'; content: string } + | { rowSpan?: number; vAlign?: 'top' | 'center' | 'bottom'; content: string } +)[] + +/** + * The data type to represent a table row + */ +export type TableRow = + | ( + | string + | { colSpan?: number; hAlign?: 'left' | 'center' | 'right'; content: string } + | { rowSpan?: number; vAlign?: 'top' | 'center' | 'bottom'; content: string } + )[] + | { [key: string]: string[] } + +/** + * Options accepted by the logger when + * logging messages + */ +export type LoggerMessageOptions = { + prefix?: string + suffix?: string +} + +/** + * The message accepted by the spinner + */ +export type SpinnerMessage = { + text: string + render(): string +} diff --git a/test/action.spec.ts b/test/action.spec.ts deleted file mode 100644 index 9401c91..0000000 --- a/test/action.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' - -import { Logger } from '../src/Logger' -import { Action } from '../src/Logger/Action' -import { MemoryRenderer } from '../src/Renderer/Memory' - -test.group('Action', () => { - test('log action in succeeded state', ({ assert }) => { - const action = new Action('create', new Logger({}, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.succeeded('hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `green(${'CREATE:'}) hello world`, - stream: 'stdout', - }, - ]) - }) - - test('log action in failed state', ({ assert }) => { - const action = new Action('create', new Logger({}, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.failed('hello world', 'File already exists') - - assert.deepEqual(renderer.logs, [ - { - message: `red(ERROR:) hello world dim((File already exists))`, - stream: 'stderr', - }, - ]) - }) - - test('log action in skipped state', ({ assert }) => { - const action = new Action('create', new Logger({}, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.skipped('hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `cyan(SKIP:) hello world`, - stream: 'stdout', - }, - ]) - }) - - test('disable colors', ({ assert }) => { - const action = new Action('create', new Logger({ colors: false }, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.succeeded('hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `CREATE: hello world`, - stream: 'stdout', - }, - ]) - }) - - test('dim message', ({ assert }) => { - const action = new Action('create', new Logger({ dim: true }, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.succeeded('hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `dim(green(CREATE:) hello world)`, - stream: 'stdout', - }, - ]) - }) - - test('add skip reason to the log', ({ assert }) => { - const action = new Action('create', new Logger({}, true)) - const renderer = new MemoryRenderer() - - action.useRenderer(renderer) - action.skipped('hello world', 'invalid message') - - assert.deepEqual(renderer.logs, [ - { - message: `cyan(SKIP:) hello world dim((invalid message))`, - stream: 'stdout', - }, - ]) - }) -}) diff --git a/test/logger.spec.ts b/test/logger.spec.ts deleted file mode 100644 index 50ce847..0000000 --- a/test/logger.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Logger } from '../src/Logger' -import { MemoryRenderer } from '../src/Renderer/Memory' - -test.group('Logger | label', () => { - test('do not add color to label when labelColors property is set to false', ({ assert }) => { - const logger = new Logger({ labelColors: false }, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.success('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ success ] Hello world`, - stream: 'stdout', - }, - ]) - }) - - test('do not add color to label when colors property is set to false', ({ assert }) => { - const logger = new Logger({ colors: false }, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.success('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ success ] Hello world`, - stream: 'stdout', - }, - ]) - }) - - test('dim labels when dimLabels property is set to true', ({ assert }) => { - const logger = new Logger({ dimLabels: true }, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.success('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ dim(green(success)) ] Hello world`, - stream: 'stdout', - }, - ]) - }) - - test('dim labels when dim property is set to true', ({ assert }) => { - const logger = new Logger({ dim: true }, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.success('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ dim(green(success)) ] dim(Hello world)`, - stream: 'stdout', - }, - ]) - }) -}) - -test.group('Logger | success', () => { - test('log success message with icon', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.success('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ green(success) ] Hello world`, - stream: 'stdout', - }, - ]) - }) -}) - -test.group('Logger | error', () => { - test('log error message with label', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.error('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ red(error) ] Hello world`, - stream: 'stderr', - }, - ]) - }) - - test('log error instance as error', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.error(new Error('Hello world')) - - assert.deepEqual(renderer.logs, [ - { - message: `[ red(error) ] Hello world`, - stream: 'stderr', - }, - ]) - }) -}) - -test.group('Logger | fatal', () => { - test('log fatal message with icon', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.fatal('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ red(error) ] Hello world`, - stream: 'stderr', - }, - ]) - }) - - test('log error instance as a fatal message', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.fatal(new Error('Hello world')) - - assert.lengthOf(renderer.logs, 1) - - assert.equal(renderer.logs[0].stream, 'stderr') - assert.equal( - renderer.logs[0].message.split('\n')[1], - `dim( at Object.executor (${__filename}:146:18))` - ) - }) -}) - -test.group('Logger | warning', () => { - test('log warning message with icon', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.warning('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ yellow(warn) ] Hello world`, - stream: 'stdout', - }, - ]) - }) -}) - -test.group('Logger | info', () => { - test('log info message with icon', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.info('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ blue(info) ] Hello world`, - stream: 'stdout', - }, - ]) - }) -}) - -test.group('Logger | debug', () => { - test('log debug message with icon', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - logger.debug('Hello world') - - assert.deepEqual(renderer.logs, [ - { - message: `[ cyan(debug) ] Hello world`, - stream: 'stdout', - }, - ]) - }) -}) - -test.group('Logger | await', () => { - test('start spinner', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `[ cyan(wait) ] installing ...`, - stream: 'stdout', - }, - ]) - }) - - test('start spinner with a custom prefix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', 'npm') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `dim([npm]) [ cyan(wait) ] installing ...`, - stream: 'stdout', - }, - ]) - }) - - test('updating spinner text must retain the prefix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', 'npm') - spinner.update('updating') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `dim([npm]) [ cyan(wait) ] installing ...`, - stream: 'stdout', - }, - { - message: `dim([npm]) [ cyan(wait) ] updating ...`, - stream: 'stdout', - }, - ]) - }) - - test('update spinner with new prefix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', 'npm') - spinner.update('updating', 'fs') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `dim([npm]) [ cyan(wait) ] installing ...`, - stream: 'stdout', - }, - { - message: `dim([fs]) [ cyan(wait) ] updating ...`, - stream: 'stdout', - }, - ]) - }) - - test('start spinner with a custom suffix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', undefined, 'npm') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `[ cyan(wait) ] installing dim(yellow((npm))) ...`, - stream: 'stdout', - }, - ]) - }) - - test('updating spinner text must retain the suffix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', undefined, 'npm') - spinner.update('updating') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `[ cyan(wait) ] installing dim(yellow((npm))) ...`, - stream: 'stdout', - }, - { - message: `[ cyan(wait) ] updating dim(yellow((npm))) ...`, - stream: 'stdout', - }, - ]) - }) - - test('update spinner with new suffix', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = logger.await('installing', undefined, 'npm') - spinner.update('updating', undefined, 'fs') - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: `[ cyan(wait) ] installing dim(yellow((npm))) ...`, - stream: 'stdout', - }, - { - message: `[ cyan(wait) ] updating dim(yellow((fs))) ...`, - stream: 'stdout', - }, - ]) - }) -}) diff --git a/test/spinner.spec.ts b/test/spinner.spec.ts deleted file mode 100644 index 23289d6..0000000 --- a/test/spinner.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * @poppinss/utils - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Logger } from '../src/Logger' -import { Spinner } from '../src/Logger/Spinner' -import { MemoryRenderer } from '../src/Renderer/Memory' - -test.group('Spinner', () => { - test('print the message with progress bar', ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = new Spinner('hello world', logger, true) - spinner.start() - - assert.deepEqual(renderer.logs, [ - { - message: 'hello world ...', - stream: 'stdout', - }, - ]) - }) - - test('update message on the update call', async ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = new Spinner('hello world', logger, true) - - spinner.start() - spinner.update('hi world') - - assert.deepEqual(renderer.logs, [ - { - message: 'hello world ...', - stream: 'stdout', - }, - { - message: 'hi world ...', - stream: 'stdout', - }, - ]) - }) - - test('stop in test mode must be a noop', async ({ assert }) => { - const logger = new Logger({}, true) - const renderer = new MemoryRenderer() - - logger.useRenderer(renderer) - const spinner = new Spinner('hello world', logger, true) - spinner.start() - spinner.stop() - - assert.deepEqual(renderer.logs, [ - { - message: 'hello world ...', - stream: 'stdout', - }, - ]) - }) -}) diff --git a/test/table.spec.ts b/test/table.spec.ts deleted file mode 100644 index 55ba11a..0000000 --- a/test/table.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Table } from '../src/Table' -import { MemoryRenderer } from '../src/Renderer/Memory' - -test.group('Table', () => { - test('render table', ({ assert }) => { - const table = new Table({}, true) - const renderer = new MemoryRenderer() - - table.useRenderer(renderer) - table.head(['name', 'profession']) - table.row(['virk', 'engineer']) - - table.render() - assert.deepEqual(renderer.logs, [ - { - message: 'cyan(name)|cyan(profession)', - stream: 'stdout', - }, - { - message: 'virk|engineer', - stream: 'stdout', - }, - ]) - }) - - test('render table with mutliple rows', ({ assert }) => { - const table = new Table({}, true) - const renderer = new MemoryRenderer() - - table.useRenderer(renderer) - table.head(['name', 'profession']) - table.row(['virk', 'engineer']) - table.row(['romain', 'engineer']) - - table.render() - assert.deepEqual(renderer.logs, [ - { - message: 'cyan(name)|cyan(profession)', - stream: 'stdout', - }, - { - message: 'virk|engineer', - stream: 'stdout', - }, - { - message: 'romain|engineer', - stream: 'stdout', - }, - ]) - }) - - test('disable colors', ({ assert }) => { - const table = new Table({ colors: false }, true) - const renderer = new MemoryRenderer() - - table.useRenderer(renderer) - table.head(['name', 'profession']) - table.row(['virk', 'engineer']) - table.row(['romain', 'engineer']) - - table.render() - assert.deepEqual(renderer.logs, [ - { - message: 'name|profession', - stream: 'stdout', - }, - { - message: 'virk|engineer', - stream: 'stdout', - }, - { - message: 'romain|engineer', - stream: 'stdout', - }, - ]) - }) -}) diff --git a/test/task-manager.spec.ts b/test/task-manager.spec.ts deleted file mode 100644 index 027d0a0..0000000 --- a/test/task-manager.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* - * @poppinss/cliui - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -// import { icons } from '../src/Icons' -import { TaskManager } from '../src/Task/Manager' -import { MemoryRenderer } from '../src/Renderer/Memory' - -test.group('TaskManager', () => { - test('run multiple tasks in sequence', async ({ assert }) => { - const renderer = new MemoryRenderer() - const manager = new TaskManager({}, true) - - await manager - .useRenderer(renderer) - .add('task 1', async (logger, task) => { - assert.equal(manager.state, 'running') - logger.log('log task 1') - await task.complete() - }) - .add('task 2', async (logger, task) => { - assert.equal(manager.state, 'running') - logger.log('log task 2') - await task.complete() - }) - .run() - - assert.equal(manager.state, 'succeeded') - assert.deepEqual(renderer.logs, [ - { - message: 'dim(┌) task 1', - stream: 'stdout', - }, - { - message: 'dim(│) log task 1', - stream: 'stdout', - }, - { - message: renderer.logs[2].message, - stream: 'stdout', - }, - { - message: 'dim(┌) task 2', - stream: 'stdout', - }, - { - message: 'dim(│) log task 2', - stream: 'stdout', - }, - { - message: renderer.logs[5].message, - stream: 'stdout', - }, - ]) - }) - - test('do not run next task when previous one fails', async ({ assert }) => { - const renderer = new MemoryRenderer() - const manager = new TaskManager({}, true) - - await manager - .useRenderer(renderer) - .add('task 1', async (logger, task) => { - assert.equal(manager.state, 'running') - logger.log('log task 1') - await task.complete() - }) - .add('task 2', async (logger, task) => { - assert.equal(manager.state, 'running') - logger.log('log task 2') - await task.fail('Something went wrong') - }) - .add('task 3', async (logger, task) => { - logger.log('log task 3') - await task.complete() - }) - .run() - - assert.equal(manager.state, 'failed') - assert.equal(manager.error, 'Something went wrong') - assert.deepEqual(renderer.logs, [ - { - message: 'dim(┌) task 1', - stream: 'stdout', - }, - { - message: 'dim(│) log task 1', - stream: 'stdout', - }, - { - message: renderer.logs[2].message, - stream: 'stdout', - }, - { - message: 'dim(┌) task 2', - stream: 'stdout', - }, - { - message: 'dim(│) log task 2', - stream: 'stdout', - }, - { - message: `dim(│) [ dim(red(error)) ] dim(Something went wrong)`, - stream: 'stderr', - }, - { - message: renderer.logs[6].message, - stream: 'stderr', - }, - ]) - }) - - test('handle exceptions to auto fail the tasks', async ({ assert }) => { - const renderer = new MemoryRenderer() - const manager = new TaskManager({}, true) - - await manager - .useRenderer(renderer) - .add('task 1', async (logger, task) => { - assert.equal(manager.state, 'running') - logger.log('log task 1') - await task.complete() - }) - .add('task 2', async (logger) => { - assert.equal(manager.state, 'running') - logger.log('log task 2') - throw new Error('Something went wrong') - }) - .add('task 3', async (logger, task) => { - logger.log('log task 3') - await task.complete() - }) - .run() - - assert.equal(manager.state, 'failed') - assert.equal(manager.error.message, 'Something went wrong') - assert.deepEqual(renderer.logs, [ - { - message: 'dim(┌) task 1', - stream: 'stdout', - }, - { - message: 'dim(│) log task 1', - stream: 'stdout', - }, - { - message: renderer.logs[2].message, - stream: 'stdout', - }, - { - message: 'dim(┌) task 2', - stream: 'stdout', - }, - { - message: 'dim(│) log task 2', - stream: 'stdout', - }, - { - message: renderer.logs[5].message, - stream: 'stderr', - }, - { - message: renderer.logs[6].message, - stream: 'stderr', - }, - ]) - }) - - test('disable colors', async ({ assert }) => { - const renderer = new MemoryRenderer() - const manager = new TaskManager({ colors: false }, true) - - await manager - .useRenderer(renderer) - .add('task 1', async (logger, task) => { - logger.log('log task 1') - await task.complete() - }) - .add('task 2', async (logger, task) => { - logger.log('log task 2') - await task.fail('Something went wrong') - }) - .add('task 3', async (logger, task) => { - logger.log('log task 3') - await task.complete() - }) - .run() - - assert.deepEqual(renderer.logs, [ - { - message: '┌ task 1', - stream: 'stdout', - }, - { - message: '│ log task 1', - stream: 'stdout', - }, - { - message: renderer.logs[2].message, - stream: 'stdout', - }, - { - message: '┌ task 2', - stream: 'stdout', - }, - { - message: '│ log task 2', - stream: 'stdout', - }, - { - message: `│ [ error ] Something went wrong`, - stream: 'stderr', - }, - { - message: renderer.logs[6].message, - stream: 'stderr', - }, - ]) - }) -}) diff --git a/tests/action.spec.ts b/tests/action.spec.ts new file mode 100644 index 0000000..7190b6f --- /dev/null +++ b/tests/action.spec.ts @@ -0,0 +1,112 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { useColors } from '../src/colors.js' +import { Action } from '../src/logger/action.js' +import { MemoryRenderer } from '../src/renderers/memory.js' + +test.group('Action', () => { + test('log action in succeeded state', ({ assert }) => { + const action = new Action('creating file') + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ raw: true })) + action.succeeded() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `green(${'DONE:'}) creating file`, + stream: 'stdout', + }, + ]) + }) + + test('log action in failed state', ({ assert }) => { + const action = new Action('creating file') + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ raw: true })) + action.failed('File already exists') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `red(FAILED:) creating file \n red(File already exists)`, + stream: 'stderr', + }, + ]) + }) + + test('log action in skipped state', ({ assert }) => { + const action = new Action('creating file') + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ raw: true })) + action.skipped() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `cyan(SKIPPED:) creating file`, + stream: 'stdout', + }, + ]) + }) + + test('disable colors', ({ assert }) => { + const action = new Action('creating file') + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ silent: true })) + action.succeeded() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `DONE: creating file`, + stream: 'stdout', + }, + ]) + }) + + test('dim message', ({ assert }) => { + const action = new Action('creating file', { dim: true }) + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ raw: true })) + action.succeeded() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `dim(green(DONE:)) dim(creating file)`, + stream: 'stdout', + }, + ]) + }) + + test('add skip reason to the log', ({ assert }) => { + const action = new Action('creating file') + const renderer = new MemoryRenderer() + + action.useRenderer(renderer) + action.useColors(useColors({ raw: true })) + action.skipped('invalid message') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `cyan(SKIPPED:) creating file dim((invalid message))`, + stream: 'stdout', + }, + ]) + }) +}) diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts new file mode 100644 index 0000000..328a4f6 --- /dev/null +++ b/tests/helpers.spec.ts @@ -0,0 +1,185 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { justify, truncate, wrap } from '../src/helpers.js' + +test.group('Helpers | justify', () => { + test('justify a string by adding space to the end of it', ({ assert }) => { + const maxWidth = 20 + const justifiedColumns = justify(['help', 'serve', 'make:controller'], { maxWidth }) + + assert.deepEqual(justifiedColumns, [ + 'help ', + 'serve ', + 'make:controller ', + ]) + }) + + test('justify and right align', ({ assert }) => { + const maxWidth = 20 + const justifiedColumns = justify(['help', 'serve', 'make:controller'], { + maxWidth, + align: 'right', + }) + + assert.deepEqual(justifiedColumns, [ + ' help', + ' serve', + ' make:controller', + ]) + }) + + test('use custom padding char', ({ assert }) => { + const maxWidth = 20 + const justifiedColumns = justify(['help', 'serve', 'make:controller'], { + maxWidth, + align: 'right', + paddingChar: '.', + }) + + assert.deepEqual(justifiedColumns, [ + '................help', + '...............serve', + '.....make:controller', + ]) + }) + + test('do not add padding when column size is already same as the maxWidth', ({ assert }) => { + const maxWidth = 15 + const justifiedColumns = justify(['help', 'serve', 'make:controller'], { + maxWidth, + align: 'right', + }) + + assert.deepEqual(justifiedColumns, [' help', ' serve', 'make:controller']) + }) +}) + +test.group('Helpers | wrap', () => { + test('wrap a string value within start and end column', ({ assert }) => { + const wrappedColumns = wrap( + [ + 'Wrap the text under the starting and the ending column. The first line will start at 1st column', + 'However, from the 2nd line onwards, the columns before the start column are filled with white space.', + 'Trim the text after a certain width', + ], + { + startColumn: 2, + trimStart: true, + endColumn: 40, + } + ) + + assert.deepEqual(wrappedColumns, [ + [ + 'Wrap the text under the starting and', + ' the ending column. The first line', + ' will start at 1st column', + ].join('\n'), + [ + 'However, from the 2nd line onwards,', + ' the columns before the start column', + ' are filled with white space.', + ].join('\n'), + 'Trim the text after a certain width', + ]) + }) + + test('do not trim starting padding', ({ assert }) => { + const wrappedColumns = wrap( + [ + 'Wrap the text under the starting and the ending column. The first line will start at 1st column', + 'However, from the 2nd line onwards, the columns before the start column are filled with white space.', + 'Trim the text after a certain width', + ], + { + startColumn: 2, + endColumn: 40, + trimStart: false, + } + ) + + assert.deepEqual(wrappedColumns, [ + [ + ' Wrap the text under the starting and', + ' the ending column. The first line', + ' will start at 1st column', + ].join('\n'), + [ + ' However, from the 2nd line onwards,', + ' the columns before the start column', + ' are filled with white space.', + ].join('\n'), + ' Trim the text after a certain width', + ]) + }) +}) + +test.group('Helpers | truncate', () => { + test('trim value after a certain length', ({ assert }) => { + const trimmedColumns = truncate( + [ + 'Wrap the text under the starting and the ending column.', + 'However, from the 2nd line onwards, the columns', + 'Trim the text', + ], + { + maxWidth: 20, + } + ) + + assert.deepEqual(trimmedColumns, [ + 'Wrap the text under…', + 'However, from the 2…', + 'Trim the text', + ]) + }) + + test('trim value from the start', ({ assert }) => { + const trimmedColumns = truncate( + [ + 'Wrap the text under the starting and the ending column.', + 'However, from the 2nd line onwards, the columns', + 'Trim the text', + ], + { + maxWidth: 20, + position: 'start', + } + ) + + assert.deepEqual(trimmedColumns, [ + '… the ending column.', + '…nwards, the columns', + 'Trim the text', + ]) + }) + + test('use a custom truncation character', ({ assert }) => { + const trimmedColumns = truncate( + [ + 'Wrap the text under the starting and the ending column.', + 'However, from the 2nd line onwards, the columns', + 'Trim the text', + ], + { + maxWidth: 20, + position: 'start', + truncationChar: '...', + } + ) + + assert.deepEqual(trimmedColumns, [ + '...he ending column.', + '...ards, the columns', + 'Trim the text', + ]) + }) +}) diff --git a/test/instructions.spec.ts b/tests/instructions.spec.ts similarity index 57% rename from test/instructions.spec.ts rename to tests/instructions.spec.ts index da83ad4..5634621 100644 --- a/test/instructions.spec.ts +++ b/tests/instructions.spec.ts @@ -1,7 +1,7 @@ /* * @poppinss/cliui * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -9,20 +9,23 @@ import { test } from '@japa/runner' -import { icons } from '../src/Icons' -import { Instructions } from '../src/Instructions' -import { MemoryRenderer } from '../src/Renderer/Memory' +import { icons } from '../src/icons.js' +import { useColors } from '../src/colors.js' +import { Instructions } from '../src/instructions.js' +import { MemoryRenderer } from '../src/renderers/memory.js' test.group('Instructions', () => { test('render instruction line', ({ assert }) => { - const instructions = new Instructions({}, true) + const instructions = new Instructions({ raw: true }) const renderer = new MemoryRenderer() instructions.useRenderer(renderer) + instructions.useColors(useColors({ raw: true })) + instructions.add('hello world') instructions.render() - assert.deepEqual(renderer.logs, [ + assert.deepEqual(renderer.getLogs(), [ { message: `dim(${icons.pointer}) hello world`, stream: 'stdout', @@ -31,73 +34,59 @@ test.group('Instructions', () => { }) test('render multiple instruction lines', ({ assert }) => { - const instructions = new Instructions({}, true) + const instructions = new Instructions({ raw: true }) const renderer = new MemoryRenderer() instructions.useRenderer(renderer) + instructions.useColors(useColors({ raw: true })) + instructions.add('hello world') instructions.add('hi world') instructions.render() - assert.deepEqual(renderer.logs, [ - { - message: `dim(${icons.pointer}) hello world`, - stream: 'stdout', - }, + assert.deepEqual(renderer.getLogs(), [ { - message: `dim(${icons.pointer}) hi world`, + message: `dim(${icons.pointer}) hello world\ndim(${icons.pointer}) hi world`, stream: 'stdout', }, ]) }) test('render instruction heading', ({ assert }) => { - const instructions = new Instructions({}, true) + const instructions = new Instructions({ raw: true }) const renderer = new MemoryRenderer() instructions.useRenderer(renderer) + instructions.useColors(useColors({ raw: true })) + instructions.heading('hey') instructions.add('hello world') instructions.add('hi world') instructions.render() - assert.deepEqual(renderer.logs, [ + assert.deepEqual(renderer.getLogs(), [ { - message: 'hey', - stream: 'stdout', - }, - { - message: `dim(${icons.pointer}) hello world`, - stream: 'stdout', - }, - { - message: `dim(${icons.pointer}) hi world`, + message: `hey\ndim(${icons.pointer}) hello world\ndim(${icons.pointer}) hi world`, stream: 'stdout', }, ]) }) test('disable icons', ({ assert }) => { - const instructions = new Instructions({ icons: false }, true) + const instructions = new Instructions({ raw: true, icons: false }) const renderer = new MemoryRenderer() instructions.useRenderer(renderer) + instructions.useColors(useColors({ raw: true })) + instructions.heading('hey') instructions.add('hello world') instructions.add('hi world') instructions.render() - assert.deepEqual(renderer.logs, [ - { - message: 'hey', - stream: 'stdout', - }, - { - message: `hello world`, - stream: 'stdout', - }, + assert.deepEqual(renderer.getLogs(), [ { - message: `hi world`, + message: `hey\nhello world\nhi world`, stream: 'stdout', }, ]) diff --git a/tests/logger.spec.ts b/tests/logger.spec.ts new file mode 100644 index 0000000..bc3edda --- /dev/null +++ b/tests/logger.spec.ts @@ -0,0 +1,432 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { useColors } from '../src/colors.js' +import { Logger } from '../src/logger/main.js' +import { MemoryRenderer } from '../src/renderers/memory.js' + +test.group('Logger | label', () => { + test('dim labels when dimLabels property is set to true', ({ assert }) => { + const logger = new Logger({ dimLabels: true }) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.success('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ dim(green(success)) ] Hello world`, + stream: 'stdout', + }, + ]) + }) + + test('dim labels when dim property is set to true', ({ assert }) => { + const logger = new Logger({ dim: true }) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.success('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ dim(green(success)) ] dim(Hello world)`, + stream: 'stdout', + }, + ]) + }) +}) + +test.group('Logger | success', () => { + test('log success message with icon', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.success('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ green(success) ] Hello world`, + stream: 'stdout', + }, + ]) + }) +}) + +test.group('Logger | error', () => { + test('log error message with label', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.error('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ red(error) ] Hello world`, + stream: 'stderr', + }, + ]) + }) + + test('log error instance as error', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.error(new Error('Hello world')) + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ red(error) ] Hello world`, + stream: 'stderr', + }, + ]) + }) +}) + +test.group('Logger | fatal', () => { + test('log fatal message with icon', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.fatal('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ red(error) ] Hello world`, + stream: 'stderr', + }, + ]) + }) + + test('log error instance as a fatal message', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.fatal(new Error('Hello world')) + + assert.lengthOf(renderer.getLogs(), 1) + + assert.equal(renderer.getLogs()[0].stream, 'stderr') + assert.match(renderer.getLogs()[0].message.split('\n')[1], new RegExp(import.meta.url)) + }) +}) + +test.group('Logger | warning', () => { + test('log warning message with icon', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.warning('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ yellow(warn) ] Hello world`, + stream: 'stdout', + }, + ]) + }) +}) + +test.group('Logger | info', () => { + test('log info message with icon', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.info('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ blue(info) ] Hello world`, + stream: 'stdout', + }, + ]) + }) +}) + +test.group('Logger | debug', () => { + test('log debug message with icon', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.debug('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ cyan(debug) ] Hello world`, + stream: 'stdout', + }, + ]) + }) +}) + +test.group('Logger | await', () => { + test('start spinner', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + const spinner = logger.await('installing') + spinner.start() + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ cyan(wait) ] installing . `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] installing ...`, + stream: 'stdout', + }, + ]) + }) + + test('start spinner with a custom prefix', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + const spinner = logger.await('installing', { prefix: 'npm' }) + spinner.start() + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `dim([npm]) [ cyan(wait) ] installing . `, + stream: 'stdout', + }, + { + message: `dim([npm]) [ cyan(wait) ] installing ...`, + stream: 'stdout', + }, + ]) + }) + + test('updating spinner text must retain the prefix', async ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + const spinner = logger.await('installing', { prefix: 'npm' }) + spinner.start() + spinner.update('updating') + + /** + * Sleep until next interval of 200 milliseconds + little buffer + */ + await new Promise((resolve) => setTimeout(resolve, 210)) + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `dim([npm]) [ cyan(wait) ] installing . `, + stream: 'stdout', + }, + { + message: `dim([npm]) [ cyan(wait) ] updating .. `, + stream: 'stdout', + }, + { + message: `dim([npm]) [ cyan(wait) ] updating ...`, + stream: 'stdout', + }, + ]) + }) + + test('update spinner with new prefix', async ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + const spinner = logger.await('installing', { prefix: 'npm' }) + spinner.start() + spinner.update('updating', { prefix: 'fs' }) + + /** + * Sleep until next interval of 200 milliseconds + little buffer + */ + await new Promise((resolve) => setTimeout(resolve, 210)) + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `dim([npm]) [ cyan(wait) ] installing . `, + stream: 'stdout', + }, + { + message: `dim([fs]) [ cyan(wait) ] updating .. `, + stream: 'stdout', + }, + { + message: `dim([fs]) [ cyan(wait) ] updating ...`, + stream: 'stdout', + }, + ]) + }) + + test('start spinner with a custom suffix', ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + const spinner = logger.await('installing', { suffix: 'npm' }) + spinner.start() + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ cyan(wait) ] installing dim(yellow((npm))) . `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] installing dim(yellow((npm))) ...`, + stream: 'stdout', + }, + ]) + }) + + test('updating spinner text must retain the suffix', async ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + const spinner = logger.await('installing', { suffix: 'npm' }) + spinner.start() + spinner.update('updating') + + /** + * Sleep until next interval of 200 milliseconds + little buffer + */ + await new Promise((resolve) => setTimeout(resolve, 210)) + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ cyan(wait) ] installing dim(yellow((npm))) . `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] updating dim(yellow((npm))) .. `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] updating dim(yellow((npm))) ...`, + stream: 'stdout', + }, + ]) + }) + + test('update spinner with new suffix', async ({ assert }) => { + const logger = new Logger({}) + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + const spinner = logger.await('installing', { suffix: 'npm' }) + spinner.start() + spinner.update('updating', { suffix: 'fs' }) + + /** + * Sleep until next interval of 200 milliseconds + little buffer + */ + await new Promise((resolve) => setTimeout(resolve, 210)) + + spinner.stop() + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ cyan(wait) ] installing dim(yellow((npm))) . `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] updating dim(yellow((fs))) .. `, + stream: 'stdout', + }, + { + message: `[ cyan(wait) ] updating dim(yellow((fs))) ...`, + stream: 'stdout', + }, + ]) + }) + + test('log using the child logger', ({ assert }) => { + const logger = new Logger() + const renderer = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.success('Hello world') + logger.child({ dimLabels: true }).success('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ green(success) ] Hello world`, + stream: 'stdout', + }, + { + message: `[ dim(green(success)) ] Hello world`, + stream: 'stdout', + }, + ]) + }) + + test('assign different renderer to the child logger', ({ assert }) => { + const logger = new Logger() + const renderer = new MemoryRenderer() + const renderer1 = new MemoryRenderer() + + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + logger.success('Hello world') + logger.child({ dimLabels: true }).useRenderer(renderer1).success('Hello world') + + assert.deepEqual(renderer.getLogs(), [ + { + message: `[ green(success) ] Hello world`, + stream: 'stdout', + }, + ]) + + assert.deepEqual(renderer1.getLogs(), [ + { + message: `[ dim(green(success)) ] Hello world`, + stream: 'stdout', + }, + ]) + }) +}) diff --git a/tests/table.spec.ts b/tests/table.spec.ts new file mode 100644 index 0000000..549c856 --- /dev/null +++ b/tests/table.spec.ts @@ -0,0 +1,137 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Table } from '../src/table.js' +import { useColors } from '../src/colors.js' +import { MemoryRenderer } from '../src/renderers/memory.js' + +test.group('Table', () => { + test('render table', ({ assert }) => { + const table = new Table({ raw: true }) + const renderer = new MemoryRenderer() + + table.useRenderer(renderer) + table.useColors(useColors({ raw: true })) + table.head(['name', 'profession']) + table.row(['virk', 'engineer']) + + table.render() + assert.deepEqual(renderer.getLogs(), [ + { + message: 'name|profession', + stream: 'stdout', + }, + { + message: 'virk|engineer', + stream: 'stdout', + }, + ]) + }) + + test('render table with mutliple rows', ({ assert }) => { + const table = new Table({ raw: true }) + const renderer = new MemoryRenderer() + + table.useRenderer(renderer) + table.useColors(useColors({ raw: true })) + + table.head(['name', 'profession']) + table.row(['virk', 'engineer']) + table.row(['romain', 'engineer']) + + table.render() + assert.deepEqual(renderer.getLogs(), [ + { + message: 'name|profession', + stream: 'stdout', + }, + { + message: 'virk|engineer', + stream: 'stdout', + }, + { + message: 'romain|engineer', + stream: 'stdout', + }, + ]) + }) + + test('disable colors', ({ assert }) => { + const table = new Table({ raw: true }) + const renderer = new MemoryRenderer() + + table.useRenderer(renderer) + table.useColors(useColors({ silent: true })) + + table.head(['name', 'profession']) + table.row(['virk', 'engineer']) + table.row(['romain', 'engineer']) + + table.render() + assert.deepEqual(renderer.getLogs(), [ + { + message: 'name|profession', + stream: 'stdout', + }, + { + message: 'virk|engineer', + stream: 'stdout', + }, + { + message: 'romain|engineer', + stream: 'stdout', + }, + ]) + }) + + test('render table head from object', ({ assert }) => { + const table = new Table({ raw: true }) + const renderer = new MemoryRenderer() + + table.useRenderer(renderer) + table.useColors(useColors({ raw: true })) + table.head([{ content: 'name' }, { content: 'profession' }]) + table.row(['virk', 'engineer']) + + table.render() + assert.deepEqual(renderer.getLogs(), [ + { + message: 'name|profession', + stream: 'stdout', + }, + { + message: 'virk|engineer', + stream: 'stdout', + }, + ]) + }) + + test('render table row from object', ({ assert }) => { + const table = new Table({ raw: true }) + const renderer = new MemoryRenderer() + + table.useRenderer(renderer) + table.useColors(useColors({ raw: true })) + table.head([{ content: 'name' }, { content: 'profession' }]) + table.row([{ content: 'virk' }, { content: 'engineer' }]) + + table.render() + assert.deepEqual(renderer.getLogs(), [ + { + message: 'name|profession', + stream: 'stdout', + }, + { + message: 'virk|engineer', + stream: 'stdout', + }, + ]) + }) +}) diff --git a/test/task.spec.ts b/tests/task.spec.ts similarity index 67% rename from test/task.spec.ts rename to tests/task.spec.ts index c05d50c..abdf6a3 100644 --- a/test/task.spec.ts +++ b/tests/task.spec.ts @@ -1,19 +1,19 @@ /* * @poppinss/cliui * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import { test } from '@japa/runner' -import { Task } from '../src/Task' +import { Task } from '../src/tasks/task.js' test.group('Task', () => { test('initiate task in idle mode', ({ assert }) => { const task = new Task('install deps') - assert.equal(task.state, 'idle') + assert.equal(task.getState(), 'idle') }) test('starting a task should notify listener', ({ assert }, done) => { @@ -21,7 +21,7 @@ test.group('Task', () => { const task = new Task('install deps') task.onUpdate(($task) => { - assert.equal($task.state, 'running') + assert.equal($task.getState(), 'running') done() }) @@ -34,7 +34,7 @@ test.group('Task', () => { const task = new Task('install deps') task.start() - assert.equal(task.state, 'running') + assert.equal(task.getState(), 'running') }) test('mark test as completed', ({ assert }, done) => { @@ -44,11 +44,11 @@ test.group('Task', () => { task.start() task.onUpdate(($task) => { - assert.equal($task.state, 'succeeded') + assert.equal($task.getState(), 'succeeded') done() }) - task.complete() + task.markAsSucceeded() }).waitForDone() test('mark test as completed with summary', ({ assert }, done) => { @@ -58,12 +58,12 @@ test.group('Task', () => { task.start() task.onUpdate(($task) => { - assert.equal($task.state, 'succeeded') - assert.equal($task.completionMessage, 'All good') + assert.equal($task.getState(), 'succeeded') + assert.equal($task.getSuccessMessage(), 'All good') done() }) - task.complete('All good') + task.markAsSucceeded('All good') }).waitForDone() test('mark test as failed', ({ assert }, done) => { @@ -73,12 +73,12 @@ test.group('Task', () => { task.start() task.onUpdate(($task) => { - assert.equal($task.state, 'failed') - assert.equal($task.completionMessage, 'Something went wrong') + assert.equal($task.getState(), 'failed') + assert.equal($task.getError(), 'Something went wrong') done() }) - task.fail('Something went wrong') + task.markAsFailed('Something went wrong') }).waitForDone() test('pass error instance as task failure message', ({ assert }, done) => { @@ -88,11 +88,11 @@ test.group('Task', () => { task.start() task.onUpdate(($task) => { - assert.equal($task.state, 'failed') - assert.equal(($task.completionMessage as Error).message, 'Something went wrong') + assert.equal($task.getState(), 'failed') + assert.equal(($task.getError() as Error).message, 'Something went wrong') done() }) - task.fail(new Error('Something went wrong')) + task.markAsFailed(new Error('Something went wrong')) }).waitForDone() }) diff --git a/tests/task_manager.spec.ts b/tests/task_manager.spec.ts new file mode 100644 index 0000000..17748f3 --- /dev/null +++ b/tests/task_manager.spec.ts @@ -0,0 +1,266 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { useColors } from '../src/colors.js' +import { Logger } from '../src/logger/main.js' +import { TaskManager } from '../src/tasks/manager.js' +import { MemoryRenderer } from '../src/renderers/memory.js' + +test.group('TaskManager', () => { + test('run multiple tasks in sequence', async ({ assert }) => { + const renderer = new MemoryRenderer() + + const manager = new TaskManager({ verbose: true }) + manager.useRenderer(renderer) + manager.useColors(useColors({ raw: true })) + + const logger = new Logger() + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + await manager + .add('task 1', async () => { + assert.equal(manager.getState(), 'running') + logger.log('log task 1') + return '' + }) + .add('task 2', async () => { + assert.equal(manager.getState(), 'running') + logger.log('log task 2') + return '' + }) + .run() + + assert.equal(manager.getState(), 'succeeded') + + assert.deepEqual(renderer.getLogs(), [ + { + message: 'dim(┌ )task 1', + stream: 'stdout', + }, + { + message: 'log task 1', + stream: 'stdout', + }, + { + message: renderer.getLogs()[2].message, + stream: 'stdout', + }, + { + message: 'dim(┌ )task 2', + stream: 'stdout', + }, + { + message: 'log task 2', + stream: 'stdout', + }, + { + message: renderer.getLogs()[5].message, + stream: 'stdout', + }, + ]) + }) + + test('do not run next task when previous one fails', async ({ assert }) => { + const renderer = new MemoryRenderer() + + const manager = new TaskManager({ verbose: true }) + manager.useRenderer(renderer) + manager.useColors(useColors({ raw: true })) + + const logger = new Logger() + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + await manager + .add('task 1', async () => { + assert.equal(manager.getState(), 'running') + logger.log('log task 1') + return '' + }) + .add('task 2', async (task) => { + assert.equal(manager.getState(), 'running') + logger.log('log task 2') + return task.error('Something went wrong') + }) + .add('task 3', async () => { + logger.log('log task 3') + return '' + }) + .run() + + assert.equal(manager.getState(), 'failed') + assert.deepEqual( + manager + .tasks() + .find((t) => !!t.getError())! + .getError(), + { message: 'Something went wrong', isError: true } + ) + + assert.deepEqual(renderer.getLogs(), [ + { + message: 'dim(┌ )task 1', + stream: 'stdout', + }, + { + message: 'log task 1', + stream: 'stdout', + }, + { + message: renderer.getLogs()[2].message, + stream: 'stdout', + }, + { + message: 'dim(┌ )task 2', + stream: 'stdout', + }, + { + message: 'log task 2', + stream: 'stdout', + }, + { + message: 'dim(│ )red(Something went wrong)', + stream: 'stderr', + }, + { + message: renderer.getLogs()[6].message, + stream: 'stderr', + }, + ]) + }) + + test('handle exceptions to auto fail the tasks', async ({ assert }) => { + const renderer = new MemoryRenderer() + + const manager = new TaskManager({ verbose: true }) + manager.useRenderer(renderer) + manager.useColors(useColors({ raw: true })) + + const logger = new Logger() + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + await manager + .add('task 1', async () => { + assert.equal(manager.getState(), 'running') + logger.log('log task 1') + return '' + }) + .add('task 2', async () => { + assert.equal(manager.getState(), 'running') + logger.log('log task 2') + throw new Error('Something went wrong') + }) + .add('task 3', async () => { + logger.log('log task 3') + return '' + }) + .run() + + assert.equal(manager.getState(), 'failed') + assert.containsSubset( + manager + .tasks() + .find((t) => !!t.getError())! + .getError(), + { message: 'Something went wrong' } + ) + + assert.deepEqual(renderer.getLogs(), [ + { + message: 'dim(┌ )task 1', + stream: 'stdout', + }, + { + message: 'log task 1', + stream: 'stdout', + }, + { + message: renderer.getLogs()[2].message, + stream: 'stdout', + }, + { + message: 'dim(┌ )task 2', + stream: 'stdout', + }, + { + message: 'log task 2', + stream: 'stdout', + }, + { + message: renderer.getLogs()[5].message, + stream: 'stderr', + }, + { + message: renderer.getLogs()[6].message, + stream: 'stderr', + }, + ]) + }) + + test('disable colors', async ({ assert }) => { + const renderer = new MemoryRenderer() + + const manager = new TaskManager({ verbose: true }) + manager.useRenderer(renderer) + manager.useColors(useColors({ silent: true })) + + const logger = new Logger() + logger.useRenderer(renderer) + logger.useColors(useColors({ raw: true })) + + await manager + .add('task 1', async () => { + logger.log('log task 1') + return '' + }) + .add('task 2', async (task) => { + logger.log('log task 2') + return task.error('Something went wrong') + }) + .add('task 3', async () => { + logger.log('log task 3') + return '' + }) + .run() + + assert.deepEqual(renderer.getLogs(), [ + { + message: '┌ task 1', + stream: 'stdout', + }, + { + message: 'log task 1', + stream: 'stdout', + }, + { + message: renderer.getLogs()[2].message, + stream: 'stdout', + }, + { + message: '┌ task 2', + stream: 'stdout', + }, + { + message: 'log task 2', + stream: 'stdout', + }, + { + message: `│ Something went wrong`, + stream: 'stderr', + }, + { + message: renderer.getLogs()[6].message, + stream: 'stderr', + }, + ]) + }) +}) diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts new file mode 100644 index 0000000..55d49cc --- /dev/null +++ b/tests/ui.spec.ts @@ -0,0 +1,75 @@ +/* + * @poppinss/cliui + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { cliui, Instructions, Logger, Table, TaskManager } from '../index.js' + +test.group('UI', () => { + test('instantiate ui primitives', ({ assert }) => { + const ui = cliui() + assert.instanceOf(ui.logger, Logger) + assert.instanceOf(ui.instructions(), Instructions) + assert.instanceOf(ui.sticker(), Instructions) + assert.instanceOf(ui.table(), Table) + assert.instanceOf(ui.tasks(), TaskManager) + }) + + test('disable colors', ({ assert }) => { + const ui = cliui({ mode: 'silent' }) + assert.equal(ui.colors.green('hello world'), 'hello world') + }) + + test('use raw colors', ({ assert }) => { + const ui = cliui({ mode: 'raw' }) + assert.equal(ui.colors.green('hello world'), 'green(hello world)') + }) + + test('use memory renderer when using raw mode', ({ assert }) => { + const ui = cliui({ mode: 'raw' }) + ui.logger.log(ui.colors.green('hello world')) + + assert.deepEqual(ui.logger.getLogs(), [ + { + stream: 'stdout', + message: 'green(hello world)', + }, + ]) + }) + + test('switch to raw mode', ({ assert }) => { + const ui = cliui() + ui.switchMode('raw') + ui.logger.log(ui.colors.green('hello world')) + + assert.deepEqual(ui.logger.getLogs(), [ + { + stream: 'stdout', + message: 'green(hello world)', + }, + ]) + }) + + test('switch to silent mode', ({ assert }) => { + const ui = cliui({ mode: 'raw' }) + ui.switchMode('silent') + + assert.equal(ui.colors.green('hello world'), 'hello world') + ui.logger.log(ui.colors.green('hello world')) + assert.deepEqual(ui.logger.getLogs(), []) + }) + + test('switch to normal mode', ({ assert }) => { + const ui = cliui({ mode: 'raw' }) + ui.switchMode('normal') + + assert.equal(ui.colors.green('hello world'), '\u001b[32mhello world\u001b[39m') + ui.logger.log(ui.colors.green('hello world')) + assert.deepEqual(ui.logger.getLogs(), []) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ff4e273..2039043 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig" -} + "extends": "@adonisjs/tsconfig/tsconfig.package.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./build" + } +} \ No newline at end of file