diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c1699dc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,290 @@ +name: Tag, Release, & Publish + +on: + push: + branches: + - master + +jobs: + build: + name: 'Release' + runs-on: ubuntu-latest + steps: + # Checkout updated source code + - uses: actions/checkout@v2 + name: Checkout Code + + - uses: actions/setup-node@v1 + name: Setup Node.js + with: + node-version: '16' + + - uses: actions/cache@v2 + name: Establish Docker Cache + id: cache + with: + path: docker-cache + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker- + + - name: Load cached Docker layers + run: | + if [ -d "docker-cache" ]; then + cat docker-cache/x* > my-image.tar + docker load < my-image.tar + rm -rf docker-cache + fi + + - name: Setup Build Tooling + id: setup + run: | + base=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-base/tags?page_size=1'|jq '."results"[]["name"]') + base=$(sed -e 's/^"//' -e 's/"$//' <<<"$base") + echo Retrieving author/dev/dev-base:$base + docker pull author/dev-base:$base + + deno=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-deno/tags?page_size=1'|jq '."results"[]["name"]') + deno=$(sed -e 's/^"//' -e 's/"$//' <<<"$deno") + echo Retrieving author/dev/dev-deno:$deno + docker pull author/dev-deno:$deno + + browser=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-browser/tags?page_size=1'|jq '."results"[]["name"]') + browser=$(sed -e 's/^"//' -e 's/"$//' <<<"$browser") + echo Retrieving author/dev/dev-browser:$browser + docker pull author/dev-browser:$browser + + node=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-node/tags?page_size=1'|jq '."results"[]["name"]') + node=$(sed -e 's/^"//' -e 's/"$//' <<<"$node") + echo Retrieving author/dev/dev-node:$node + docker pull author/dev-node:$node + + # node -e "const p=new Set(Object.keys(require('./package.json').peerDependencies));p.delete('@author.io/dev');console.log('npm i ' + Array.from(p).join(' '))" + version=$(npm show @author.io/dev version) + echo $version + npm i -g @author.io/dev@$version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Test + if: success() + run: | + npm i + dev -v + npm run ci + + - name: Tag + id: autotagger + if: success() + uses: butlerlogic/action-autotag@stable + with: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + # If the new version/tag is a pre-release (i.e. 1.0.0-beta.1), create + # an environment variable indicating it is a prerelease. + - name: Pre-release + if: steps.autotagger.outputs.tagname != '' + run: | + if [[ "${{ steps.autotagger.output.version }}" == *"-"* ]]; then echo "::set-env IS_PRERELEASE=true";else echo "::set-env IS_PRERELEASE=''";fi + + - name: Release + id: create_release + if: steps.autotagger.outputs.tagname != '' + uses: actions/create-release@v1.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.autotagger.outputs.tagname }} + release_name: ${{ steps.autotagger.outputs.tagname }} + body: ${{ steps.autotagger.outputs.tagmessage }} + draft: false + prerelease: env.IS_PRERELEASE != '' + + # Build tarballs of the module code. + - name: Build Release Artifacts + id: build_release + if: steps.create_release.outputs.id != '' + run: | + ln -s /node_modules ./node_modules + dev build --pack --mode ci --peer + cp -rf .dist ./dist + + # Upload tarballs to the release. + - name: Upload Release Artifacts + uses: AButler/upload-release-assets@v2.0 + if: steps.create_release.outputs.id != '' + with: + files: '.dist/*.tar.gz' + repo-token: ${{ secrets.GITHUB_TOKEN }} + release-tag: ${{ steps.autotagger.outputs.tagname }} + + - name: Publish to npm + id: publish_npm + if: steps.autotagger.outputs.tagname != '' + uses: author/action-publish@stable + with: + scan: ./dist + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Rollback Release + if: failure() && steps.create_release.outputs.id != '' + uses: author/action-rollback@stable + with: + tag: ${{ steps.autotagger.outputs.tagname }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Failure Notification + if: failure() && steps.create_release.outputs.id != '' + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: Github # Optional. (defaults to webhook app) + SLACK_CHANNEL: author # Optional. (defaults to webhook) + SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" + uses: Ilshidur/action-slack@master + with: + args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} failed to publish and was rolled back.' # Optional + + - name: Success Notification + if: success() && steps.autotagger.outputs.tagname != '' + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: Github # Optional. (defaults to webhook app) + SLACK_CHANNEL: author # Optional. (defaults to webhook) + SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" + uses: Ilshidur/action-slack@master + with: + args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} published to npm.' # Optional + + - name: Inaction Notification + if: steps.autotagger.outputs.tagname == '' + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: Github # Optional. (defaults to webhook app) + SLACK_CHANNEL: author # Optional. (defaults to webhook) + SLACK_AVATAR: "https://cdn.freebiesupply.com/logos/large/2x/nodejs-icon-logo-png-transparent.png" # Optional. can be (repository, sender, an URL) (defaults to webhook app avatar) + uses: Ilshidur/action-slack@master + with: + args: "New code was added to author/shell master branch." # Optional + + + + + + # # If the version has changed, create a new git tag for it. + # - name: Tag + # id: autotagger + # uses: butlerlogic/action-autotag@master + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # # The remaining steps all depend on whether or not + # # a new tag was created. There is no need to release/publish + # # updates until the code base is in a releaseable state. + + # # If the new version/tag is a pre-release (i.e. 1.0.0-beta.1), create + # # an environment variable indicating it is a prerelease. + # - name: Pre-release + # if: steps.autotagger.outputs.tagname != '' + # run: | + # if [[ "${{ steps.autotagger.output.version }}" == *"-"* ]]; then echo "::set-env IS_PRERELEASE=true";else echo "::set-env IS_PRERELEASE=''";fi + + # # Create a github release + # # This will create a snapshot of the module, + # # available in the "Releases" section on Github. + # - name: Release + # id: create_release + # if: steps.autotagger.outputs.tagname != '' + # uses: actions/create-release@v1.0.0 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # tag_name: ${{ steps.autotagger.outputs.tagname }} + # release_name: ${{ steps.autotagger.outputs.tagname }} + # body: ${{ steps.autotagger.outputs.tagmessage }} + # draft: false + # prerelease: env.IS_PRERELEASE != '' + + # - uses: actions/setup-node@v1 + # if: steps.create_release.outputs.id != '' + # with: + # node-version: '13' + + # # Build tarballs of the module code. + # - name: Build Release Artifacts + # id: build_release + # if: steps.create_release.outputs.id != '' + # run: | + # npm install + # cd ./build && npm install && cd ../ + # npm run build --if-present + # for d in .dist/*/*/ ; do tar -cvzf ${d%%/}-${{ steps.autotagger.outputs.version }}.tar.gz ${d%%}*; done; + # if [[ ${{ github.ref }} == *"-"* ]]; then echo ::set-output isprerelease=true;else echo ::set-output isprerelease=false;fi + # # Upload tarballs to the release. + # - name: Upload Release Artifacts + # uses: AButler/upload-release-assets@v2.0 + # if: steps.create_release.outputs.id != '' + # with: + # files: './.dist/**/*.tar.gz' + # repo-token: ${{ secrets.GITHUB_TOKEN }} + # release-tag: ${{ steps.autotagger.outputs.tagname }} + + # # Build npm packages + # - name: Build Module Artifacts + # id: build_npm + # if: steps.create_release.outputs.id != '' + # run: | + # npm install + # cd ./build && npm install && cd ../ + # npm run build --if-present + # # Use this action to publish a single module to npm. + # - name: Publish + # id: publish_npm + # if: steps.autotagger.outputs.tagname != '' + # uses: author/action-publish@master + # with: + # scan: .dist + # env: + # REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + # - name: Rollback Release + # if: failure() && steps.create_release.outputs.id != '' + # uses: author/action-rollback@stable + # with: + # tag: ${{ steps.autotagger.outputs.tagname }} + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # - name: Failure Notification + # if: failure() && steps.create_release.outputs.id != '' + # env: + # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # SLACK_USERNAME: Github # Optional. (defaults to webhook app) + # SLACK_CHANNEL: author # Optional. (defaults to webhook) + # SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" + # uses: Ilshidur/action-slack@master + # with: + # args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} failed to publish and was rolled back.' # Optional + + # - name: Success Notification + # if: success() && steps.autotagger.outputs.tagname != '' + # env: + # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # SLACK_USERNAME: Github # Optional. (defaults to webhook app) + # SLACK_CHANNEL: author # Optional. (defaults to webhook) + # SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" + # uses: Ilshidur/action-slack@master + # with: + # args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} published to npm.' # Optional + + # - name: Inaction Notification + # if: steps.autotagger.outputs.tagname == '' + # env: + # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # SLACK_USERNAME: Github # Optional. (defaults to webhook app) + # SLACK_CHANNEL: author # Optional. (defaults to webhook) + # SLACK_AVATAR: "https://cdn.freebiesupply.com/logos/large/2x/nodejs-icon-logo-png-transparent.png" # Optional. can be (repository, sender, an URL) (defaults to webhook app avatar) + # uses: Ilshidur/action-slack@master + # with: + # args: "New code was added to author/shell master branch." # Optional \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..2c23e4d --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,91 @@ +name: Test Suite + +on: + pull_request: + branches: + - master + +jobs: + build: + name: 'Run Tests' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + name: Checkout Code + + - name: List Directory Contents (for Troubleshooting) + run: | + pwd + ls -l + + - uses: actions/setup-node@v1 + name: Setup Node.js + with: + node-version: '15' + + - uses: actions/cache@v2 + name: Establish npm Cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - uses: actions/cache@v2 + name: Establish Docker Cache + id: cache + with: + path: docker-cache + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker- + + - name: Load cached Docker layers + run: | + if [ -d "docker-cache" ]; then + cat docker-cache/x* > my-image.tar + docker load < my-image.tar + rm -rf docker-cache + fi + + - name: Download Dev Tooling + id: setup + run: | + echo ${{ secrets.GH_DOCKER_TOKEN }} | docker login https://docker.pkg.github.com -u ${{ secrets.GH_DOCKER_USER }} --password-stdin + base=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-base/tags?page_size=1'|jq '."results"[]["name"]') + base=$(sed -e 's/^"//' -e 's/"$//' <<<"$base") + echo Retrieving author/dev/dev-base:$base + docker pull author/dev-base:$base + # docker pull docker.pkg.github.com/author/dev/dev-base:$base + + deno=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-deno/tags?page_size=1'|jq '."results"[]["name"]') + deno=$(sed -e 's/^"//' -e 's/"$//' <<<"$deno") + echo Retrieving author/dev/dev-deno:$deno + # docker pull docker.pkg.github.com/author/dev/dev-deno:$deno + docker pull author/dev-deno:$deno + + browser=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-browser/tags?page_size=1'|jq '."results"[]["name"]') + browser=$(sed -e 's/^"//' -e 's/"$//' <<<"$browser") + echo Retrieving author/dev/dev-browser:$browser + # docker pull docker.pkg.github.com/author/dev/dev-browser:$browser + docker pull author/dev-browser:$browser + + node=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-node/tags?page_size=1'|jq '."results"[]["name"]') + node=$(sed -e 's/^"//' -e 's/"$//' <<<"$node") + echo Retrieving author/dev/dev-node:$node + # docker pull docker.pkg.github.com/author/dev/dev-node:$node + docker pull author/dev-node:$node + + # node -e "const p=new Set(Object.keys(require('./package.json').peerDependencies));p.delete('@author.io/dev');console.log('npm i ' + Array.from(p).join(' '))" + version=$(npm show @author.io/dev version) + echo $version + npm i -g @author.io/dev@$version + dev -v + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Test + if: success() + run: | + dev -v + npm run ci \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 5ac46b5..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Tag, Release, & Publish - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - # Checkout updated source code - - uses: actions/checkout@v2 - - # If the version has changed, create a new git tag for it. - - name: Tag - id: autotagger - uses: butlerlogic/action-autotag@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # The remaining steps all depend on whether or not - # a new tag was created. There is no need to release/publish - # updates until the code base is in a releaseable state. - - # If the new version/tag is a pre-release (i.e. 1.0.0-beta.1), create - # an environment variable indicating it is a prerelease. - - name: Pre-release - if: steps.autotagger.outputs.tagname != '' - run: | - if [[ "${{ steps.autotagger.output.version }}" == *"-"* ]]; then echo "::set-env IS_PRERELEASE=true";else echo "::set-env IS_PRERELEASE=''";fi - - # Create a github release - # This will create a snapshot of the module, - # available in the "Releases" section on Github. - - name: Release - id: create_release - if: steps.autotagger.outputs.tagname != '' - uses: actions/create-release@v1.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.autotagger.outputs.tagname }} - release_name: ${{ steps.autotagger.outputs.tagname }} - body: ${{ steps.autotagger.outputs.tagmessage }} - draft: false - prerelease: env.IS_PRERELEASE != '' - - - uses: actions/setup-node@v1 - if: steps.create_release.outputs.id != '' - with: - node-version: '13' - - # Build tarballs of the module code. - - name: Build Release Artifacts - id: build_release - if: steps.create_release.outputs.id != '' - run: | - npm install - cd ./build && npm install && cd ../ - npm run build --if-present - for d in .dist/*/*/ ; do tar -cvzf ${d%%/}-${{ steps.autotagger.outputs.version }}.tar.gz ${d%%}*; done; - if [[ ${{ github.ref }} == *"-"* ]]; then echo ::set-output isprerelease=true;else echo ::set-output isprerelease=false;fi - - # Upload tarballs to the release. - - name: Upload Release Artifacts - uses: AButler/upload-release-assets@v2.0 - if: steps.create_release.outputs.id != '' - with: - files: './.dist/**/*.tar.gz' - repo-token: ${{ secrets.GITHUB_TOKEN }} - release-tag: ${{ steps.autotagger.outputs.tagname }} - - # Build npm packages - - name: Build Module Artifacts - id: build_npm - if: steps.create_release.outputs.id != '' - run: | - npm install - cd ./build && npm install && cd ../ - npm run build --if-present - - # Use this action to publish a single module to npm. - - name: Publish - id: publish_npm - if: steps.autotagger.outputs.tagname != '' - uses: author/action-publish@master - with: - scan: .dist - env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - - - name: Rollback Release - if: failure() && steps.create_release.outputs.id != '' - uses: author/action-rollback@stable - with: - tag: ${{ steps.autotagger.outputs.tagname }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 178c500..5a478a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.log .* +_* !.github !.gitignore !.npmignore @@ -9,6 +10,7 @@ !.*.yml node_modules test/output/**/* +test/*.txt /**/*.old test/package-lock.json build/package-lock.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2c1e145..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: node_js -node_js: -- '13.5.0' - -install: - - npm run setup - - npm install - -script: - - ls -l - - cd ./test && ls -l && npm run node \ No newline at end of file diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/README.md b/README.md index 91dfa05..e9e8d43 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ -# @author.io/shell ![Version](https://img.shields.io/github/v/tag/author/shell?label=Latest&style=for-the-badge) +# @author.io/shell +![Version](https://img.shields.io/github/v/tag/author/shell?label=Latest&style=for-the-badge) -![Build Status](https://travis-ci.org/author/shell.svg?branch=master) using the [cross-runtime template](https://github.com/author/template-cross-runtime). +This is a super-lightweight framework for building text-based programs, like [CLI](https://en.wikipedia.org/wiki/Command-line_interface) applications. See the [installation guide](#installation) to jumpstart your CLI. -This is a super-lightweight framework for building text-based programs, like [CLI](https://en.wikipedia.org/wiki/Command-line_interface) applications. +--- +This library is now supported by this [Chrome CLI Devtools Extension](https://chrome.google.com/webstore/detail/cli/okpglddgmnblhbdpdcmodmacgcibgfkf): + +![Devtools Extension](https://lh3.googleusercontent.com/WKZpJavmX4RRPyaVBFe6Vn88ZXJbjy9FCP_Mwyxo1JrWY78a9_Rh9c-sy4TawzIKy8xUmnXoxes=w640-h400-e365) + +You can see the library in use (in browsers and Node.js) in this [OpenJS World 2020 talk](https://youtu.be/dw7ABwvFtdM) (The Benefits of a "CLI First" Development Strategy). + +--- ## Uses @@ -21,68 +29,67 @@ There are two types of text-based apps: **tl;dr** Use this library to create multipurpose tools. Use [@author.io/arg](https://github.com/author/arg) to create single purpose tools. -This framework is designed to support multipurpose CLI tools. At the core, it provides a clean, easily-understood, repeatable pattern for building maintainable multipurpose CLI applications. - -Multipurpose tools require a layer of organizational overhead to help isolate different commands and features. This overhead is unnecessary in single purpose tools. Single purpose tools just need argument parsing, which the [@author.io/arg](https://github.com/author/arg) does very well. - -`@author.io/arg` is embedded in this framework, making `@author.io/shell` _capable_ of creating single purpose tools, but it's a bit redundant. - -**Think about how your tooling evolves...** - -Sometimes single purpose tools grow into multipurpose tools over time. Tools which start out using the `@author.io/arg` library be transitioned into multipurpose tools using `@author.io/shell`, with reasonable ease. After all, they use the same code, just nicely separated by purpose. - -## Installation & Usage +
+Detailed Explanation +
-### For Node (ES Modules) +This framework was designed to support multipurpose CLI tools. At the core, it provides a clean, easily-understood, repeatable pattern for building maintainable multipurpose CLI applications. -`npm install @author.io/node-shell` +Multipurpose tools require a layer of organizational overhead to help isolate different commands and features. This overhead is unnecessary in single purpose tools. Single purpose tools just need argument parsing, which the [@author.io/arg](https://github.com/author/arg) does very well. -Please note, you'll need a verison of Node that support ESM Modules. In Node 12, this feature is behind the `--experimental-modules` flag. It is available in Node 13+ without a flag, but your `package.json` file must have the `"type": "module"` attribute. +`@author.io/arg` is embedded in this framework, making `@author.io/shell` _capable_ of creating single purpose tools, but it's merely unnecessary overhead for single purpose commands. +
+
-### For Node (CommonJS/require) - -If you need to use the older CommonJS format (i.e. `require`), run `npm install @author.io/node-shell-legacy` instead. - -### For Browsers - -**CDN** - -```javascript -import { Shell, Command } from 'https://cdn.pika.dev/@author.io/browser-shell/v1' -``` +**Think about how your tooling evolves...** -Also available from [jsdelivr](https://www.jsdelivr.com/?query=%40author.io%2Fshell) and [unpkg](https://unpkg.com/@author.io/browser-shell). +Sometimes single purpose tools grow into multipurpose tools over time. Tools which start out using the `@author.io/arg` library can be transitioned into multipurpose tools using `@author.io/shell` (with reasonable ease). After all, they use the same code, just nicely separated by purpose. -**npm options** +## Differentiating Features -If you wish to bundle this library in your build process, use the version most appropriate for your target runtimes: +1. Supports **middleware** (express-style). +1. Supports **postware** (middleware that runs after a command). +1. **Customizable **help/usage** screens. +1. Produces **introspectable** JSON. Load a JSON config, have a working CLI. +1. Reusable **plugin** system. +1. Dynamically add/remove commands. +1. Track command execution **history**. +1. Define **universal flags** once, reuse in all commands. -- `npm install @author/shell` (source) -- `npm install @author/browser-shell` (Minified ES Module) -- `npm install @author/browser-shell-es6` (IIFE Minified Module - globally accessible) +
+Also has better source & distribution code -### Debugging +1. Cross-runtime (browser, node, deno) +1. Separation of Concerns: Arg parsing and text formatting are separate microlibs. +1. Modern ES Module syntax +1. 40+ unit tests -Each distribution has a corresponding `-debug` version that should be installed _alongside_ the main module (the debugging is an add-on module). For example, `npm install @author.io/node-shell-debug --save-dev` would install the debugging code for Node. +
## Basic Examples +See the [Installation Guide](#installation) when you're ready to get started. + There is a complete working example of a CLI app (with a mini tutorial) in the examples directory. -_This example imports the library for Node. Simply swap the Node import for the appropriate browser import if you're building a web utility. Everything else is the same for both Node and browser environments._ +_This example imports the library for Node. Simply swap the Node import for the appropriate browser import if you're building a web utility. Everything else is the same for both Node and browser environments._ ```javascript -import { Shell, Command } from '@author.io/node-shell' +import { Shell, Command } from '@author.io/shell' // Define a command const ListCommand = new Command({ name: 'list', description: 'List the contents of the directory.', + disableHelp: false, // Set to true to turn off default help messages for the entire shell (you can still provide your own). Defaults to false. + // arguments are listed after the command in the default help screen. Ex: "dir list path" + arguments: 'path', // Can be space/comma/tab/semicolon delimited or an array. alias: 'ls', // Any flag parsing options from the @author.io/arg library can be configured here. // See https://github.com/author/arg#configuration-methods for a list. flags: { - l: { + long: { + alias: 'l', description: 'Long format'. type: 'boolean', default: false @@ -90,18 +97,19 @@ const ListCommand = new Command({ rootDir: { description: 'The root directory to list.', aliases: ['input', 'in', 'src'], - single: true + single: true, + // validate: RegExp/Function (see github.com/author/arg) } }, handler (metadata, callback) { - // ... this is where your command actually runs ... - + // ... this is where your command actually does something ... + // Data comes from @author.io/arg lib. It looks like: // { // command: , // input: 'whatever user typed after "command"', // flags: { - // recognized: {}, + // recognized: {}, // unrecognized: [ // 'whatever', // 'user', @@ -110,16 +118,20 @@ const ListCommand = new Command({ // }, // valid: false, // violations: [], - // flag (name) { return String } + // flag (name) { return String }, + // data (getter) // } console.log(metadata) // A single flag's value can be retrieved with this helper method. - console.log(metadata.flag('l)) + console.log(metadata.flag('long')) + + // Any unrecognized flags can be retrieved by index number (0-based) + console.log(metadata.flag(0)) // The first unrecognized flag... returns null if it doesn't exist - // Execution callbacks are optional. If a callback is passed from the - // execution context to this handler, it will run after the command - // has finished processing + // Execution callbacks are optional. If a callback is passed from the + // execution context to this handler, it will run after the command + // has finished processing // (kind of like "next" in Express). // Promises are also supported. callback && callback() @@ -130,10 +142,19 @@ const shell = new Shell({ name: 'myapp', version: '1.0.0', description: 'My demo app.', + // This middleware runs before all command handlers. + use: [ + (meta, next) => { ...; next() } + ], + // Trailers are like "post-middleware" that run after command handlers. + trailer: [ + (meta, next) => { ...; next() } + (meta, next) => { console.log('All done!') } + ], commands: [ // These can be instances of Command... list, - + // or just the configuration of a Command { name: 'find', @@ -184,7 +205,7 @@ Each command has a handler function, which is responsible for doing something. T command: , input: 'Raw string of flags/arguments passed to the command', flags: { - recognized: {}, + recognized: {}, unrecognized: [ 'whatever', 'user', @@ -203,8 +224,13 @@ Each command has a handler function, which is responsible for doing something. T - **`flag()`** is a special method for retrieving the value of any flag (recognized or unrecognized). See below. - **valid** indicates whether the input conforms to the parsing rules. - **violations** is an array of strings, where each string represents a violation of the parsing rules. +- **data** _(getter)_ returns a key/value object with all of the known flags, as well as an _attempt_ to map any unrecognized flags with known argument names. (See basic example for argument example) + +
+Understanding flag() +
-_The `flag()` method_ is a shortcut to help developers create more maintainable and understandable code. Consider the following example that does **not** use the flag method: +The `flag()` method is a shortcut to help developers create more maintainable and understandable code. Consider the following example that does **not** use the flag method: ```javascript const cmd = new Command({ @@ -236,7 +262,309 @@ const cmd = new Command({ }) ``` -While the differences aren't extreme, it abstracts the need to know whether a flag is recognized or not (or even exists). +While the differences aren't extreme, it abstracts the need to know whether a flag is recognized or not (or even exists). If a `flag()` is executed for a non-existant flag, it will return `null`. +
+ +
+Understanding data +The `data` attribute supplied to handlers in the metadata argument contains the values for known flags, and _**attempts to map unknown arguments** to configured argument names_. + +For example, + +```javascript +const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email displayName', + handler (meta) { + console.log(meta.data) + } + }] +}) + +shell.exec('create me@domain.com "John Doe" test1 test2') +``` + +_Output:_ + +```json +{ + "email": "me@domain.com", + "displayName": "John Doe", + "unknown1": "test1", + "unknown2": "test2 +} +``` + +If there is a name conflict, the output will contain an array of values. For example: + +```javascript +const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email displayName', + flags: { + email: { + alias: 'e' + } + }, + handler (meta) { + console.log(meta.data) + } + }] +}) + +shell.exec('create me@domain.com -e bob@other.com') +``` + +_Output:_ + +```json +{ + "email": ["bob@other.com", "me@domain.com"], + "displayName": "John Doe", +} +``` + +> Notice the values from the known flags are _first_. +
+ +## Plugins + +Plugins expose functions, objects, and primitives to shell handlers. + +_Example:_ + +Consider an example where information is retrieved from a remote API. To do this, an HTTP request library may be necessary to make the request and parse the results. In this example, the axios library is defined as a plugin. The plugin is accessible in the metadata passed to each handler, as shown below. + +
+ Why would you do this? +

+ Remember, the shell library can produce JSON (See the Introspection/Metadata Generation section). JSON is a string format for storing data. The output will contain a stringified version of all the handler functions. This can be used as the configuration for another instance of a shell. In other words, you can maintain a runtime-agnostic configuration. You could use _mostly_ the same configuration for the browser, Node, Deno, Vert.x, or another JavaScript runtime. However; the modules/packages like the HTTP request module may or may not work in each runtime. +

+

+ Plugins allow developers to write handlers that are completely "self contained". It is then possible to modify the plugin configuration for each runtime without modifying every handler in the shell. +

+
+
+ +```javascript +import axios from 'axios' + +const sh = new Shell({ + name: 'info', + plugins: { + httprequest: axios // replace this with any compatible library + }, + commands: [{ + name: 'person', + flags: { + name: { + description: 'Name of the person you want info about.', + required: true + } + }, + handler (meta) { + meta.plugins.httprequest({ + method: 'get', + url: `http://api.com/person/${meta.flag('name')}` + }).then(console.log).catch(console.error) + } + }, { + name: 'group', + flags: { + name: { + description: 'Name of the group you want info about.', + required: true + } + }, + handler (meta) { + meta.plugins.httprequest({ + method: 'get', + url: `http://api.com/group/${meta.flag('name')}` + }).then(console.log).catch(console.error) + } + }] +}) +``` + +Commands will inherit plugins from the shell and any parent commands. It is possible to "override" a plugin in any specific command. + +
+Override Example + +```javascript +const sh = new Shell({ + name: 'test', + plugins: { + test: value => { + return value + 1 + } + }, + commands: [{ + name: 'cmd', + plugins: { + test: value => { + return value + 10 + } + }, + handler(meta) { + console.log(meta.test(1)) // Outputs 11 + } + }] +}) +``` + +
+ +## Universal Flags +_Common flags are automatically applied to multiple commands._ + +Sometimes a CLI app has multiple commands/subcommands that need the same flag associated with each command/subcommand. For example, if a `--note` flag were needed on every command, it would be a pain to copy/paste the config into every single command. Common flags resolve this by automatically applying to all commands from the point where the common flag is configured (i.e. the point where inheritance/nesting begins). + +
+Apply a common flag to ALL commands +To include the same flag on all commands, add a common flag to the shell. + +```javascript +const shell = new Shell({ + name: 'mycli', + commmonflags: { + note: { + alias: 'n', + description: 'Save a note about the operation.' + } + }, + commands: [{ + name: 'create', + flag: { + writable: { + alias: 'w', + description: 'Make it writable.' + } + }, + ... + }, { + name: 'read', + ... + }] +}) + +shell.exec('create --help') +shell.exec('read --help') +``` + +create output: +```sh +mycli create + +Flags: + note [-n] Save a note about the operation. + writable [-w] Make it writable. +``` + +read` output: +```sh +mycli read + +Flags: + note [-n] Save a note about the operation. +``` +
+ +
+Apply a common flag to a specific command/subcommands + +```javascript +const shell = new Shell({ + name: 'mycli', + commands: [{ + name: 'create', + commmonflags: { + note: { + alias: 'n', + description: 'Save a note about the operation.' + } + }, + flag: { + writable: { + alias: 'w', + description: 'Make it writable.' + } + }, + commands: [...] + ... + }, { + name: 'read', + description: 'Read a directory.', + ... + }] +}) + +shell.exec('create --help') +shell.exec('read --help') +``` + +_`create` output:_ +```sh +mycli create + +Flags: + note [-n] Save a note about the operation. + writable [-w] Make it writable. +``` + +_`read` output:_ +```sh +mycli read + + Read a directory. + +``` +
+ +#### Filtering Universal Flags + +Universal/common flags accept a special attribute named `ignore`, which will prevent the flags from being applied to specific commands. This should be used sparingly. + +
+Cherry-picking example +
+ +```javascript +const shell = new Shell({ + name: 'mycli', + commmonflags: { + ignore: 'info', // This can also be an array of string. Fully qualified subcommands will also be respected. + note: { + alias: 'n', + description: 'Save a note about the operation.' + } + }, + commands: [{ + name: 'create', + handler () {} + }, { + name: 'read', + handler () {} + }, { + name: 'update', + handler () {} + }, { + name: 'delete', + handler () {} + }, { + name: 'info', + handler () {} + }] +}) +``` + +Any command, except `info`, will accepts/parse the `note` flag. + +
## Middleware @@ -274,7 +602,9 @@ shell.useWith('demo', function (metadata, next) { }) ``` -The code above would only run when the user inputs the `demo` command (or and `demo` subcommand). +The code above would only run when the user inputs the `demo` command (or any `demo` subcommand). + +#### Command-Specific Assignments It is possible to assign middleware to more than one command at a time, and it is possible to target subcommands. For example: @@ -290,8 +620,353 @@ shell.useWith(['demo', 'command subcommand'], function (metadata, next) { Notice the array as the first argument of the `useWith` method. This middleware would be assigned to `demo` command, all `demo` subcommands, the `subcommand` of `command`, and all subcommands of `subcommand`. If this sounds confusing, just know that middleware is applied to commands, including nested commands. -### Other Middleware +Assigned middleware can also be applied directly to a `Command` class. For example, + +```javascript +const cmd = new Command({ + name: 'demo', + flags: { + a: { type: String }, + b: { type: String } + }, + handler: metadata => { + console.log(metadata) + } +}) + +cmd.use(function (metadata, next) { + console.log(`this middleware is specific to the "${cmd.name}" command`) + next() +}) +``` + +#### Command-Exclusion Assignments + +Sometimes middleware needs to be applied to all but a few commands. The `useExcept` method supports these needs. It is basically the opposite of `useWith`. Middleware is applied to all commands/subcommands _except_ those specified. + +For example: + +```javascript +const shell = new Shell({ + ..., + commands: [{ + name: 'add', + handler (meta) { + ... + } + }, { + name: 'subtract', + handler (meta) { + ... + } + }, { + name: 'info', + handler (meta) { + ... + } + }] +}) + +shell.useExcept(['info], function (meta, next) { + console.log(`this middleware is only applied to some math commands`) + next() +}) +``` + +In this example, the console statement would be displayed for all commands except the `info` command (and any info subcommands). + +### Built-in "Middleware" + +Displaying help and version information is built-in (overridable). + +**Help** + +Appending `--help` to anything will display the help content for the shell/command/subcommand. This will respect any custom usage/help configurations that may be defined. + +**Shell Version** + +A `version` command is available on the shell. For example: -One development goal of this framework is to remain as lightweight and unopinionated as possible. Another is to be as simple to use as possible. These two goals often conflict with each other (the more features you add, the heavier it becomes). In an attempt to find a comfortable balance, some additional middleware libraries are available fo those who want a little extra functionality. +```sh +$ cmd version +1.0.0 +``` + +The following common flag variations map to the version command, producing the same output: + +```sh +$ cmd --version +1.0.0 + +$ cmd -v +1.0.0 +``` + +This can be overridden by creating a command called `version`, the same way any other command is created. + +```javascript +const v = new Command({ + name: 'version', + handler (meta) { + console.log(this.shell.version) + } +}) + +shell.add(v) +``` + +### Middleware Libraries + +One development goal of this framework is to remain as lightweight and unopinionated as possible. Another is to be as simple to use as possible. These two goals often conflict with each other (the more features you add, the heavier it becomes). In an attempt to find a comfortable balance, some additional middleware libraries are available for those who want a little extra functionality. 1. [@author.io/shell-middleware](https://github.com/author/shell-middleware) +1. Submit a PR to add yours here. + +### Trailers +_(Postware/Afterware)_ + +Trailers operate just like middleware, but they execute _after_ the command handler is executed. + +```javascript +const shell = new Shell({ + name: 'mycli', + trailer: [ + function () { console.log('Done!' ) } + ], + command: [{ + name: 'dir', + handler () { + console.log('ls -l') + }, + // Subcommands + commands: [{ + name: 'perm', + description: 'Permissions', + handler () { + console.log('Display permissions for a directory.') + } + }] + }] +}) + +// Execute the "dir" command +shell.exec('dir') + +// Execute the "perm" subcommand +shell.exec('dir perm') +``` + +_`dir` command output:_ +```sh +ls -l +Done! +``` + +_`dir perm` subcommand output:_ +```sh +Display permissions for a directory. +Done! +``` + +### Customized Help/Usage Messages + +**Customizing Flag Appearance:** + +The `Shell` and `Command` classes can both accept several boolean attributes to customize the description of each flag within a command. Each of these is `true` by default. + +1. `describeDefault`: Display the default flag value. +1. `describeOptions`: List the valid options for a flag. +1. `describeMultipleValues`: Appends `Can be used multiple times.` to the flag description +1. `describeRequired`: Prepends `Required.` to the flag description whenever a flag is required. + +
+Example +
+ +```javascript +const c = new Command({ + name: '...', + flags: { + name: { + alias: 'nm', + required: true, + default: 'Rad Dev', + allowMultipleValues: true, + options: ['Mr Awesome', 'Mrs Awesome', 'Rad Dev'], + description: 'Specify a name.' + } + } +}) +``` + +The help message for this flag would look like: + +```sh +Flags: + -name ['nm'] Required. Specify a name. Options: Mr + Awesome, Mrs Awesome, Rad Dev. Can be + used multiple times. (Default Rad Dev) +``` +
+
+ +**Customizing the Entire Message:** + +This library uses a vanilla dependency (i.e. no-subdependencies) called [@author.io/table](https://github.com/author/table) to format the usage and help messages of the shell. The `Table` library can be used to create your own custom screens, though most users will likely want to stick with the defaults. If you want to customize messages, the following example can be used as a starting point. The configuration options for the table can be found in the README of its repository. + +```javascript +import { Shell, Command, Table } from '@author.io/shell' + +const shell = new Shell(...) +shell.usage = '...' +shell.help = () => { + const rows = [ + ['Command', 'Alias Names'], + ['...', '...'] + ] + + const table = new Table(rows) + + return shell.usage + '\n' + table.output +} +``` + +**The `usage` and/or `help` attributes of an individual `Command` can also be set:** + +```javascript +import { Shell, Command, Table } from '@author.io/shell' + +const cmd = new Command(...) +cmd.usage = '...' +cmd.help = () => { + const rows = [ + ['Flags', 'Alias Names'], + ['...', '...'] + ] + + const table = new Table(rows) + + return cmd.usage + '\n' + table.output +} +``` + +There is also a `Formatter` class that helps combine usage/help messages internally. This class is exposed for those who want to dig into the inner workings, but it should be considered as more of an example than a supported feature. Since it is an internal class, it may change without warning (though we'll try to keep the methods consistent across releases). + +### Introspection/Metadata Generation + +A JSON metadoc can be produced from the shell: + +```javascript +console.log(shell.data) +``` + +Simple CLI utilities can also be loaded entirely from a JSON file by passing the object into the shell constructor as the only argument. The limitation is no imports or hoisted variables/methods will be recognized in a shell which is loaded this way. + +### Autocompletion/Input Hints + +This library can use a _command hinting_ feature, i.e. a shell `hint()` method to return suggestions/hints about a partial command. This feature was part of the library through the `v1.5.x` release lifecycle. In `v.1.6.0+`, this feature is no longer a part of the core library. It is now available as the [author/shell-hints plugin](https://github.com/author/shell-hints). + +Consider the following shell: + +```javascript +import HintPlugin from 'https://cdn.pika.dev/@author.io/browser-shell-hints' + +const shell = new Shell({ + name: 'mycli', + command: [{ + name: 'dir', + handler () { + console.log('ls -l') + }, + // Subcommands + commands: [{ + name: 'perm', + description: 'Permissions', + handler () { + console.log('Display permissions for a directory.') + } + }, { + name: 'payload', + description: 'Payload', + handler () { + console.log('Display payload/footprint for a directory.') + } + }] + }] +}) + +HintPlugin.apply(shell) // <-- Adds the hint method. + +// Help us figure out what we can do! +console.log(shell.hint('dir p')) +``` + +_Output:_ + +```sh +{ + commands: ['perm', 'payload'], + flags: [] +} +``` + +The hint matches "**dir p**ermission" and "**dir p**ayload", but does not match any flags. + +If no options/hints are available, `null` is returned. + +While this add-on provides input hints that could be used for suggestions/completions, it does **not** generate autocompletion files for shells like bash, zsh, fish, powershell, etc. + +> There are many variations of autocompletion for different shells, which are not available in browsers (see our Devtools extension for browser completion). + +If you wish to generate your own autocompletion capabilities, use the `shell.data` attribute to retrieve data for the shell (see prior section). For terminals, consider using the shell metadata with a module like [omlette](https://github.com/f/omelette) to produce autocompletion for your favorite terminal app. For browser-based CLI apps, consider using our devtools extension for an autocompletion experience. + +## Installation + +### Node.js + +
+Modern (ES Modules) +
+ +```sh +npm install @author.io/shell --save +``` + +Please note, you'll need a verison of Node that supports ES Modules. In Node 12, this feature is behind the `--experimental-modules` flag. It is available in Node 13+ without a flag, but the `package.json` file must have the `"type": "module"` attribute. This feature is generally available in [Node 14.0.0](https://nodejs.org) and above. +
+ +
+Legacy (CommonJS/require) +
+ +DEPRECATED + +If you need to use the older CommonJS format (i.e. `require`), run `npm install @author.io/shell-legacy` instead. +
+ +### Browsers + +**CDN** + +```javascript +import { Shell, Command } from 'https://cdn.pika.dev/@author.io/shell' +``` + +Also available from [jsdelivr](https://www.jsdelivr.com/package/npm/@author.io/shell/index.js) and [unpkg](https://unpkg.com/@author.io/shell/index.js). + +### Debugging + +Each distribution has a corresponding `-debug` version that should be installed _alongside_ the main module (the debugging is an add-on module). For example, `npm install @author.io/shell-debug --save-dev` would install the debugging code for Node. In the browser, appending the debug library adds sourcemaps. + +### Related Modules + +1. [@author.io/table](https://github.com/author/table) - Used to generate the default usage/help messages for the shell and subcommands. + +**Sponsors (as of 2020)** + + + + + + +
diff --git a/build/lib/browser-package.js b/build/lib/browser-package.js index eb27a8a..156cdfc 100644 --- a/build/lib/browser-package.js +++ b/build/lib/browser-package.js @@ -16,7 +16,7 @@ build.supportedBrowsers().forEach(edition => { const prefix = `${config.npmOrganization}${config.npmOrganization.trim().length > 0 ? '/' : ''}` outdir = `${config.browserOutput}/${edition}` - const srcfile = `${config.browserOutput}/browser-${build.name}/${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js` + const srcfile = `${config.browserOutput}/browser-${build.name}/${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js` console.log(`Creating production npm package for ${edition}`) const pkg = Object.assign({}, mainpkg) @@ -24,8 +24,9 @@ build.supportedBrowsers().forEach(edition => { pkg.description = `${mainpkg.description} (Production code for ${originalEditionName} browsers)` pkg.devDependencies = {} pkg.devDependencies[`${prefix}${edition}-debug`] = mainpkg.version - pkg.module = `./${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js` - pkg.main = `./${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js` + pkg.files.indexOf('*.js.map') < 0 && pkg.files.push('*.js.map') + pkg.module = `./${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js` + pkg.main = `./${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js` if (!fs.existsSync(outdir)) { fs.mkdirSync(outdir) @@ -43,10 +44,10 @@ build.supportedBrowsers().forEach(edition => { .toString() .replace(/\/+#\s+?sourceMappingURL=/, `//# sourceMappingURL=../${edition}-debug/`) fs.writeFileSync(globalFile, content) - fs.renameSync(srcfile.replace('.min.js', '-global.min.js'), `${outdir}/${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}-global.min.js`) + fs.renameSync(srcfile.replace('.min.js', '-global.min.js'), `${outdir}/${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}-global.min.js`) } - fs.renameSync(srcfile, `${outdir}/${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js`) + fs.renameSync(srcfile, `${outdir}/${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js`) fs.writeFileSync(`${outdir}/package.json`, JSON.stringify(pkg, null, 2)) // Add license @@ -57,7 +58,7 @@ build.supportedBrowsers().forEach(edition => { console.log(`Creating debug npm package for ${edition}.`) fs.mkdirSync(`${outdir}-debug`) - fs.renameSync(srcfile.replace('.min.js', '.min.js.map'), `${outdir}-debug/${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js.map`) + fs.renameSync(srcfile.replace('.min.js', '.min.js.map'), `${outdir}-debug/${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js.map`) if (originalEditionName === 'current') { fs.renameSync(srcfile.replace('.min.js', '-global.min.js.map'), `${outdir}-debug/${build.name}-${build.version}-global.min.js.map`) } @@ -66,8 +67,9 @@ build.supportedBrowsers().forEach(edition => { delete pkg.devDependencies pkg.name = `${pkg.name}-debug` pkg.description = `${mainpkg.description} (Sourcemaps for ${originalEditionName} browsers)` - pkg.module = `./${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js.map` - pkg.main = `./${build.name}-${build.version}${edition.replace('browser-'+build.name, '')}.min.js.map` + pkg.module = `./${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js.map` + pkg.main = `./${build.name}-${build.version}${edition.replace('browser-' + build.name, '')}.min.js.map` + pkg.files.indexOf('*.js.map') < 0 && pkg.files.push('*.js.map') pkg.dependencies = {} pkg.dependencies['source-map-support'] = mainpkg['source-map-support'] fs.writeFileSync(`${outdir}-debug/package.json`, JSON.stringify(pkg, null, 2)) diff --git a/build/lib/build.js b/build/lib/build.js index f238c23..eea4a26 100644 --- a/build/lib/build.js +++ b/build/lib/build.js @@ -1,7 +1,8 @@ import fs from 'fs' import path from 'path' +import chalk from 'chalk' // Included in Rollup import stripCode from 'rollup-plugin-strip-code' -import replace from 'rollup-plugin-replace' +import replace from '@rollup/plugin-replace' const pkg = JSON.parse(fs.readFileSync('../package.json')) @@ -154,4 +155,26 @@ export default class build { return false } + + ignoreCircularDependency () { + const refs = new Set([...arguments]) + + return warning => { + console.log(chalk.red(warning.importer)) + if ( + warning.code === 'CIRCULAR_DEPENDENCY' && + refs.size > 0 && + Array.from(refs).filter(p => { + try { + return !warning.importer.indexOf(path.normalize(p)) + } catch (e) { return false } + }).length > 0 + ) { + return + } + + console.log(chalk.yellow.bold(`(!) ${warning.code}`)) + console.log(chalk.grey(warning.message)) + } + } } diff --git a/build/lib/reset.js b/build/lib/reset.js index cfac5c0..1d2eada 100644 --- a/build/lib/reset.js +++ b/build/lib/reset.js @@ -5,8 +5,8 @@ fs .readdirSync(path.resolve('./')) .forEach(filepath => { const fullpath = path.resolve(filepath) - if (filepath.startsWith('.') && filepath !== '.git' && fs.statSync(fullpath).isDirectory()) { + if (filepath.startsWith('.') && filepath !== '.github' && filepath !== '.git' && fs.statSync(fullpath).isDirectory()) { fs.rmdirSync(filepath, { recursive: true }) console.log(`Removed ${fullpath}`) } - }) \ No newline at end of file + }) diff --git a/build/package.json b/build/package.json index df005fb..cc7da48 100644 --- a/build/package.json +++ b/build/package.json @@ -29,10 +29,10 @@ "@babel/plugin-transform-flow-strip-types": "^7.7.4", "@babel/preset-env": "^7.7.7", "@rollup/plugin-multi-entry": "^3.0.0", + "@rollup/plugin-replace": "^2.3.1", "browserslist": "^4.8.2", "rollup": "^1.27.13", "rollup-plugin-babel": "^4.3.3", - "rollup-plugin-replace": "^2.2.0", "rollup-plugin-strip-code": "^0.2.7", "rollup-plugin-terser": "^5.1.3", "shortbus": "1.2.8", diff --git a/build/rollup.browser.config.js b/build/rollup.browser.config.js index 24881b4..e89c50a 100644 --- a/build/rollup.browser.config.js +++ b/build/rollup.browser.config.js @@ -33,6 +33,7 @@ const globalplugins = [ ] // 2. Build Browser Production Package: Standard (Minified/Munged) +const onwarn = build.ignoreCircularDependency('../src/command.js', '../src/shell.js', '../src/format.js', '../src/base.js') outdir += `/browser-${build.name}` build.supportedBrowsers().forEach(edition => { console.log(`Generating ${edition} browser code.`) @@ -56,6 +57,7 @@ build.supportedBrowsers().forEach(edition => { configuration.push({ input, plugins, + onwarn, output: { banner: config.banner, file: `${outdir}/${build.name}-${build.version}${edition !== 'current' ? '-' + edition : ''}.min.js`, @@ -73,6 +75,7 @@ build.supportedBrowsers().forEach(edition => { configuration.push({ input, plugins, + onwarn, output: { banner: config.banner, file: `${outdir}/${build.name}-${build.version}-global.min.js`, diff --git a/build/rollup.node.config.js b/build/rollup.node.config.js index 04990c3..c1460cd 100644 --- a/build/rollup.node.config.js +++ b/build/rollup.node.config.js @@ -13,7 +13,7 @@ install() const build = new Build() // Identify source file -const input = path.resolve(`../${build.pkg.main||'../src/index.js'}`) +const input = path.resolve(`../${build.pkg.main || '../src/index.js'}`) // Configure metadata for the build process. const rootdir = config.nodeOutput // Main output directory @@ -26,6 +26,7 @@ fs.rmdirSync(rootdir, { recursive: true }) let terserCfg = config.terser terserCfg.module = true // terserCfg.mangle = { properties: true } +// console.log(terserCfg) // Identify plugins const plugins = [ @@ -35,18 +36,19 @@ const plugins = [ presets: [['@babel/preset-env', { targets: { node: true } }]], plugins: [ ['@babel/plugin-proposal-class-properties', { loose: false }], - // ['@babel/plugin-proposal-private-methods', { loose: false }] - ], - externalHelpersWhitelist: ['classPrivateFieldSet', 'classPrivateFieldGet', 'classPrivateMethods'] + ['@babel/plugin-proposal-private-methods', { loose: false }] + ] }), terser(terserCfg) ] // 2. Build Node Production Package: Standard (Minified/Munged) +const onwarn = build.ignoreCircularDependency('../src/command.js', '../src/shell.js', '../src/format.js', '../src/base.js') outdir += `/node-${build.name}` configuration.push({ input, plugins, + onwarn, output: { banner: config.banner, file: `${outdir}/${build.name}-${build.version}.min.js`, @@ -60,6 +62,7 @@ configuration.push({ configuration.push({ input, plugins, + onwarn, output: { banner: config.banner, file: `${outdir}-legacy/${build.name}-${build.version}.min.js`, diff --git a/build/rollup.test.browser.config.js b/build/rollup.test.browser.config.js index 1e7b7b2..5d0e73f 100644 --- a/build/rollup.test.browser.config.js +++ b/build/rollup.test.browser.config.js @@ -34,6 +34,7 @@ const globalplugins = [ ] // 2. Build Browser Production Package: Standard (Minified/Munged) +const onwarn = build.ignoreCircularDependency('../src/command.js', '../src/shell.js', '../src/format.js', '../src/base.js') build.supportedBrowsers().forEach(edition => { console.log(`Generating ${edition} browser code.`) const plugins = globalplugins.slice() @@ -51,13 +52,15 @@ build.supportedBrowsers().forEach(edition => { let terserCfg = config.terser terserCfg.module = edition === 'current' terserCfg.compress.module = edition === 'current' - + plugins.push(terser(terserCfg)) configuration.push({ input, plugins, + onwarn, output: { + exports: 'named', banner: config.banner, file: `${outdir}/${build.name}-${build.version}${edition !== 'current' ? '-' + edition : ''}.min.js`, format: edition === 'current' ? 'esm' : 'iife', @@ -74,7 +77,9 @@ build.supportedBrowsers().forEach(edition => { configuration.push({ input, plugins, + onwarn, output: { + exports: 'named', banner: config.banner, file: `${outdir}/${build.name}-${build.version}-global.min.js`, format: 'iife', diff --git a/build/rollup.test.node.config.js b/build/rollup.test.node.config.js index b8c85c2..2136e3f 100644 --- a/build/rollup.test.node.config.js +++ b/build/rollup.test.node.config.js @@ -43,10 +43,13 @@ const plugins = [ ] // 2. Build Node Production Package: Standard (Minified/Munged) +const onwarn = build.ignoreCircularDependency('../src/command.js', '../src/shell.js', '../src/format.js', '../src/base.js') configuration.push({ input, plugins, + onwarn, output: { + exports: 'named', banner: config.banner, file: output, format: 'esm', diff --git a/examples/cli/index.js b/examples/cli/index.js index 1eaa185..6e6b282 100755 --- a/examples/cli/index.js +++ b/examples/cli/index.js @@ -1,10 +1,12 @@ -#!/usr/bin/env node --experimental-modules +#!/usr/bin/env node -r source-map-support/register import fs from 'fs' import path from 'path' -import { Command, Shell } from '../../src/index.js' +import { Command, Shell, Formatter } from '../../src/index.js' +import { fileURLToPath } from 'url' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const pkg = JSON.parse(fs.readFileSync('./package.json')) +const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, './package.json'))) const shell = new Shell({ name: Object.keys(pkg.bin)[0], @@ -109,6 +111,32 @@ shell.add(new Command({ } })) + +shell.add(new Command({ + name: 'doc', + description: 'Output the metadoc of this shell.', + flags: { + file: { + alias: 'f', + type: String + } + }, + handler (meta) { + let data = this.shell.data + + if (meta.flag('file') !== null) { + fs.writeFileSync(path.resolve(meta.flag('file')), JSON.stringify(data, null, 2)) + } else { + console.log(data) + } + } +})) + +shell.use((data, next) => { + console.log('This middleware runs on every command.') + next() +}) + const cmd = process.argv.slice(2).join(' ').trim() // console.log(cmd) -shell.exec(cmd).catch(e => console.log(e.message || e)) +shell.exec(cmd).catch(e => console.log(e.message || e)) \ No newline at end of file diff --git a/examples/cli/package.json b/examples/cli/package.json index cebea26..1d5ec67 100644 --- a/examples/cli/package.json +++ b/examples/cli/package.json @@ -16,5 +16,8 @@ "engines": { "node": "^12.0.0" }, - "dependencies": {} + "dependencies": {}, + "devDependencies": { + "@author.io/node-shell-debug": "^1.5.0" + } } diff --git a/examples/json/README.md b/examples/json/README.md new file mode 100644 index 0000000..54b2ad5 --- /dev/null +++ b/examples/json/README.md @@ -0,0 +1,196 @@ +# CLI example + +To try this out: + +1. Clone the repo +1. Navigate to the `examples/cli` directory in your terminal/console. +1. Run `npm link`. This will make the command available globally. + +> Notice that there are no dependencies in the package.json file for this example app. That's because this example is part of the module. In all other apps, you would need to run `npm install @author.io/shell -S` to make it work. + +Next, start using it. + +> The name of the shell is defined in the package.json file (`bin` attribute). + +```sh +examples/cli> dir +``` + +You should see the following output: + +```sh +dir 1.0.0 +A simple file system utility. + + - list [ls] : List files of a directory. Optionally specify a + long format. + - export : Export a file representation of a directory. + Has an additional subcommand. + - mkdir [md, m] : Make a new directory. +``` + +### Try the list Command + +```sh +examples/cli> dir list +``` +_Output:_ +```sh +README.md, index.js, package-lock.json, package.json +``` + +#### Same command, as an alias & a flag: + +```sh +examples/cli> dir ls -l +``` +_`-l` is the "long format" flag defined in the list command (see index.js)._ + +_Output:_ +```sh +- README.md +- index.js +- package-lock.json +- package.json +``` + +### Explore Another Command + +```sh +dir md -R /path/to/dir +``` + +```sh +...make a directory... +{ + command: 'mkdir', + input: '-R /path/to/dir', + flags: { + recognized: { p: true }, + unrecognized: [ '/path/to/dir' ] + }, + valid: true, + violations: [], + help: { requested: false } +} +``` + +**Notice the help attribute**. A help flag is automatically created for every command (can be overridden), which can be detected in the help attribute. In the example above, no help was requested. + +Run the same command again with the help flag: +```sh +dir md -R /path/to/dir --help +``` + +_Output:_ + +```sh +dir mkdir [OPTIONS] + + Make a new directory. + +Options: + + -p [-R] : Recursively create the directory if it does not + already exist. +``` + +By default, a help message like the one above will be logged out to the console. However; this can be disabled by setting `autohelp: false` in the shell configuration. When automatic help is turned off, the help objects are still passed to handler functions, giving developers full control. + +_With `autohelp: false`_ + +```sh +{ + command: 'mkdir', + input: '-R /path/to/dir -h', + flags: { recognized: { p: true }, unrecognized: [ '/path/to/dir' ] }, + valid: true, + violations: [], + help: { + requested: true, + message: 'dir mkdir [OPTIONS]\n' + + '\n' + + ' Make a new directory.\n' + + '\n' + + 'Options:\n' + + '\n' + + ' -p [-R] : Recursively create the directory if it does not\n' + + ' already exist.\n' + } +} +``` + +> **TRY IT YOURSELF:** Change the source code of the `mkdir` command to do something meaningful. For example, use Node's recursive directory creation option (see [the docs](https://nodejs.org/dist/latest-v13.x/docs/api/fs.html#fs_fs_mkdir_path_options_callback)) to make new directories. + +### Subcommands + +There is an example subcommand called `json`, which is part of the `export` family of commands. + +In this app, running the main command, `export`, isn't supposed to do anything. It shows the help message: + +```sh +dir export +``` + +_Output:_ +```sh +dir export [OPTIONS] + + Export a file representation of a directory. + +Options: + + json: Generate a JSON representation of the directory. +``` + +The `json` **subcommand** is a little more interesting. It is supposed to display the file system as a JSON object. + +```sh +dir export json +``` + +_Outputs:_ + +```sh +{ + "/.../@author.io/shell/examples/cli": [ + "README.md", + "index.js", + "package-lock.json", + "package.json" + ] +} +``` + +You can see all of the subcommand features by applying the `--help` flag to the command: + +```sh +dir export json -help +``` + +_Outputs:_ + +```sh +dir export json [OPTIONS] + + Generate a JSON representation of the directory. + +Options: + + -array [-a] : Generate an array. This is a pretty long message + for such a short explanation. Kinda Rube + Goldbergish. +``` + +Notice there is an `array` flag for the `json` subcommand. + +```sh +dir export json -a +``` + +_Outputs:_ + +```sh +[ 'README.md', 'index.js', 'package-lock.json', 'package.json' ] +``` + diff --git a/examples/json/dir.json b/examples/json/dir.json new file mode 100644 index 0000000..98f3460 --- /dev/null +++ b/examples/json/dir.json @@ -0,0 +1,112 @@ +{ + "name": "dir", + "description": "A simple file system utility.", + "version": "1.0.0", + "commands": { + "list": { + "description": "List files of a directory. Optionally specify a long format.", + "help": "dir list [OPTIONS]\n\n List files of a directory. Optionally specify a long format.\n\nOptions:\n\n -l : Long format.\n", + "usage": "dir list [OPTIONS]\n\n List files of a directory. Optionally specify a long format.", + "aliases": [ + "ls" + ], + "flags": { + "l": { + "description": "Long format.", + "default": false, + "aliases": [] + } + }, + "handler": "(data) => {\n let dir = process.cwd()\n\n if (data.flags.unrecognized.length > 0) {\n dir = path.resolve(data.flags.unrecognized[0])\n }\n\n const out = fs.readdirSync(dir)\n .map(item => {\n if (data.flags.recognized.l) {\n return ((fs.statSync(path.join(dir, item)).isDirectory() ? '>' : '-') + ' ' + item).trim()\n }\n\n return item\n })\n .join(data.flags.recognized.l ? '\\n' : ', ')\n\n console.log(out)\n }", + "commands": {}, + "middleware": [] + }, + "export": { + "description": "Export a file representation of a directory.", + "help": "dir export [OPTIONS]\n\n Export a file representation of a directory.\n\nOptions:\n\n json: Generate a JSON representation of the directory. \n", + "usage": "dir export [OPTIONS]\n\n Export a file representation of a directory.", + "aliases": [], + "flags": {}, + "handler": "(data, cb) => {\n if (data.help && data.help.requested) {\n console.log(data.help.message)\n }\n\n cb && cb(data)\n }", + "commands": { + "json": { + "description": "Generate a JSON representation of the directory.", + "help": "dir export json [OPTIONS]\n\n Generate a JSON representation of the directory.\n\nOptions:\n\n -array [-a] : Generate an array. This is a pretty long message\n for such a short explanation. Kinda Rube\n Goldbergish.\n", + "usage": "dir export json [OPTIONS]\n\n Generate a JSON representation of the directory.", + "aliases": [], + "flags": { + "array": { + "type": "boolean", + "default": false, + "description": "Generate an array. This is a pretty long message for such a short explanation. Kinda Rube Goldbergish.", + "aliases": [ + "a" + ] + } + }, + "handler": "function (data) {\n if (data.help.requested) {\n return console.log(this.help)\n }\n\n let dir = process.cwd()\n\n if (data.flags.unrecognized.length > 0) {\n dir = path.resolve(data.flags.unrecognized[0])\n }\n\n const contents = fs.readdirSync(dir)\n\n if (data.flags.recognized.array) {\n return console.log(contents)\n }\n\n const result = Object.defineProperty({}, dir, {\n enumerable: true,\n value: contents\n })\n\n console.log(JSON.stringify(result, null, 2))\n }", + "commands": {}, + "middleware": [] + } + }, + "middleware": [] + }, + "mkdir": { + "description": "Make a new directory.", + "help": "dir mkdir [OPTIONS]\n\n Make a new directory.\n\nOptions:\n\n -p [-R] : Recursively create the directory if it does not\n already exist.\n", + "usage": "dir mkdir [OPTIONS]\n\n Make a new directory.", + "aliases": [ + "md", + "m" + ], + "flags": { + "p": { + "type": "boolean", + "default": false, + "description": "Recursively create the directory if it does not already exist.", + "aliases": [ + "R" + ] + } + }, + "handler": "function (data) {\n console.log('...make a directory...')\n console.log(data)\n }", + "commands": {}, + "middleware": [] + }, + "doc": { + "description": "Output the metadoc of this shell.", + "help": "dir doc [OPTIONS]\n\n Output the metadoc of this shell.\n\nOptions:\n\n -file [-f] \n", + "usage": "dir doc [OPTIONS]\n\n Output the metadoc of this shell.", + "aliases": [], + "flags": { + "file": { + "name": "file", + "aliases": [ + "f" + ] + }, + "help": { + "description": "Display doc help.", + "aliases": [ + "h" + ], + "default": false, + "type": "boolean", + "name": "help" + } + }, + "handler": "function (meta) {\n let data = this.shell.data\n\n if (meta.flag('file') !== null) {\n fs.writeFileSync(path.resolve(meta.flag('file')), JSON.stringify(data, null, 2))\n } else {\n console.log(data)\n }\n }", + "commands": {}, + "middleware": [] + } + }, + "middleware": [ + "(data, next) => {\n console.log('This middleware runs on every command.')\n next()\n}" + ], + "help": "dir 1.0.0\n\n A simple file system utility.\n\n - list [ls] \t : List files of a directory. Optionally specify a\n long format.\n - export \t : Export a file representation of a directory.\n Has an additional subcommand.\n - mkdir [md, m] \t : Make a new directory.\n - doc \t : Output the metadoc of this shell.\n", + "usage": "dir 1.0.0\n\n A simple file system utility.", + "defaultHandler": "(data, cb) => {\n if (data.help && data.help.requested) {\n console.log(data.help.message)\n }\n\n cb && cb(data)\n }", + "authohelp": true, + "runtime": "node", + "maxHistoryItems": 100 +} \ No newline at end of file diff --git a/examples/json/index.js b/examples/json/index.js new file mode 100755 index 0000000..c5d973c --- /dev/null +++ b/examples/json/index.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import fs from 'fs' +import path from 'path' +import { Command, Shell } from '../../src/index.js' +import { fileURLToPath } from 'url' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'dir.json'))) +const shell = new Shell(config) +const cmd = process.argv.slice(2).join(' ').trim() +shell.exec(cmd).catch(e => console.log(e.message || e)) diff --git a/examples/json/package-lock.json b/examples/json/package-lock.json new file mode 100644 index 0000000..a46eb29 --- /dev/null +++ b/examples/json/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "jdir", + "version": "1.0.0", + "lockfileVersion": 1 +} diff --git a/examples/json/package.json b/examples/json/package.json new file mode 100644 index 0000000..f7005d4 --- /dev/null +++ b/examples/json/package.json @@ -0,0 +1,20 @@ +{ + "name": "jdir", + "version": "1.0.0", + "description": "A simple file system utility.", + "main": "index.js", + "bin": { + "jdir": "index.js" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "type": "module", + "private": true, + "author": "Corey Butler", + "license": "MIT", + "engines": { + "node": "^12.0.0" + }, + "dependencies": {} +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5c41e35..0000000 --- a/package-lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@author.io/shell", - "version": "1.1.7", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@author.io/arg": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@author.io/arg/-/arg-1.2.5.tgz", - "integrity": "sha512-i+6F2ViIojVDqs+GS6Att/79SPDvdTijYzNg0VmNXF/03MIPWKnVwyRLOCpTx0pRjZNa3WQJwGZ3vqGN36lG1w==" - } - } -} diff --git a/package.json b/package.json index 03ea4d7..40f0d90 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,76 @@ { "name": "@author.io/shell", - "version": "1.1.7", + "version": "1.9.2", "description": "A micro-framework for creating CLI-like experiences. This supports Node.js and browsers.", - "main": "src/index.js", + "main": "./src/index.js", + "module": "./index.js", + "exports": { + "import": "./index.js", + "default": "./index.js" + }, + "browser": "./index.js", "scripts": { - "test": "cd ./test && npm test", - "build": "cd ./build && npm run all", - "build:node": "cd ./build && npm run node", - "build:browser": "cd ./build && npm run browser", - "test:node": "cd ./test && npm run node", - "test:browser": "cd ./test && npm run browser", - "reset": "echo Resetting test environments && cd ./test && npm run clean && cd ../build && npm run clean", - "manually": "cd ./test && npm run browser:manual:open", - "again": "cd ./test && npm run browser:manual", - "setup": "echo \"Installing build and test dependencies...\" && cd ./build && npm i --no-package-lock && cd ../test && npm i --no-package-lock && echo Template Setup Complete.", - "resetup": "node -e \"let fs=require('fs'),path=require('path');fs.rmdirSync(path.resolve('./build/node_modules'), { recursive: true });fs.rmdirSync(path.resolve('./test/node_modules'), { recursive: true });\" && npm run setup", - "report:compat": "cd ./test && npm run compat", - "report:syntax": "cd ./test && npm run syntax", - "report:preview": "npm pack --dry-run && echo \"==============================\" && echo \"This report shows what will be published to the module registry. Pay attention to the tarball contents and assure no sensitive files will be published.\"" + "start": "dev workspace", + "test": "npm run test:node && npm run test:deno && npm run test:browser && npm run report:syntax && npm run report:size", + "test:node": "dev test -rt node tests/*.js", + "test:node:sanity": "dev test -rt node tests/01-sanity.js", + "test:node:base": "dev test -rt node tests/02-base.js", + "test:node:relationships": "dev test -rt node tests/06-relationships.js", + "test:node:regression": "dev test -rt node tests/100-regression.js", + "test:browser": "dev test -rt browser tests/*.js", + "test:browser:sanity": "dev test -rt browser tests/01-sanity.js", + "test:browser:base": "dev test -rt browser tests/02-base.js", + "test:deno": "dev test -rt deno tests/*.js", + "test:deno:sanity": "dev test -rt deno tests/01-sanity.js", + "manually": "dev test -rt manual tests/*.js", + "build": "dev build --verbose", + "report:syntax": "dev report syntax --pretty", + "report:size": "dev report size ./.dist/**/*.js ./.dist/**/*.js.map", + "report:compat": "dev report compatibility ./src/**/*.js", + "report:preview": "npm pack --dry-run && echo \"==============================\" && echo \"This report shows what will be published to the module registry. Pay attention to the tarball contents and assure no sensitive files will be published.\"", + "ci": "dev test --verbose --mode ci --peer -rt node tests/*.js && dev test --mode ci -rt deno tests/*.js && dev test --mode ci -rt browser tests/*.js", + "x": "esbuild ./src/index.js --format esm --bundle --minify --outfile=.dist/shell/index.js" }, "keywords": [ "cli", "args", - "arg" + "arg", + "shell" ], - "author": "Corey Butler", + "repository": { + "type": "git", + "url": "https://github.com/author/shell" + }, + "dev": { + "ignorecircular": [ + "src/command.js", + "src/shell.js" + ], + "replace": { + "<#REPLACE_VERSION#>": "package.version" + }, + "volume": [ + "./node_modules/@author.io/arg:/node_modules/@author.io/arg", + "./node_modules/@author.io/table:/node_modules/@author.io/table" + ], + "alias": { + "@author.io/shell": "/app/.dist/@author.io/shell/index.js", + "@author.io/arg": "/node_modules/@author.io/arg/index.js", + "@author.io/table": "/node_modules/@author.io/table/index.js" + }, + "ci": { + "verbose": true, + "peer": true, + "embed": [ + "@author.io/arg", + "@author.io/table" + ] + } + }, + "author": { + "name": "Corey Butler", + "url": "http://coreybutler.com" + }, "license": "MIT", "type": "module", "files": [ @@ -46,10 +92,14 @@ ], "globals": [ "window", - "global" + "global", + "globalThis" ] }, - "dependencies": { - "@author.io/arg": "^1.2.5" + "devDependencies": { + "esbuild": "^0.14.10", + "@author.io/arg": "^1.3.23", + "@author.io/dev": "^1.1.5", + "@author.io/table": "^1.0.3" } } diff --git a/src/base.js b/src/base.js new file mode 100644 index 0000000..460ef62 --- /dev/null +++ b/src/base.js @@ -0,0 +1,506 @@ +import Middleware from './middleware.js' +import Formatter from './format.js' +import Shell from './shell.js' +import Command from './command.js' + +export default class Base { + #plugins = {} + #url = null + #support = null + #formattedDefaultHelp + #description + #customUsage + #customHelp + #arguments = new Set() + #autohelp = true + #processors = new Map() + #commands = new Map() + #width = 80 + #name = 'Unknown' + #middleware = new Middleware() + #trailer = new Middleware() + #commonflags = {} + #display = { + // These are all null, representing they're NOT configured. + Default: null, + Options: null, + MultipleValues: null, + Required: null + } + + #hasCustomDefaultHandler = false + + #defaultHandler = function (meta) { + if (this.parent !== null && this.parent.hasCustomDefaultHandler) { + return this.parent.defaultHandler(...arguments) + } else if (this.shell && this.shell !== null && this.shell.hasCustomDefaultHandler) { + return this.shell.defaultHandler(...arguments) + } + + if (this.#autohelp) { + console.log(this.help) + } + } + + constructor (cfg = {}) { + if (typeof cfg !== 'object') { + throw new Error('Invalid command configuration. Expected an object.') + } + + if (!cfg.hasOwnProperty('name')) { // eslint-disable-line no-prototype-builtins + throw new Error('Invalid command configuration. A "name" attribute is required.') + } + + if (cfg.hasOwnProperty('help')) { // eslint-disable-line no-prototype-builtins + this.#customHelp = cfg.help + } + + if (cfg.hasOwnProperty('usage')) { // eslint-disable-line no-prototype-builtins + this.#customUsage = cfg.usage + } + + if (cfg.hasOwnProperty('disablehelp') && !cfg.hasOwnProperty('disableHelp')) { // eslint-disable-line no-prototype-builtins + cfg.disableHelp = cfg.disablehelp + } + + if (cfg.hasOwnProperty('disableHelp') && cfg.disableHelp === true) { // eslint-disable-line no-prototype-builtins + this.#autohelp = false + } + + if (typeof cfg.help === 'function' || typeof cfg.help === 'string') { + this.help = cfg.help + } + + if (typeof cfg.usage === 'function' || typeof cfg.usage === 'string') { + this.usage = cfg.usage + } + + if (cfg.hasOwnProperty('defaultHandler') && cfg.defaultHandler.toString() !== this.#defaultHandler.toString()) { // eslint-disable-line no-prototype-builtins + this.defaultHandler = cfg.defaultHandler + } + + if (typeof cfg.arguments === 'string') { + cfg.arguments = cfg.arguments.split(/\s+|\t+|\,+|\;+/).map(arg => arg.trim()) // eslint-disable-line no-useless-escape + } + + if (Array.isArray(cfg.arguments)) { + this.#arguments = new Set(cfg.arguments) + } + + if (typeof cfg.url === 'string') { + this.#url = cfg.url + } + + if (typeof cfg.support === 'string') { + this.#support = cfg.support + } + + this.#name = (cfg.name || 'unknown').trim().split(/\s+/)[0] + this.#description = cfg.description || null + + if (Array.isArray(cfg.commands)) { + cfg.commands.forEach(cmd => this.add(cmd)) + } else if (typeof cfg.commands === 'object') { + for (const key in cfg.commands) { + const data = cfg.commands[key] + data.name = key + this.add(data) + } + } + + if (cfg.hasOwnProperty('middleware')) { // eslint-disable-line no-prototype-builtins + console.warn('The "middleware" attribute has been replaced with the "use" attribute.') + cfg.use = cfg.middleware + delete cfg.middleware + } + + if (!cfg.hasOwnProperty('commonflag')) { // eslint-disable-line no-prototype-builtins + if (cfg.hasOwnProperty('commonFlag')) { // eslint-disable-line no-prototype-builtins + cfg.commonflag = cfg.commonFlag + } else if (cfg.hasOwnProperty('commonflags')) { // eslint-disable-line no-prototype-builtins + cfg.commonflag = cfg.commonflags + } else if (cfg.hasOwnProperty('commonFlag')) { // eslint-disable-line no-prototype-builtins + cfg.commonflag = cfg.commonFlag + } else if (cfg.hasOwnProperty('commonFlags')) { // eslint-disable-line no-prototype-builtins + cfg.commonflag = cfg.commonFlags + } + } + + if (cfg.hasOwnProperty('commonflag')) { // eslint-disable-line no-prototype-builtins + if (typeof cfg.commonflag !== 'object') { + throw new Error('The "commonflag" configuration attribute must be an object.') + } + } + + if (typeof cfg.plugins === 'object') { + this.#plugins = cfg.plugins + } + + Object.defineProperties(this, { + __processors: { + enumerable: false, + get () { + return this.#processors + } + }, + __commands: { + enumerable: false, + get () { + return this.#commands + } + }, + __width: { + enumerable: false, + get () { + return this.#width + }, + set (v) { + this.#width = v || 80 + } + }, + __commonflags: { + enumerable: false, + get () { + return this.#commonflags + }, + set (value) { + this.#commonflags = value + } + }, + arguments: { + enumerable: false, + get () { + return this.#arguments + } + }, + initializeMiddleware: { + enumerable: false, + configurable: false, + writable: false, + value: code => { + if (typeof code === 'string') { + this.use(Function('return ' + code)()) // eslint-disable-line no-new-func + } else if (typeof code === 'function') { + this.use(code) + } else { + throw new Error('Invalid middleware: ' + code.toString()) + } + } + }, + initializeTrailer: { + enumerable: false, + configurable: false, + writable: false, + value: code => { + if (typeof code === 'string') { + this.trailer(Function('return ' + code)()) // eslint-disable-line no-new-func + } else if (typeof code === 'function') { + this.trailer(code) + } else { + throw new Error('Invalid trailer: ' + code.toString()) + } + } + }, + initializeHelpAnnotations: { + enumerable: false, + configurable: false, + writable: false, + value: cfg => { + if (cfg.hasOwnProperty('describeDefault') && typeof cfg.describeDefault === 'boolean') { // eslint-disable-line no-prototype-builtins + this.#display.Default = cfg.describeDefault + } + if (cfg.hasOwnProperty('describeOptions') && typeof cfg.describeOptions === 'boolean') { // eslint-disable-line no-prototype-builtins + this.#display.Options = cfg.describeOptions + } + if (cfg.hasOwnProperty('describeMultipleValues') && typeof cfg.describeMultipleValues === 'boolean') { // eslint-disable-line no-prototype-builtins + this.#display.MultipleValues = cfg.describeMultipleValues + } + if (cfg.hasOwnProperty('describeRequired') && typeof cfg.describeRequired === 'boolean') { // eslint-disable-line no-prototype-builtins + this.#display.Required = cfg.describeRequired + } + } + } + }) + + this.updateHelp() + } + + get plugins () { + return this.#plugins + } + + // @readonly + get name () { + return this.#name || 'Unknown' + } + + // @readonly + get description () { + return this.#description || this.usage || '' + } + + // @readonly + get url () { + return this.URL + } + + // @readonly + get URL () { + const uri = (this.#url || '').trim() + + if (uri.length === 0) { + if (this.hasOwnProperty('parent')) { // eslint-disable-line no-prototype-builtins + return this.parent.URL + } else if (this instanceof Command) { + return this.shell.URL + } + } + + return uri + } + + // @readonly + get support () { + const support = (this.#support || '').trim() + + if (support.length === 0) { + if (this.hasOwnProperty('parent')) { // eslint-disable-line no-prototype-builtins + return this.parent.support + } else if (this instanceof Command) { + return this.shell.support + } + } + + return support + } + + get autohelp () { + return this.#autohelp + } + + set autohelp (value) { + if (typeof value !== 'boolean') { + return + } + this.#autohelp = value + this.#processors.forEach(cmd => { cmd.autohelp = value }) + } + + updateHelp () { + this.#formattedDefaultHelp = new Formatter(this) + this.#formattedDefaultHelp.width = this.#width + } + + describeHelp (attr, prop) { + if (this.#display[prop] !== null) { + return this.#display[prop] + } + + if (this instanceof Command) { + if (this.shell && this.shell !== null && this.shell[attr] !== null) { + return this.shell[attr] + } + + if (this.parent !== null) { + return this.parent[attr] + } + } + + return true + } + + get describeDefault () { + return this.describeHelp('describeDefault', 'Default') + } + + get describeOptions () { + return this.describeHelp('describeOptions', 'Options') + } + + get describeMultipleValues () { + return this.describeHelp('describeMultipleValues', 'MultipleValues') + } + + get describeRequired () { + return this.describeHelp('describeRequired', 'Required') + } + + get usage () { + if (this.#customUsage !== null) { + return typeof this.#customUsage === 'function' ? this.#customUsage() : this.#customUsage + } + + return this.#formattedDefaultHelp.usage + } + + set usage (value) { + if (typeof value === 'string' && value.trim().length === 0) { + value = null + } + + this.#customUsage = value + + this.updateHelp() + } + + get help () { + if (this.#customHelp) { + return typeof this.#customHelp === 'function' ? this.#customHelp(this) : this.#customHelp + } + + if (!this.autohelp) { + return '' + } + + return this.#formattedDefaultHelp.help + } + + set help (value) { + if (typeof value === 'string' && value.trim().length === 0) { + value = null + } + + this.#customHelp = value + + this.updateHelp() + } + + // @private + set defaultHandler (value) { + if (typeof value === 'function') { + this.#defaultHandler = value + this.#hasCustomDefaultHandler = true + this.#processors.forEach(cmd => { cmd.defaultProcessor = value }) + } else { + throw new Error(`Invalid default method (must be a function, not "${typeof value}").`) + } + } + + get defaultHandler () { + return this.#defaultHandler + } + + // @private + get hasCustomDefaultHandler () { + return this.#hasCustomDefaultHandler + } + + get data () { + const commands = {} + + Array.from(this.#processors.values()).forEach(cmd => { + const data = cmd.data + const name = data.name + delete data.name + commands[name] = data + }) + + return commands + } + + get middleware () { + return this.#middleware + } + + get trailers () { + return this.#trailer + } + + get commands () { + return this.#processors + } + + get commandlist () { + const list = new Set() + this.commands.forEach(cmd => { + list.add(cmd.name) + cmd.commandlist.forEach(subcmd => list.add(`${cmd.name} ${subcmd}`)) + }) + + return Array.from(list).sort() + } + + getCommand (name = null) { + if (!name) { + return null + } + + const names = name.split(/\s+/i) + let cmd = this.#commands.get(names.shift()) + if (cmd) { + cmd = this.#processors.get(cmd) + for (const nm of names) { + cmd = cmd.getCommand(nm) + } + } + + return cmd instanceof Command ? cmd : null + } + + remove () { + for (const cmd of arguments) { + if (typeof cmd === 'symbol') { + this.#processors.delete(cmd) + this.#commands.forEach(oid => oid === cmd && this.#commands.delete(oid)) + } + + if (typeof cmd === 'string') { + const OID = this.#commands.get(cmd) + if (OID) { + this.remove(OID) + } + } + } + } + + use () { + for (const arg of arguments) { + if (typeof arg !== 'function') { + throw new Error(`All "use()" arguments must be valid functions.\n${arg.toString().substring(0, 50)} ${arg.toString().length > 50 ? '...' : ''}`) + } + + this.#middleware.use(arg) + } + + this.#processors.forEach(subCmd => subCmd.use(...arguments)) + } + + trailer () { + this.#trailer = this.#trailer || new Middleware() + + for (const arg of arguments) { + if (typeof arg !== 'function') { + throw new Error(`All "trailer()" arguments must be valid functions.\n${arg.toString().substring(0, 50)} ${arg.toString().length > 50 ? '...' : ''}`) + } + + this.#trailer.use(arg) + } + + this.#processors.forEach(subCmd => subCmd.trailer(...arguments)) + } + + add () { + for (let command of arguments) { + if (!(command instanceof Command)) { + if (typeof command === 'object') { + command = new Command(command) + } else { + throw new Error('Invalid argument. Only "Command" instances may be added to the processor.') + } + } + + command.autohelp = this.autohelp + + if (this instanceof Shell) { + command.shell = this + } else if (this instanceof Command) { + command.parent = this + } + + this.#processors.set(command.OID, command) + this.#commands.set(command.name, command.OID) + + command.aliases.forEach(alias => this.#commands.set(alias, command.OID)) + } + } +} diff --git a/src/command.js b/src/command.js index 98d2f8b..9666458 100644 --- a/src/command.js +++ b/src/command.js @@ -1,61 +1,56 @@ import { Parser } from '../node_modules/@author.io/arg/index.js' -import Middleware from './middleware.js' +// import { Parser } from '@author.io/arg' +import Shell from './shell.js' +import Base from './base.js' +import { METHOD_PATTERN, FLAG_PATTERN, STRIP_QUOTE_PATTERN } from './utility.js' -const STRIP_EQUAL_SIGNS = /(\=+)(?=([^'"\\]*(\\.|['"]([^'"\\]*\\.)*[^'"\\]*['"]))*[^'"]*$)/g -const SUBCOMMAND_PATTERN = /^([^"'][\S\b]+)[\s+]?([^-].*)$/i -const FLAG_PATTERN = /((?:"[^"\\]*(?:\\[\S\s][^"\\]*)*"|'[^'\\]*(?:\\[\S\s][^'\\]*)*'|\/[^\/\\]*(?:\\[\S\s][^\/\\]*)*\/[gimy]*(?=\s|$)|(?:\\\s|\S))+)(?=\s|$)/g - -export default class Command { +export default class Command extends Base { #pattern #oid - #name - #description - #aliases - #customUsage - #customHelp + #aliases = new Set() #fn - #flagConfig = null - #subcommands = new Map() - #processors = new Map() - #autohelp = true + #flagConfig = {} #parent = null #shell = null - #middleware = new Middleware() - #tabWidth - #tableWidth - #hasCustomDefaultHandler = false - #defaultHandler = data => console.log(this.help) constructor (cfg = {}) { - if (typeof cfg !== 'object') { - throw new Error('Invalid command configuration. Expected an object.') + if (cfg.hasOwnProperty('handler')) { // eslint-disable-line no-prototype-builtins + if (typeof cfg.handler === 'string') { + cfg.handler = Function('return (' + cfg.handler.replace('function anonymous', 'function') + ').call(this)').call(globalThis) // eslint-disable-line no-new-func + } + + if (typeof cfg.handler !== 'function') { + throw new Error('Invalid command configuration. A "handler" function is required.') + } } - if (!cfg.hasOwnProperty('name')) { - throw new Error('Invalid command configuration. A "name" attribute is required.') + super(cfg) + + if (cfg.hasOwnProperty('use') && Array.isArray(cfg.use)) { // eslint-disable-line no-prototype-builtins + cfg.use.forEach(code => this.initializeMiddleware(code)) } - if (cfg.handler && typeof cfg.handler !== 'function') { - throw new Error('Invalid command configuration. A "handler" function is required.') + if (cfg.hasOwnProperty('trailer') && Array.isArray(cfg.trailer)) { // eslint-disable-line no-prototype-builtins + cfg.trailer.forEach(code => this.initializeTrailer(code)) } - this.#name = cfg.name.trim().split(/\s+/)[0] + this.initializeHelpAnnotations(cfg) + this.#fn = cfg.handler this.#oid = Symbol(((cfg.name || cfg.usage) || cfg.pattern) || 'command') this.#pattern = cfg.pattern || /[\s\S]+/i - this.#customUsage = cfg.usage || null - this.#customHelp = cfg.help || null - this.aliases = cfg.aliases - this.#description = cfg.description || null - this.#tabWidth = cfg.hasOwnProperty('tabWidth') ? cfg.tabWidth : 4 - this.#tableWidth = cfg.hasOwnProperty('tableWidth') ? cfg.tableWidth : 70 - - if (cfg.alias) { - if (Array.isArray(cfg.alias)) { - this.aliases = cfg.alias - } else { - this.aliases.push(cfg.alias) + + if (cfg.alias && !cfg.aliases) { + cfg.aliases = typeof cfg.alias === 'string' ? [cfg.alias] : (Array.isArray(cfg.alias) ? cfg.alias : Array.from(cfg.alias)) + delete cfg.alias + } + + if (cfg.aliases) { + if (!Array.isArray(cfg.aliases)) { + throw new Error('The alias property only accepts an array.') } + + this.#aliases = new Set(cfg.aliases) } if (cfg.flags) { @@ -63,233 +58,241 @@ export default class Command { throw new Error(`Invalid flag configuration (expected and object, received ${typeof cfg.flags}).`) } - this.#flagConfig = cfg.flags - } + for (const [key, value] of Object.entries(cfg.flags)) { + if (value.hasOwnProperty('alias')) { // eslint-disable-line no-prototype-builtins + value.aliases = value.aliases || [] - if (typeof cfg.autohelp === 'boolean') { - this.#autohelp = cfg.autohelp - } + if (Array.isArray(value.alias)) { + value.aliases = Array.from(new Set(...value.aliases, ...value.alias)) + if (value.aliases.filter(a => typeof a !== 'string') > 0) { + throw new Error(`${key} flag aliases must be strings. Type failure on: ${value.aliases.filter(a => typeof a !== 'string').join(', ')}.`) + } + } else if (typeof value.alias === 'string') { + value.aliases.push(value.alias) + } else { + throw new Error(`Aliases must be strings, not ${typeof value.alias} (${key} flag).`) + } - if (typeof cfg.defaultHandler === 'function') { - this.defaultHandler = cfg.defaultHandler - } + delete value.alias + } + } - if (Array.isArray(cfg.commands)) { - cfg.commands.forEach(cmd => this.add(cmd)) + this.#flagConfig = cfg.flags } if (Array.isArray(cfg.subcommands)) { - cfg.subcommands.forEach(cmd => this.add(cmd)) + this.add(...cfg.subcommands) } const attributes = new Set([ 'commands', 'subcommands', + 'plugins', 'defaultHandler', - 'autohelp', + 'disableHelp', + 'describeDefault', + 'describeOptions', + 'describeMultipleValues', + 'describeRequired', 'flags', 'alias', 'aliases', - 'tabWidth', - 'tableWidth', 'description', 'help', 'usage', 'pattern', 'name', - 'handler' + 'handler', + 'middleware', + 'use', + 'arguments', + 'commonflag', + 'commonflags', + 'trailer', + 'url', + 'support' ]) const unrecognized = Object.keys(cfg).filter(attribute => !attributes.has(attribute)) if (unrecognized.length > 0) { - throw new Error(`Unrecognized shell configuration attribute(s): ${unrecognized.join(', ')}`) + throw new Error(`Unrecognized configuration attribute(s): ${unrecognized.join(', ')}`) } - } - set tableWidth(value) { - this.#tableWidth = value - this.#processors.forEach(cmd => cmd.tableWidth = value) - } + const ignoreFlags = commonFlags => { + if (commonFlags.ignore && (Array.isArray(commonFlags.ignore) || typeof commonFlags.ignore === 'string')) { + const ignore = new Set(Array.isArray(commonFlags.ignore) ? commonFlags.ignore : [commonFlags.ignore]) + // delete commonFlags.ignore + const root = this.commandroot.replace(new RegExp(`^${this.shell.name}\\s+`, 'i'), '') - set tabWidth(value) { - this.#tabWidth = value - this.#processors.forEach(cmd => cmd.tabWidth = value) - } + for (const cmd of ignore) { + if (root.startsWith(cmd)) { + commonFlags = {} + break + } + } + } - // @private - set defaultHandler (value) { - if (typeof value === 'function') { - this.#defaultHandler = value - this.#hasCustomDefaultHandler = true - this.#processors.forEach(cmd => cmd.defaultProcessor = value) - } else { - throw new Error(`Invalid default method (must be a function, not ${typeof cfg.defaultHandler}).`) + return commonFlags } - } - // @private - get hasCustomDefaultHandler () { - return this.#hasCustomDefaultHandler - } + Object.defineProperties(this, { + __commonFlags: { + enumerable: false, + get () { + let flags = ignoreFlags(this.__commonflags) // Object.assign({}, this.__commonflags, this.#flagConfig) - set parent (cmd) { - if (cmd instanceof Command) { - this.#parent = cmd - } - } + if (this.parent !== null) { + flags = Object.assign(flags, this.parent.__commonFlags) + } - set shell (shell) { - this.#shell = shell - } + const result = Object.assign({}, this.shell !== null ? ignoreFlags(this.shell.__commonflags) : {}, flags) - get shell () { - if (!this.#shell) { - if (this.#parent) { - return this.#parent.shell + if (Array.isArray(result.ignore) || typeof result.ignore === 'string') { + delete result.ignore + } + + return result + } + }, + __flagConfig: { + enumerable: false, + get () { + const flags = new Map(Object.entries(Object.assign(this.__commonFlags, this.#flagConfig || {}))) + flags.delete('help') + return flags + } + }, + getTerminalCommand: { + enumerable: false, + configurable: false, + writable: false, + value: input => { + const args = input.trim().split(/\t+|\s+/) + let cmd = this + + while (args.length > 0) { + const arg = args[0] + const subcmd = cmd.getCommand(arg) + if (subcmd) { + cmd = subcmd + args.shift() + } else { + break + } + } + + return { + command: cmd, + arguments: args.join(' ') + } + } } - return '' - } + }) - return this.#shell.name - } + this.__commonflags = cfg.commonflags || {} + + this.__width = this.shell === null ? 80 : this.shell.tableWidth || 80 - get autohelp() { - return this.#autohelp + this.updateHelp() } - set autohelp(value) { - if (typeof value !== 'boolean') { - return + get data () { + const commands = super.data + + let handler = (this.#fn || this.defaultHandler).toString() + if (METHOD_PATTERN.test(handler)) { + handler = handler.replace(METHOD_PATTERN.exec(handler)[1], 'function ') } - this.#autohelp = value - this.#processors.forEach(cmd => cmd.autohelp = value) - } - get subcommands () { - return this.#processors - } + const flags = Object.assign(this.__commonFlags, this.#flagConfig || {}) - get name () { - return this.#name - } + for (const [key, value] of Object.entries(flags)) { // eslint-disable-line no-unused-vars + value.aliases = value.aliases || [] - get description () { - return this.#description || this.usage - } + if (value.hasOwnProperty('alias')) { // eslint-disable-line no-prototype-builtins + if (value.aliases.indexOf(value.alias) < 0) { + value.aliases.push(value.alias) + } + } - get commandroot () { - if (this.#parent) { - return `${this.#parent.commandroot} ${this.#name}`.trim() + delete value.alias } - if (this.#shell) { - return `${this.#shell.name} ${this.#name}`.trim() - } + // Apply any missing default values to flags. + Object.keys(flags).forEach(name => { flags[name] = Object.assign(this.getFlagConfiguration(name), flags[name]) }) - return this.#name - } + const data = { + name: this.name, + description: this.description, + help: this.help, + usage: this.usage, + aliases: Array.from(this.#aliases), + flags, + handler, + commands, + disableHelp: !this.autohelp, + use: this.middleware.data, + trailer: this.trailers.data + } - get usage () { - if (this.#customUsage) { - return typeof this.#customUsage === 'function' ? this.#customUsage() : this.#customUsage + for (const [key, value] of Object.entries(data.flags)) { // eslint-disable-line no-unused-vars + delete value.alias } - const a = Array.from(this.#aliases) - const msg = [`${this.commandroot}${a.length > 0 ? ' <' + a.join(', ') + '> ' : ''} [OPTIONS]`.trim()] + return data + } - if (this.#description.trim().length > 0) { - msg.push('\n ' + this.#description.trim()) + set parent (cmd) { + if (cmd instanceof Command) { + this.#parent = cmd + } else { + throw new Error(`Cannot set parent of "${this.name}" command to anything other than another command. To make this command a direct descendent of the main shell, use the shell attribute instead.`) } - return msg.join('\n').trim() } - set usage (value) { - this.#customUsage = value + get parent () { + return this.#parent } - get help () { - if (this.#customHelp) { - return typeof this.#customHelp === 'function' ? this.#customHelp() : this.#customHelp + set shell (shell) { + if (!shell) { + throw new Error(`Cannot set shell of ${this.name} command to a non-Shell object.`) } + if (shell instanceof Shell) { + this.#shell = shell + this.__width = this.shell === null ? 80 : shell.tableWidth + } else { + throw new Error(`Expected a Shell object, received a "${typeof shell}" object.`) + } + } - let maxWidth = this.#tableWidth - let tabWidth = this.#tabWidth - - let msg = [this.usage + '\n'] - let flags = Object.keys(this.#flagConfig || {}).filter(f => f !== 'help') - if (flags.length > 0) { - msg.push('Options:\n') - - flags.forEach(flag => { - let message = ` -${flag}` - flag = this.#flagConfig[flag] - flag.alias = Array.isArray(flag.alias) ? flag.alias : [flag.alias] - if (flag.aliases) { - flag.alias = flag.alias.concat(flag.aliases) - } - flag.alias = new Set(flag.alias.filter(a => typeof a === 'string')) - - if (flag.alias.size > 0) { - message += ` [${Array.from(flag.alias).map(a => '-'+a).join(', ')}]` - } - message += '\t' - - let desc = [] - let tabs = message.match(/\t/gi).length - let prefixLength = message.length + 2 + (this.#tabWidth*tabs) - let dsc = new String(flag.description) - const match = new RegExp(`(.{0,${this.#tableWidth-prefixLength}}[\\s\n])`, 'g') - - if (flag.description) { - desc = flag.description.match(match) - desc.push(dsc.replace(desc.join(''), '')) - } - - while (desc.length > 1 && desc[desc.length - 1].length + desc[desc.length - 2].length < (this.#tableWidth-prefixLength)) { - desc[desc.length - 2] += desc.pop() - } - - desc = desc.reverse().map(item => item.trim()) - - if (desc.length > 0) { - let prefix = '' - for (let i = 0; i < prefixLength; i++) { - prefix += ' ' - } - - message += ' : ' + desc.pop() - while (desc.length > 0) { - message += `\n${prefix}${desc.pop()}` - } - } + get shell () { + if (!this.#shell) { + if (this.#parent) { + return this.#parent.shell + } - msg.push(message) - }) - } else if (this.#processors.size > 0) { - msg.push('Options:\n') + return null } - this.#processors.forEach(proc => { - let message = ` ${ proc.name }: \t ${ proc.description } ` + return this.#shell + } - if (proc.aliases.length > 0) { - message = `${ message } Aliases: ${ proc.aliases.join(', ') }.` - } - msg.push(message) - }) + get plugins () { + return Object.assign({}, this.shell.plugins, this.parent ? this.parent.plugins : {}, super.plugins) + } - let tab = '' - for (let i = 0; i < tabWidth; i++) { - tab += ' ' + get commandroot () { + if (this.#parent) { + return `${this.#parent.commandroot} ${this.name}`.trim() } - return (msg.join('\n') + '\n').replace(/\n{2,}$/, '\n').replace(/\t/gi, tab) - // return `${this.#name} help goes here` - } + if (this.#shell) { + return `${this.#shell.name} ${this.name}`.trim() + } - set help (value) { - this.#customHelp = value + return this.name } set aliases (value) { @@ -323,27 +326,22 @@ export default class Command { } get aliases () { - return this.#aliases + return Array.from(this.#aliases) || [] } get OID () { return this.#oid } - getCommand (name) { - return this.#subcommands.get(name) - } - addFlag (name, cfg) { if (typeof name !== 'string') { - if (!cfg.hasOwnProperty('name')) { + if (!cfg.hasOwnProperty('name')) { // eslint-disable-line no-prototype-builtins throw new Error('Invalid flag name (should be a string).') } else { name = cfg.name } } - this.#flagConfig = this.#flagConfig || {} this.#flagConfig[name] = cfg } @@ -351,62 +349,39 @@ export default class Command { delete this.#flagConfig[name] } - supportsFlag (name) { - return this.#flagConfig.hasOwnProperty(name) - } - - add (command) { - if (!(command instanceof Command)) { - if (typeof command === 'object') { - command = new Command(command) - } else { - throw new Error('Invalid argument. Only "Command" instances may be added to the processor.') - } - } - - command.parent = this - command.autohelp = this.#autohelp - command.tabWidth = this.#tabWidth - command.tableWidth = this.#tableWidth - - this.#processors.set(command.OID, command) - this.#subcommands.set(command.name, command.OID) - - command.aliases.forEach(alias => this.#subcommands.set(alias, command.OID)) - } - - remove () { - for (const cmd of arguments) { - if (typeof cmd === 'symbol') { - this.#processors.delete(cmd) - this.#subcommands.forEach(oid => oid === cmd && this.#subcommands.delete(oid)) - } - - if (typeof cmd === 'string') { - const OID = this.#subcommands.get(cmd) - if (OID) { - this.remove(OID) + getFlagConfiguration (name) { + let flag = this.__flagConfig.get(name) + if (!flag) { + for (const [f, cfg] of this.__flagConfig) { // eslint-disable-line no-unused-vars + if ((cfg.aliases && cfg.aliases.indexOf(name) >= 0) || (cfg.alias && cfg.alias === flag)) { + flag = cfg + break } } - } - } - use () { - for (const arg of arguments) { - if (typeof arg !== 'function') { - throw new Error(`All "use()" arguments must be valid functions.\n${ arg.toString().substring(0, 50) } ${ arg.toString().length > 50 ? '...' : '' }`) + if (!flag) { + return null } + } - this.#middleware.use(arg) + return { + description: flag.description, + required: flag.hasOwnProperty('required') ? flag.required : false, // eslint-disable-line no-prototype-builtins + aliases: flag.aliases || [flag.alias].filter(i => i !== null), + type: flag.type === undefined ? 'string' : (typeof flag.type === 'string' ? flag.type : flag.type.name.toLowerCase()), + options: flag.hasOwnProperty('options') ? flag.options : null, // eslint-disable-line no-prototype-builtins + allowMultipleValues: flag.hasOwnProperty('allowMultipleValues') ? flag.allowMultipleValues : false // eslint-disable-line no-prototype-builtins } + } - this.#processors.forEach(subCmd => subCmd.use(...arguments)) + supportsFlag (name) { + return this.#flagConfig.hasOwnProperty(name) // eslint-disable-line no-prototype-builtins } deepParse (input) { - let meta = this.parse(input) + const meta = this.parse(input) - if (this.#subcommands.size === 0) { + if (this.__commands.size === 0) { return meta } @@ -414,37 +389,36 @@ export default class Command { return meta } - let args = meta.input.split(/\s+/) - let subcmd = this.#subcommands.get(args.shift()) + const args = meta.input.split(/\s+/) + const subcmd = this.__commands.get(args.shift()) if (!subcmd) { return meta } - return this.#processors.get(subcmd).deepParse(args.join(' ')) + return this.__processors.get(subcmd).deepParse(args.join(' ')) } parse (input) { // Parse the command input for flags - const data = { command: this.#name, input: input.trim() } - - let flagConfig = this.#flagConfig || {} + const data = { command: this.name, input: input.trim() } + const flagConfig = Object.assign(this.__commonFlags, this.#flagConfig || {}) - if (!flagConfig.hasOwnProperty('help')) { + if (!flagConfig.hasOwnProperty('help')) { // eslint-disable-line no-prototype-builtins flagConfig.help = { - description: `Display ${ this.#name } help.`, - aliases: ['h'], + description: `Display ${this.name} help.`, + // aliases: ['h'], default: false, type: 'boolean' } } - let source = input.replace(STRIP_EQUAL_SIGNS, '').trim() + ' ' - const flags = Array.from(FLAG_PATTERN[Symbol.matchAll](source), x => x[0]) + const flags = Array.from(FLAG_PATTERN[Symbol.matchAll](input), x => x[0]) const parser = new Parser(flags, flagConfig) + const pdata = parser.data + const recognized = {} - let recognized = parser.data - + parser.recognizedFlags.forEach(flag => { recognized[flag] = pdata[flag] }) parser.unrecognizedFlags.forEach(arg => delete recognized[arg]) data.flags = { recognized, unrecognized: parser.unrecognizedFlags } @@ -452,8 +426,8 @@ export default class Command { data.violations = parser.violations data.parsed = {} - if (Object.keys(parser.data.flagSource).length > 0) { - for (const [key, src] of Object.entries(parser.data.flagSource)) { + if (Object.keys(pdata.flagSource).length > 0) { + for (const [key, src] of Object.entries(pdata.flagSource)) { // eslint-disable-line no-unused-vars data.parsed[src.name] = src.inputName } } @@ -466,6 +440,8 @@ export default class Command { data.help.message = this.help } + const args = Array.from(this.arguments) + Object.defineProperties(data, { flag: { enumerable: true, @@ -473,8 +449,20 @@ export default class Command { writable: false, value: name => { try { - return parser.data.flagSource[name].value + if (typeof name === 'number') { + return Array.from(parser.unrecognizedFlags)[name] + } else { + if (data.flags.recognized.hasOwnProperty(pdata.flagSource[name].name)) { // eslint-disable-line no-prototype-builtins + return data.flags.recognized[pdata.flagSource[name].name] + } else { + return pdata.flagSource[name].value + } + } } catch (e) { + if (this.arguments.has(name)) { + return Array.from(parser.unrecognizedFlags)[args.indexOf(name)] + } + return undefined } } @@ -486,6 +474,50 @@ export default class Command { shell: { enumerable: true, get: () => this.shell + }, + data: { + enumerable: true, + get () { + const uf = parser.unrecognizedFlags + const result = Object.assign({}, recognized) + delete result.help + + args.forEach((name, i) => { + const value = uf[i] + let normalizedValue = Object.keys(pdata).filter(key => key.toLowerCase() === value) + normalizedValue = (normalizedValue.length > 0 ? normalizedValue.pop() : value) + + if (normalizedValue !== undefined) { + normalizedValue = normalizedValue.trim() + + if (STRIP_QUOTE_PATTERN.test(normalizedValue)) { + normalizedValue = normalizedValue.substring(1, normalizedValue.length - 1) + } + } + + if (result.hasOwnProperty(name)) { // eslint-disable-line no-prototype-builtins + result[name] = Array.isArray(result[name]) ? result[name] : [result[name]] + result[name].push(normalizedValue) + } else { + result[name] = normalizedValue + } + }) + + if (uf.length > args.length) { + uf.slice(args.length) + .forEach((flag, i) => { + let name = `unknown${i + 1}` + while (result.hasOwnProperty(name)) { // eslint-disable-line no-prototype-builtins + const number = name.substring(7) + name = 'unknown' + (parseInt(number) + 1) + } + + result[name] = flag + }) + } + + return result + } } }) @@ -498,90 +530,58 @@ export default class Command { } async run (input, callback) { - let fn = this.#fn || this.#defaultHandler - let data = typeof input === 'string' ? this.parse(input) : input - const parsed = SUBCOMMAND_PATTERN.exec(input) + const fn = (this.#fn || this.defaultHandler).bind(this) + const data = typeof input === 'string' ? this.parse(input) : input arguments[0] = this.deepParse(input) + arguments[0].plugins = this.plugins - // A possible subcommand was input - if (parsed) { - let cmd = parsed[1] - let args = parsed.length > 2 ? parsed[2] : '' - let command = null - let subcommand = this.#subcommands.get(cmd) - - if (!subcommand) { - for (const [name, id] of this.#subcommands) { - const subcmd = this.#processors.get(id) + if (this.shell !== null) { + const parentMiddleware = this.shell.getCommandMiddleware(this.commandroot.replace(new RegExp(`^${this.shell.name}`, 'i'), '').trim()) - if (subcmd.aliases.indexOf(cmd)) { - subcommand = subcmd - break - } - } + if (parentMiddleware.length > 0) { + this.middleware.use(...parentMiddleware) } + } - // If the prospective subcommand is not recognized, run the main processor - if (!subcommand) { - return await (new Promise((resolve, reject) => { - try { - if (this.#autohelp && data.help.requested) { - console.log(this.help) - resolve() - } else { - try { - this.#middleware.use(fn) - resolve(() => this.#middleware.run(data, () => callback && callback())) - } catch (ee) { - reject(ee) - } - } - } catch (e) { - reject(e) - } - })) - } + const trailers = this.trailers - const processor = this.#processors.get(subcommand) + if (arguments[0].help && arguments[0].help.requested) { + console.log(this.help) - if (this.#autohelp) { - if (data.help.requested) { - if (processor) { - return console.log(processor.help) - } else { - return console.log(this.help) - } - } + if (trailers.size > 0) { + trailers.run(arguments[0]) } - if (!processor) { - return new Promise((resolve, reject) => reject(new Error(`${ this.#name } "${cmd}" command not found.`))) - } + return + } + + // No subcommand was recognized + if (this.middleware.size > 0) { + this.middleware.run(arguments[0], async meta => await Command.reply(fn(meta, callback))) - if (this.#middleware.size > 0) { - this.#middleware.use(async meta => await Command.reply(fn(meta, callback))) - return this.#middleware.run(...arguments, () => {}) + if (trailers.size > 0) { + trailers.run(arguments[0]) } - return Command.reply(await processor.run(args, callback)) + return } - // No subcommand was recognized - if (this.#middleware.size > 0) { - this.#middleware.use(async meta => await Command.reply(fn(meta, callback))) - return this.#middleware.run(...arguments, () => {}) - } + // Command.reply(fn(arguments[0], callback)) + data.plugins = this.plugins + Command.reply(fn(data, callback)) - return Command.reply(fn(data, callback)) + if (trailers.size > 0) { + trailers.run(arguments[0]) + } } static stderr (err) { if (err instanceof Error) { - return new Promise((resolve, reject) => reject(err)) + return new Promise((resolve, reject) => reject(err)).catch(console.error) } - return new Promise((resolve, reject) => reject(err)) + return new Promise((resolve, reject) => reject(err)).catch(console.error) } static reply (callback) { @@ -590,6 +590,7 @@ export default class Command { if (typeof callback === 'function') { callback() } + resolve() } catch (e) { reject(e) diff --git a/src/format.js b/src/format.js new file mode 100644 index 0000000..8a1a486 --- /dev/null +++ b/src/format.js @@ -0,0 +1,118 @@ +import Table from '@author.io/table' +import Command from './command.js' +import Shell from './shell.js' + +class Formatter { + #data = null + #tableWidth = 80 + #colAlign = [] // Defaults to ['l', 'l', 'l'] + #colWidth = ['20%', '15%', '65%'] + + constructor (data) { + this.#data = data + } + + set width (value) { + this.#tableWidth = value < 20 ? 20 : value + } + + set columnWidths (value) { + this.#colWidth = value + } + + set columnAlignment (value) { + this.#colAlign = value + } + + get usage () { + const desc = this.#data.description.trim() + + if (this.#data instanceof Command) { + const aliases = this.#data.aliases + const out = [`${this.#data.commandroot}${aliases.length > 0 ? '|' + aliases.join('|') : ''}${this.#data.__flagConfig.size > 0 ? ' [FLAGS]' : ''}${this.#data.arguments.size > 0 ? ' ' + Array.from(this.#data.arguments).map(i => '<' + i + '>').join(' ') : ''}`] + + if (this.#data.__processors.size > 0) { + out[out.length - 1] += (this.#data.arguments.size > 0 || this.#data.__flagConfig.size > 0 ? ' |' : '') + ' [COMMAND]' + } + + if (desc.trim().length > 0 && out !== desc) { + out.push(new Table([[desc.trim().replace(/\n/gi, '\n ')]], null, null, this.#tableWidth, [2, 0, 1, 1]).output) + } + + return out.join('\n') + } else if (this.#data instanceof Shell) { + return `${this.#data.name}${this.#data.__processors.size > 0 ? ' [COMMAND]' : ''}\n${desc.trim().length > 0 ? new Table([[desc.trim().replace(/\n/gi, '\n ')]], null, null, this.#tableWidth, [2, 0, 1, 1]).output : ''}${this.#data.arguments.size > 0 ? ' ' + Array.from(this.#data.arguments).map(i => '[' + i + ']').join(' ') : ''}\n`.trim() + } + + return '' + } + + get subcommands () { + const rows = Array.from(this.#data.__processors.values()).map(cmd => { + const nm = [cmd.name].concat(cmd.aliases) + return [nm.join('|'), cmd.description] + }) + + const result = [] + + if (rows.length > 0) { + const table = new Table(rows, this.#colAlign, ['25%', '75%'], this.#tableWidth, [2]) + result.push('\nCommands:\n') + result.push(table.output) + } + + return result.join('\n') + } + + get help () { + const usage = this.usage.trim() + + if (this.#data instanceof Command) { + const flags = this.#data.__flagConfig + const rows = [] + + if (flags.size > 0) { + flags.forEach((cfg, flag) => { + let aliases = Array.from(cfg.aliases || cfg.alias || []) + aliases = aliases.length === 0 ? '' : '[' + aliases.map(a => `-${a}`).join(', ') + ']' + + let dsc = [cfg.description || ''] + + if (cfg.hasOwnProperty('options') && this.#data.describeOptions) { // eslint-disable-line no-prototype-builtins + dsc.push(`Options: ${cfg.options.join(', ')}.`) + } + + if (cfg.hasOwnProperty('allowMultipleValues') && cfg.allowMultipleValues === true && this.#data.describeMultipleValues) { // eslint-disable-line no-prototype-builtins + dsc.push('Can be used multiple times.') + } + + if (cfg.hasOwnProperty('default') && this.#data.describeDefault) { // eslint-disable-line no-prototype-builtins + dsc.push(`(Default: ${cfg.default.toString()})`) + } + + if (cfg.hasOwnProperty('required') && cfg.required === true && this.#data.describeRequired) { // eslint-disable-line no-prototype-builtins + dsc.unshift('Required.') + } + + dsc = dsc.join(' ').trim() + + rows.push(['--' + flag, aliases || '', dsc || '']) + }) + } + + const table = new Table(rows, this.#colAlign, this.#colWidth, this.#tableWidth, [2, 0, usage.length > 0 ? 1 : 0, 0]) + + let subcommands = '\n' + this.subcommands + if (subcommands.trim().length === 0) { + subcommands = '' + } + return usage + (flags.size > 0 ? '\n\nFlags:\n' + table.output : '') + subcommands + } else if (this.#data instanceof Shell) { + return [usage, this.subcommands].join('\n') + } + + return '' + } +} + +export { Formatter as default, Formatter, Table } diff --git a/src/index.js b/src/index.js index 4d70273..4e8cc76 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ import Command from './command.js' import Shell from './shell.js' - -const all = { Shell, Command } -export { Command, Shell, all as default } +import Middleware from './middleware.js' +import { Formatter, Table } from './format.js' +const all = { Shell, Command, Formatter, Table, Middleware } +export { Command, Shell, Formatter, Table, Middleware, all as default } diff --git a/src/middleware.js b/src/middleware.js index 3604e68..934eaf1 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,18 +1,35 @@ -const last = a => a[a.length - 1] -const reduce = a => a.slice(0, -1) - export default class Middleware { - constructor () { this.size = 0 } + constructor () { + Object.defineProperties(this, { + _data: { enumerable: false, configurable: false, value: [] }, + go: { enumerable: false, configurable: false, writable: true, value: (...args) => { args.pop().apply(this, args) } } + }) + } + + get size () { return this._data.length } + + get data () { return this._data } use (method) { - this.size++ - this.run = ((stack) => (...args) => stack(...reduce(args), () => { - const next = last(args) - method.apply(this, [...reduce(args), next.bind.apply(next, [null, ...reduce(args)])]) - }))(this.run) + const methodBody = method.toString() + if (methodBody.indexOf('[native code]') < 0) { + this._data.push(methodBody) + } + + this.go = (stack => (...args) => { + const next = args.pop() + stack(...args, () => { + method.apply(this, [...args, next.bind(null, ...args)]) + }) + })(this.go) } - run (...args) { - last(args).apply(this, reduce(args)) + run () { + const args = Array.from(arguments) + if (args.length === 0 || typeof args[args.length - 1] !== 'function') { + args.push(() => {}) + } + + this.go(...args) } } diff --git a/src/shell.js b/src/shell.js index 0bddb08..c5bad75 100644 --- a/src/shell.js +++ b/src/shell.js @@ -1,227 +1,76 @@ import Command from './command.js' -import Middleware from './middleware.js' +import Base from './base.js' +import { COMMAND_PATTERN } from './utility.js' -const COMMAND_PATTERN = /^(\w+)\s+([\s\S]+)?/i - -export default class Shell { - #processors = new Map() - #commands = new Map() - #middleware = new Middleware() +export default class Shell extends Base { #middlewareGroups = new Map() #history = [] #maxHistoryItems - #name - #description #version - #customHelp = null - #customUsage = null #cursor = 0 - #autohelp = true #tabWidth - #tableWidth - #hasCustomDefaultHandler = false - #runtime = globalThis.hasOwnProperty('window') + #runtime = globalThis.hasOwnProperty('window') // eslint-disable-line no-prototype-builtins ? 'browser' : ( - globalThis.hasOwnProperty('process') - && globalThis.process.release - && globalThis.process.release.name - ? globalThis.process.release.name - : 'unknown' - ) - #defaultHandler = (data, cb) => { - if (data.help && data.help.requested) { - console.log(data.help.message) - } - - cb && cb(data) - } + globalThis.hasOwnProperty('process') && // eslint-disable-line no-prototype-builtins + globalThis.process.release && + globalThis.process.release.name + ? globalThis.process.release.name + : 'unknown' + ) constructor (cfg = { maxhistory: 100 }) { - this.#name = cfg.name || 'unknown' - this.#description = cfg.description || null - this.#version = cfg.version || '1.0.0' - this.#maxHistoryItems = cfg.maxhistory || 100 + super(cfg) - this.#tabWidth = cfg.hasOwnProperty('tabWidth') ? cfg.tabWidth : 4 - this.#tableWidth = cfg.hasOwnProperty('tableWidth') ? cfg.tableWidth : 70 + this.initializeHelpAnnotations(cfg) - if (cfg.hasOwnProperty('autohelp')) { - this.#autohelp = cfg.autohelp - } + this.__commonflags = cfg.commonflags || {} - if (cfg.hasOwnProperty('defaultHandler')) { - this.defaultHandler = cfg.defaultHandler + if (cfg.hasOwnProperty('use') && Array.isArray(cfg.use)) { // eslint-disable-line no-prototype-builtins + cfg.use.forEach(code => this.initializeMiddleware(code)) } - if (Array.isArray(cfg.commands)) { - cfg.commands.forEach(cmd => this.add(cmd)) + if (cfg.hasOwnProperty('trailer') && Array.isArray(cfg.trailer)) { // eslint-disable-line no-prototype-builtins + cfg.trailer.forEach(code => this.initializeTrailer(code)) } - } - - get version () { - return this.#version || 'Unknown' - } - - get name () { - return this.#name || 'Unknown' - } - - get description () { - return this.#description || '' - } - set tableWidth(value) { - this.#tableWidth = value - this.#processors.forEach(cmd => cmd.tableWidth = value) - } - - set tabWidth(value) { - this.#tabWidth = value - this.#processors.forEach(cmd => cmd.tabWidth = value) - } - - get autohelp () { - return this.#autohelp - } + this.#version = cfg.version || '1.0.0' + this.#maxHistoryItems = cfg.maxhistory || cfg.maxHistoryItems || 100 + this.#tabWidth = cfg.hasOwnProperty('tabWidth') ? cfg.tabWidth : 4 // eslint-disable-line no-prototype-builtins - // @private - set defaultHandler(value) { - if (typeof value === 'function') { - this.#defaultHandler = value - this.#hasCustomDefaultHandler = true - this.#processors.forEach(cmd => cmd.defaultProcessor = value) - } else { - throw new Error(`Invalid default method (must be a function, not ${typeof cfg.defaultHandler}).`) - } + // This sets a global symbol that dev tools can find. + globalThis[Symbol('SHELL_INTEGRATIONS')] = this } - // @private - get hasCustomDefaultHandler() { - return this.#hasCustomDefaultHandler - } + get data () { + const commands = super.data - set autohelp (value) { - if (typeof value !== 'boolean') { - return + return { + name: this.name, + description: this.description, + version: this.version, + commands, + use: this.middleware.data, + trailer: this.trailers.data, + help: this.help, + usage: this.usage, + defaultHandler: this.defaultHandler.toString(), + disableHelp: !this.autohelp, + runtime: this.#runtime, + maxHistoryItems: this.#maxHistoryItems } - this.#autohelp = value - this.#commands.forEach(cmd => cmd.autohelp = value) } - get name () { - return this.#name - } - - get description () { - return this.#description || this.usage + get version () { + return this.#version || 'Unknown' } - get usage () { - if (this.#customUsage) { - return typeof this.#customUsage === 'function' ? this.#customUsage(this) : this.#customUsage - } - - return `${this.#name} ${this.#version}\n\n ${this.#description || ''}\n`.trim() + set tableWidth (value) { + this.__width = value } - set usage (value) { - this.#customUsage = value - } - - get help () { - if (this.#customHelp) { - return typeof this.#customHelp === 'function' ? this.#customHelp(this) : this.#customHelp - } - - let mainmsg = [this.usage + '\n'] - - const help = new Map() - - let nameWidth = 0 - let aliasWidth = 0 - let tabWidth = this.#tabWidth - let maxWidth = this.#tableWidth - - this.#processors.forEach(proc => { - nameWidth = proc.name.length > nameWidth ? proc.name.length : nameWidth - aliasWidth = proc.aliases.join(', ').trim().length + proc.aliases.length > aliasWidth ? proc.aliases.join(', ').trim().length + proc.aliases.length : aliasWidth - - let summary = proc.description - - let size = proc.subcommands.size - if (size > 0) { - summary += ` Has ${ size === 1 ? 'an' : size } additional subcommand${ size !== 1 ? 's' : '' }.` - } - - help.set(proc.name, { - description: summary, - aliases: proc.aliases - }) - }) - - help.forEach((data, name) => { - let msg = name - - // Command name - while (msg.length < nameWidth) { - msg += ' ' - } - - // Aliases - let aliases = data.aliases.map(item => `${item}`).join(', ') - while (aliases.length < aliasWidth) { - aliases += ' ' - } - - msg += (aliases.trim().length > 0 ? ' [' + aliases.replace(/^(.*[^\s])/i, '$1]$`') : aliases) + '\t' - - // Desc - let desc = [] - let tabs = msg.match(/\t/gi).length - let descWidth = maxWidth - (nameWidth + aliasWidth + 10) - - if (data.description && data.description.length > descWidth) { - let dsc = new String(data.description) - let match = new RegExp(`(.{0,${descWidth}}[\\s\n])`, 'g') - - desc = data.description.match(match) - desc.push(dsc.replace(desc.join(''), '')) - - while (desc.length > 1 && desc[desc.length - 1].length + desc[desc.length - 2].length < descWidth) { - desc[desc.length - 2] += desc.pop() - } - - desc = desc.reverse().map(item => item.trim()) - } else { - desc.push(data.description) - } - - if (desc.length > 0) { - let prefix = '' - for (let i = 0; i < (nameWidth + aliasWidth + 10 + (tabs * tabWidth)); i++) { - prefix += ' ' - } - - msg += ' : ' + desc.pop() - while (desc.length > 0) { - msg += `\n${prefix}${desc.pop()}` - } - } - - mainmsg.push(' - ' + msg) - }) - - let tab = '' - for (let i = 0; i < tabWidth; i++) { - tab += ' ' - } - - return mainmsg.join('\n') + '\n'.replace(/\n{2,}$/, '\n').replace(/\t/g, tab) - } - - set help (value) { - this.#customHelp = value + get tableWidth () { + return this.__width } history (count = null) { @@ -229,121 +78,105 @@ export default class Shell { return [] } - return this.#history.slice(0, count) + return count === null ? this.#history.slice() : this.#history.slice(0, count) } priorCommand (count = 0) { + if (this.#history.length === 0) { + return null + } + if (count < 0) { - return this.nextCommand(abs(count)) + return this.nextCommand(Math.abs(count)) } + count = count % this.#history.length + this.#cursor += count if (this.#cursor >= this.#history.length) { this.#cursor = this.#history.length - 1 } - return this.#history[this.#cursor] + return this.#history[this.#cursor].input } nextCommand (count = 1) { + if (this.#history.length === 0) { + return null + } + if (count < 0) { - return this.priorCommand(abs(count)) + return this.priorCommand(Math.abs(count)) } + count = count % this.#history.length + this.#cursor -= count if (this.#cursor < 0) { this.#cursor = 0 + return undefined } - return this.#history[this.#cursor] + return this.#history[this.#cursor].input } - add () { - for (let command of arguments) { - if (typeof command === 'object' && !(command instanceof Command)) { - command = new Command(command) - } - - if (!(command instanceof Command)) { - throw new Error('Invalid argument. Only "Command" instances may be added to the processor.') - } - - if (!command.hasCustomDefaultHandler) { - command.defaultHandler = this.#defaultHandler - } - - command.autohelp = this.#autohelp - command.shell = this - - this.#processors.set(command.OID, command) - this.#commands.set(command.name, command.OID) - - command.aliases.forEach(alias => this.#commands.set(alias, command.OID)) + useWith (commands) { + if (arguments.length < 2) { + throw new Error('useWith([\'command\', \'command\'], fn) requires two or more arguments.') } - } - getCommand (name=null) { - if (!name) { - return null - } + commands = typeof commands === 'string' ? commands.split(/\s+/) : commands - let names = name.split(/\s+/i) - let cmd = this.#commands.get(names.shift()) - if (cmd) { - cmd = this.#processors.get(cmd) - for (const nm of names) { - cmd = cmd.getCommand(nm) - } + if (!Array.isArray(commands) || commands.filter(c => typeof c !== 'string').length > 0) { + throw new Error(`The first argument of useWith must be a string or array of strings. Received ${typeof commands}`) } - return cmd instanceof Command ? cmd : null - } - - use () { - for (const arg of arguments) { - if (typeof arg !== 'function') { - throw new Error(`All "use()" arguments must be valid functions.\n${arg.toString().substring(0,50)}${arg.toString().length > 50 ? '...' : ''}`) - } + const fns = Array.from(arguments).slice(1) - this.#middleware.use(arg) - } + commands.forEach(cmd => this.#middlewareGroups.set(cmd.trim(), (this.#middlewareGroups.get(cmd.trim()) || []).concat(fns))) } - useWith (commands) { + useExcept (commands) { if (arguments.length < 2) { - throw new Error('useWith([\'command\', \'command\'], fn) requires two or more arguments.') + throw new Error('useExcept([\'command\', \'command\'], fn) requires two or more arguments.') } commands = typeof commands === 'string' ? commands.split(/\s+/) : commands if (!Array.isArray(commands) || commands.filter(c => typeof c !== 'string').length > 0) { - throw new Error(`The first argument of useWith must be a string or array of strings. Received ${typeof commands}`) + throw new Error(`The first argument of useExcept must be a string or array of strings. Received ${typeof commands}`) } const fns = Array.from(arguments).slice(1) + const all = new Set(this.commandlist.map(i => i.toLowerCase())) - commands.forEach(cmd => this.#middlewareGroups.set(cmd, (this.#middlewareGroups.get(cmd) || []).concat(fns))) - } - - remove () { - for (const cmd of arguments) { - if (typeof cmd === 'symbol') { - this.#processors.delete(cmd) - this.#commands.forEach(oid => oid === cmd && this.#commands.delete(oid)) - } - - if (typeof cmd === 'string') { - const OID = this.#commands.get(cmd) - if (OID) { - this.remove(OID) + commands.forEach(cmd => { + all.delete(cmd) + for (const c of all) { + if (c.indexOf(cmd) === 0) { + all.delete(c) } } - } + }) + + this.useWith(Array.from(all), ...fns) } async exec (input, callback) { - this.#history.shift({ input, time: new Date().toLocaleString()}) + // The array check exists because people are passing process.argv.slice(2) into this + // method, often forgetting to join the values into a string. + if (Array.isArray(input)) { + input = input.map(i => { + if (i.indexOf(' ') >= 0 && !/^[\"\'].+ [\"\']$/.test(i)) { + return `"${i}"` + } else { + return i + } + }).join(' ') + } + + this.#history.unshift({ input, time: new Date().toLocaleString() }) if (this.#history.length > this.#maxHistoryItems) { this.#history.pop() @@ -352,47 +185,61 @@ export default class Shell { let parsed = COMMAND_PATTERN.exec(input + ' ') if (parsed === null) { + if (input.indexOf('version') !== -1 || input.indexOf('-v') !== -1) { + return console.log(this.version) + } else if (input.indexOf('help') !== -1) { + return console.log(this.help) + } + return Command.stderr(this.help) } parsed = parsed.filter(item => item !== undefined) - let cmd = parsed[1] - let args = parsed.length > 2 ? parsed[2] : '' - let command = null + const cmd = parsed[1] + const args = parsed.length > 2 ? parsed[2] : '' + // const command = null - const action = this.#commands.get(cmd) + const action = this.__commands.get(cmd) if (!action) { + if (cmd.toLowerCase() === 'version') { + return console.log(this.version) + } + return Command.stderr(this.help) } - const processor = this.#processors.get(action) + const processor = this.__processors.get(action) if (!processor) { return Command.stderr('Command not found.') } - // Apply command-specific middleware (configured via useWith) - processor.commandroot.replace(new RegExp('^' + this.#name, 'i'), '') - .trim() - .split(/\s+/) - .reduce((cmdpath, name) => { - cmdpath.push(name) - const fns = this.#middlewareGroups.get(cmdpath.join(' ')) - if (fns) { - processor.use(...fns) - } - }, []) + const term = processor.getTerminalCommand(args) + return await Command.reply(await term.command.run(term.arguments, callback)) + } - if (this.#middleware.size === 0) { - return await Command.reply(await processor.run(args, callback)) - } + getCommandMiddleware (cmd) { + const results = [] + cmd.split(/\s+/).forEach((c, i, a) => { + const r = this.#middlewareGroups.get(a.slice(0, i + 1).join(' ')) + r && results.push(r.flat(Infinity)) + }) - arguments[0] = processor.deepParse(args) + return results.flat(Infinity) + } + + clearHistory () { + this.#history = [] + } - return this.#middleware.run( - ...arguments, - async () => await Command.reply(await processor.run(args))) + // Clear the terminal + clear () { + this.#history = [] + console.clear() + // .write('\x1b[0f') // regular clear + // .write('\x1b[2J') // full clear + // .write('\033[0;0f') //ubuntu } } diff --git a/src/utility.js b/src/utility.js new file mode 100644 index 0000000..83fea4e --- /dev/null +++ b/src/utility.js @@ -0,0 +1,24 @@ +// const STRIP_EQUAL_SIGNS = /(\=+)(?=([^'"\\]*(\\.|['"]([^'"\\]*\\.)*[^'"\\]*['"]))*[^'"]*$)/g + +const SUBCOMMAND_PATTERN = /^([^"'][\S\b]+)[\s+]?([^-].*)$/i // eslint-disable-line no-useless-escape +const FLAG_PATTERN = /((?:"[^"\\]*(?:\\[\S\s][^"\\]*)*"|'[^'\\]*(?:\\[\S\s][^'\\]*)*'|\/[^\/\\]*(?:\\[\S\s][^\/\\]*)*\/[gimy]*(?=\s|$)|(?:\\\s|\S))+)(?=\s|$)/g // eslint-disable-line no-useless-escape +const METHOD_PATTERN = /^([\w]+\s?)\(.*\)\s?{/i // eslint-disable-line no-useless-escape +const STRIP_QUOTE_PATTERN = /"([^"\\]*(\\.[^"\\]*)*)"|\'([^\'\\]*(\\.[^\'\\]*)*)\'/ig // eslint-disable-line no-useless-escape +const COMMAND_PATTERN = /^(\w+)\s+([\s\S]+)?/i // eslint-disable-line no-useless-escape +const CONSTANTS = Object.freeze({ + SUBCOMMAND_PATTERN, + FLAG_PATTERN, + METHOD_PATTERN, + STRIP_QUOTE_PATTERN, + COMMAND_PATTERN +}) + +export { + CONSTANTS as default, + CONSTANTS, + SUBCOMMAND_PATTERN, + FLAG_PATTERN, + METHOD_PATTERN, + STRIP_QUOTE_PATTERN, + COMMAND_PATTERN +} diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index 0410f26..0000000 --- a/test/.eslintrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": [ - "plugin:compat/recommended" - ], - "env": { - "browser": true, - "es6": true - }, - "parserOptions": { - "sourceType": "module", - "ecmaVersion": 2019 - } -} diff --git a/test/assets/index.html b/test/assets/index.html deleted file mode 100644 index 76a4be3..0000000 --- a/test/assets/index.html +++ /dev/null @@ -1,358 +0,0 @@ - - - {{NAMESPACE}} Manual Testing - - - - -
-
-
- -
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/test/package.json b/test/package.json deleted file mode 100644 index 0fbd273..0000000 --- a/test/package.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "description": "Test Suite", - "main": "index.js", - "scripts": { - "start": "npm test", - "test": "npm run node && npm run browser", - "node": "npm run build:test:node && tap --no-esm --no-coverage -r source-map-support/register -R spec ./unit/*-*/**/*-*.js", - "browser": "npm run build:test:browser && node ./unit/prepareKarmaBrowserSuite.js && karma start ./unit/karma.conf.cjs", - "build:test:node": "cd ../build && npm run test:node && cd ../test", - "build:test:browser": "cd ../build && npm run test:browser && cd ../test", - "browser:manual": "npm run clean && npm run build:test:browser && node ./unit/prepareKarmaBrowserSuite.js && node ./unit/prepareManualTestEnvironment.js && cd .testsuite", - "browser:manual:open": "npm run browser:manual && cd .testsuite && fenix open", - "clean": "node ../build/lib/reset.js", - "syntax": "./syntax/validator.js --source ../src/**/*.js --config ./package.json --parser babel-eslint --verbose | snazzy", - "compat": "echo 'Building...' && cd ../build && npm run browser && echo 'Testing modern browser compatibility...' && cd ../test && BROWSERSLIST_ENV=current BROWSERSLIST_CONFIG=../build/.browserslistrc eslint -c ./.eslintrc ../.dist/browser/browser-*/*.min.js --no-ignore && echo Done." - }, - "author": "", - "type": "module", - "engines": { - "node": ">=13.5.0" - }, - "dependencies": { - "@author.io/arg": "^1.1.0", - "@babel/core": "^7.7.5", - "@babel/preset-env": "^7.7.7", - "@rollup/plugin-multi-entry": "^3.0.0", - "babel-eslint": "^10.0.3", - "browserify": "^16.5.0", - "diff": "^4.0.2", - "eslint": "^6.8.0", - "eslint-plugin-compat": "^3.3.0", - "karma": "^4.4.1", - "karma-babel-preprocessor": "^8.0.1", - "karma-browserify": "^6.1.0", - "karma-chrome-launcher": "^3.1.0", - "karma-edgium-launcher": "^4.0.0-0", - "karma-firefox-launcher": "^1.2.0", - "karma-ie-launcher": "^1.0.0", - "karma-safari-launcher": "^1.0.0", - "karma-sauce-launcher": "^2.0.2", - "karma-source-map-support": "^1.4.0", - "karma-sourcemap-loader": "^0.3.7", - "karma-spec-reporter": "0.0.32", - "karma-tap": "^4.1.4", - "karma-tap-pretty-reporter": "^4.1.0", - "magic-string": "^0.25.6", - "snazzy": "^8.0.0", - "source-map-support": "^0.5.16", - "standard": "14.3.1", - "tap": "14.10.5", - "tap-spec": "^5.0.0", - "tape": "4.12.0" - }, - "standard": { - "parser": "babel-eslint", - "ignore": [ - "_*", - "_**/*", - ".**/*", - "node_modules", - "karma.conf.js", - "karma.conf.cjs", - "build.js" - ], - "globals": [ - "window", - "global", - "MutationObserver", - "DOMParser", - "XMLHttpRequest", - "FormData", - "btoa", - "localStorage", - "Request", - "Response", - "Headers", - "fetch", - "Element", - "HTMLElement", - "NodeFilter" - ] - }, - "nyc": {} -} diff --git a/test/syntax/validator.js b/test/syntax/validator.js deleted file mode 100755 index ee8d642..0000000 --- a/test/syntax/validator.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node --experimental-modules -import fs from 'fs' -import path from 'path' -import Args from '@author.io/arg' -import standard from 'standard' - -// Configure argument parser -Args.configure({ - source: { - default: path.join(process.cwd(), '../unit/**/*.js') - }, - config: { - default: path.join(process.cwd(), '../package.json') - }, - parser: { - default: '' - }, - verbose: { - default: false - } -}) - -Args.enforceRules() - -// Normalize the source path and identify which linter to use -let scanpath = Args.value('source') -let scanFile = false - -if (scanpath.indexOf('*') === -1) { - const stat = fs.statSync(scanpath) - if (stat.isDirectory()) { - scanpath = path.join(scanpath, '../unit/**/*.js') - } else if (!stat.isFile) { - throw new Error(`Cannot find or process "${scanpath}"`) - } else { - scanFile = true - } -} - -const pkg = JSON.parse(fs.readFileSync(Args.value('config')).toString()) -const opts = { - cwd: path.resolve(Args.value('source')), - ignore: (pkg.standard ? pkg.standard.ignore : []) || [], // file globs to ignore (has sane defaults) - globals: (pkg.standard ? pkg.standard.globals : []) || [], // global variables to declare - // plugins: (pkg.standard ? pkg.standard.plugins : []) || [], // eslint plugins - parser: Args.value('parser') -} - -// Custom standardjs output -const output = (err, result) => { - if (err) { - process.stderr.write(err) - } - - if (result) { - if (!result.errorCount && !result.warningCount) { - process.exitCode = 0 - return - } - - // Are any fixable rules present? - var isFixable = result.results.some(function (result) { - return result.messages.some(function (message) { - return !!message.fix - }) - }) - - if (isFixable) { - console.error( - '%s: %s', - opts.cmd, - 'Run `' + opts.cmd + ' --fix` to automatically fix some problems.' - ) - } - - result.results.forEach(function (result) { - result.messages.forEach(function (message) { - console.log( - ' %s:%d:%d: %s%s', - result.filePath, message.line || 0, message.column || 0, message.message, - Args.value('verbose') ? ' (' + message.ruleId + ')' : '' - ) - }) - }) - - process.exitCode = result.errorCount ? 1 : 0 - } -} - -if (scanFile) { - standard.lintText(fs.readFileSync(scanpath).toString(), opts, output) -} else { - standard.lintFiles(scanpath, opts, output) -} diff --git a/test/unit/01-sanity/01-sanity.js b/test/unit/01-sanity/01-sanity.js deleted file mode 100644 index 418f2ee..0000000 --- a/test/unit/01-sanity/01-sanity.js +++ /dev/null @@ -1,50 +0,0 @@ -import 'source-map-support/register.js' -import test from 'tape' -import { Command, Shell } from '../../.node/index.js' - -test('Sanity Check - Shell', t => { - const shell = new Shell({ - name: 'test' - }) - - t.ok(shell instanceof Shell, 'Basic shell instantiates correctly.') - - t.end() -}) - -test('Sanity Check - Command', t => { - const mirror = new Command({ - name: 'mirror', - description: 'Search metadoc for all the things.', - alias: 'search', - handler: (input, cb) => { - console.log(`Mirroring input: ${input}`) - - cb && cb() - } - }) - - t.ok(mirror instanceof Command, 'Command initialized successfully.') - - const CLI = new Shell({ - name: 'test', - description: 'test', - commands: [ - mirror, - { - name: 'test', - description: 'Test command which has no custom handler.' - } - ] - }) - - t.ok(CLI instanceof Shell, 'Shell initialized with commands successfully.') - - let defaultHandlerFires = false - - CLI.exec('test', data => defaultHandlerFires = true) - - t.ok(defaultHandlerFires, 'Default handler fires.') - - t.end() -}) diff --git a/test/unit/karma.conf.cjs b/test/unit/karma.conf.cjs deleted file mode 100644 index dc7c5fa..0000000 --- a/test/unit/karma.conf.cjs +++ /dev/null @@ -1,189 +0,0 @@ -// Karma configuration -const fs = require('fs') -const config = JSON.parse(fs.readFileSync('../build/config.json')) -const pkg = JSON.parse(fs.readFileSync('../package.json')) -const BrowsersList = require('../../build/node_modules/browserslist') -// const babelify = require('babelify') - -let local = !process.env.hasOwnProperty('CI') -if (process.env.hasOwnProperty('LOCAL')) { - if (process.env.LOCAL.trim().toLowerCase() === 'false') { - local = false - } -} - -if (process.env.hasOwnProperty('SAUCE_USERNAME') && process.env.hasOwnProperty('SAUCE_ACCESS_KEY')) { - local = false -} - -let browsers = {} -if (!local) { - const opts = {} - if (process.env.BROWSERSLIST_CONFIG) { - opts.path = process.env.BROWSERSLIST_CONFIG - } - - if (process.env.BROWSERSLIST_ENV) { - opts.env = process.env.BROWSERSLIST_ENV - } - - BrowsersList(null, opts) - .forEach(item => { - if (item.indexOf('-')) { - let browser = item.split('-').shift().split(' ') - let version = /([0-9]+\.?([0-9]+)?)/.exec(browser[1]) - - if (version && browser[0].trim().toLowerCase() !== 'samsung') { - let label = `sl_${browser[0]}_${browser[1]}`.toLowerCase() - browsers[label] = { - base: 'SauceLabs', - browserName: browser[0], - version: version[1] - } - - if (browser[0].indexOf('_') > 0) { - browser = browser[0].split('_') - switch (browser[0].trim().toLowerCase()) { - case 'ios': - browsers[label].platformName = 'iOS' - break - - case 'and': - case 'android': - browsers[label].platformName = 'Android' - break - } - browser[0] = browser[1] - } - - switch (browser[0].toLowerCase()) { - case 'edge': - browsers[label].browserName = 'MicrosoftEdge' - if (browser[1] === '17') { - browsers[label].version = '17.17134' - } - break - case 'saf': - browsers[label].browserName = 'Safari' - break - case 'chr': - browsers[label].browserName = 'Chrome' - break - case 'uc': - // Unsupported in SauceLabs - delete browsers[label] - } - - if (browsers[label] && !browsers[label].platformName) { - browsers[label].platformName = 'macOS 10.14' - } - } - } - }) -} - -// console.log(browsers) -// process.exit(0) - -for (const browser in browsers) { - console.log(`Queued tests for ${browsers[browser].browserName} ${browsers[browser].version} on ${browsers[browser].platformName || 'default OS'}.`) -} - -if (!local) { - console.log('Testing remotely.') -} - -module.exports = function (config) { - config.set({ - // browserDisconnectTimeout: 10000, - // browserDisconnectTolerance: 1, - // browserNoActivityTimeout: 10000, - processKillTimeout: 30000, - - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', - - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['browserify', 'source-map-support', 'tap'], - - browserify: { - debug: true - // transform: babelify.configure({ - // presets: ['@babel/preset-env'] - // }) - }, - - // list of files / patterns to load in the browser - files: [ - { pattern: '../.browser/*.js', included: true, served: true, type: 'module' }, - { pattern: '../.browser/*.js.map', included: false, served: true, type: 'js' }, - '../.testsuite/browser-test.js' - // { pattern: '.testsuite/*.js', included: true, nocache: true } - ], - - // list of files to exclude - exclude: [ - '../.browser/*-global.*', - '../.browser/*-es*.*' - ], - - // preprocess matching files before serving them to the browser - // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - '../.browser/*.min.js': ['sourcemap'], - '../.testsuite/browser-test.js': ['browserify'] - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['tap-pretty', 'saucelabs'], - - tapReporter: { - prettify: require('tap-spec'), // default 'standard TAP' output - separator: '****************************' - }, - // specReporter: { - // maxLogLines: 15, // limit number of lines logged per test - // suppressErrorSummary: false, // do not print error summary - // suppressFailed: false, // do not print information about failed tests - // suppressPassed: false, // do not print information about passed tests - // suppressSkipped: true, // do not print information about skipped tests - // showSpecTiming: false, // print the time elapsed for each spec - // failFast: true - // }, - - // web server port - port: 9876, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_ERROR, - - // enable / disable watching file and executing tests whenever any file changes - // autoWatch: true, - - // start these browsers - // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: local ? ['Chrome'] : Object.keys(browsers), - customLaunchers: local ? null : browsers, - - sauceLabs: { - testName: pkg.name - }, - - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: true, - // autoWatch: true, - - // Concurrency level - // how many browser should be started simultanous - concurrency: 1 - // concurrency: Infinity - }) -} diff --git a/test/unit/prepareKarmaBrowserSuite.js b/test/unit/prepareKarmaBrowserSuite.js deleted file mode 100644 index a245ed6..0000000 --- a/test/unit/prepareKarmaBrowserSuite.js +++ /dev/null @@ -1,89 +0,0 @@ -import fs from 'fs' -import path from 'path' -// Rollup is used from the build package instead of requiring 2 copies of rollup to be installed in node_modules. -import multi from '../../build/node_modules/@rollup/plugin-multi-entry/dist/index.js' -import rollup from '../../build/node_modules/rollup/dist/rollup.js' -import MagicString from 'magic-string' -import diff from 'diff' - -const config = JSON.parse(fs.readFileSync('../build/config.json')) -const pkg = JSON.parse(fs.readFileSync('../package.json')) -const testpkg = JSON.parse(fs.readFileSync('./package.json')) -const file = path.resolve(path.join(config.testOutput, '.testsuite', 'browser-test.js')) - -fs.rmdirSync(path.dirname(file), { recursive: true }) -fs.mkdirSync(path.dirname(file)) - -const outputOptions = { - file, - format: 'cjs' -} - -const external = config.external -external.push('.node/index.js') -external.push('./.node/index.js') -external.push('../.node/index.js') -external.push('../../.node/index.js') -external.push('tape') - -const matcher = /import\s+(?:.*as\s+)?(\{.*\}|[\S]+)\s.*from\s+['"].*\/\.node\/index\.js['"]/ -const tests = new Set(process.argv.filter((val, i, args) => i > 0 && /-+test/i.test(args[i - 1])).map(i => i.toLowerCase())) -const input = tests.size === 0 ? ['./unit/0*-*/*-*.js'] : Array.from(tests) - -export default function importExtractor() { - return { - name: 'import-extractor', // this name will show up in warnings and errors - load (id) { - if (fs.existsSync(id)) { - console.log(`Including ${id}`) - const input = fs.readFileSync(id).toString() - const ms = new MagicString(input) - const output = input - .replace(/(import\s+.*\s+['"].*\/)\.node\/index\.js(['"])/gi, `$1.browser/${pkg.name.split('/').pop()}-${pkg.version}.min.js$2`) - .replace(/import\s+['"]source-map-support\/register.*/gi, '') // Not supported by browserify - - const changes = diff.diffChars(input, output) - if (changes && changes.length > 0) { - let idx = 0 - - changes.forEach(part => { - if (part.added) { - ms.prependLeft(idx, part.value) - idx -= part.count - } else if (part.removed) { - ms.remove(idx, idx + part.count) - } - - idx += part.count - }) - } - - return { code: output, map: ms.generateMap({ hires: true })} - } - return null - } - // , buildEnd () {} - }; -} - -async function build () { - const bundle = await rollup.rollup({ - input, - external, - plugins: [ - importExtractor(), - multi(), - ] - }) - - // const { output } = await bundle.generate(outputOptions) - - await bundle.write(outputOptions) - - // The setTimeout is used to force Karma to wait for the code to be loaded by the prep script. - // const content = fs.readFileSync(file).toString() - // fs.writeFileSync(file, `setTimeout(function () {\n${content}\n}, 600)`) - // console.log(`Wrote "${file}" to disk.`) -} - -build() diff --git a/test/unit/prepareManualTestEnvironment.js b/test/unit/prepareManualTestEnvironment.js deleted file mode 100644 index 46344aa..0000000 --- a/test/unit/prepareManualTestEnvironment.js +++ /dev/null @@ -1,73 +0,0 @@ -import path from 'path' -import fs from 'fs' -import Build from '../../build/lib/build.js' -import browserify from 'browserify' -import { execSync } from 'child_process' - -const build = new Build() -const pkg = JSON.parse(fs.readFileSync('../package.json').toString()) -const name = pkg.name.split('/').pop() -const sources = fs.readdirSync(path.resolve('./.browser')).filter(i => path.extname(i).split('.').pop() === 'js') -const options = sources.map(src => ``) -options.sort((a, b) => { - if (a.replace(/[^\S0-9]/gi, '') > b.replace(/[^\S0-9]/gi, '')) { - return -1 - } else { - return 1 - } -}) - -if (pkg.main) { - options.push(``) -} - -const content = fs.readFileSync('./assets/index.html') - .toString() - .replace(/\{\{script\}\}/g, `./${name}.min.js`) - .replace(/\{\{NAMESPACE\}\}/g, name) - .replace(/\{\{OPTIONS\}\}/gi, options.join('\n ')) - -if (!fs.existsSync(path.resolve('./.testsuite'))) { - fs.mkdirSync(path.resolve('./.testsuite'), { recursive: true }) -} - -fs.writeFileSync('./.testsuite/index.html', content) -sources.forEach(jsfile => { - fs.copyFileSync(`./.browser/${jsfile}`, `./.testsuite/${jsfile}`) - fs.copyFileSync(`./.browser/${jsfile}.map`, `./.testsuite/${jsfile}.map`) -}) - -const cwd = process.cwd() -const out = path.join(process.cwd(), '.testsuite') -build.walk('../src').forEach(file => { - const input = path.resolve(file) - const output = path.dirname(input).replace(path.join(cwd, '..'), out) - if (fs.statSync(input).isFile() && !fs.existsSync(output)) { - fs.mkdirSync(output) - } - - const content = fs.readFileSync(input) - .toString() - .replace(/([\t ]*\/\* ?node-only ?\*\/)[\s\S]*?(\/\* ?end-node-only ?\*\/[\t ]*\n?)/gim, '') - // .replace(/<#(\s+)?REPLACE_VERSION(\s+)?#>/gi, pkg.version) - - fs.writeFileSync(input.replace(path.join(cwd, '..'), out), content) -}) - -// Add the libraries from the test suite -if (fs.existsSync(path.resolve('./.testsuite/browser-test.js'))) { - const content = fs.readFileSync('./.testsuite/browser-test.js').toString() - const importStatements = content.match(/_interop[\S]+\(require\(['"]([\S]+)['"]/g) - const names = new Set() - importStatements.forEach(i => i.match(/_interop[\S]+\(require\(['"]([\S]+)['"]/).slice(1).forEach(name => names.add(name))) - - if (names.size > 0) { - // TODO: Use the browserify API to bundle - // const bundle = fs.createWriteStream(path.resolve('./.testsuite/bundle.js')) - const cmd = `./node_modules/browserify/bin/cmd.js -r ${Array.from(names).join('-r ')} > "${path.resolve('./.testsuite/bundle.js')}"` - execSync(cmd, { stdio: 'inherit' }) - // console.log(bundle) - // browserify(Array.from(names)).bundle().on('data', chunk => bundle.write(chunk)).on('end', () => console.log('done')) - console.log(`Generated browser references for: ${Array.from(names).join(', ')}.`) - } -} diff --git a/tests/01-sanity.js b/tests/01-sanity.js new file mode 100644 index 0000000..000506e --- /dev/null +++ b/tests/01-sanity.js @@ -0,0 +1,250 @@ +import test from 'tappedout' +import { Command, Shell, Formatter } from '@author.io/shell' +// import fs from 'fs' + +test('Sanity Check - Shell', t => { + const shell = new Shell({ + name: 'test' + }) + + t.ok(shell instanceof Shell, 'Basic shell instantiates correctly.') + t.ok(Object.getOwnPropertySymbols(globalThis).filter(s => globalThis[s] instanceof Shell).length === 1, 'The shell is discoverable.') + t.end() +}) + +test('Sanity Check - Command', t => { + const mirror = new Command({ + name: 'mirror', + description: 'Search metadoc for all the things.', + alias: 'search', + handler: (input, cb) => { + console.log(`Mirroring input: ${input}`) + + cb && cb() + } + }) + + t.ok(mirror instanceof Command, 'Command initialized successfully.') + + const CLI = new Shell({ + name: 'test', + description: 'test', + commands: [ + mirror, + { + name: 'test', + description: 'Test command which has no custom handler.' + } + ] + }) + + t.ok(CLI instanceof Shell, 'Shell initialized with commands successfully.') + + CLI.exec('test').then(data => { + t.pass('Default handler fires.') + t.end() + }).catch(e => { + t.fail(e.message) + t.end() + }) +}) + +test('Output Formatting', t => { + const shell = new Shell({ + name: 'test' + }) + + const cmd = new Command({ + name: 'cmd', + alias: 'c', + flags: { + test: { + alias: 't', + description: 'test description' + }, + more: { + aliases: ['m', 'mr'], + description: 'This is a longer description that should break onto more than one line, or perhaps even more than one extra line with especially poor grammar and spellling.' + }, + none: { + description: 'Ignore me. I do not exist.' + } + } + }) + + shell.add(cmd) + + const formatter = new Formatter(cmd) + formatter.width = 80 + + t.ok(formatter instanceof Formatter, 'Basic formatter instantiates correctly.') +// fs.writeFileSync('./test.txt', Buffer.from(formatter.help)) +// console.log(formatter.help) +// t.expect(`test cmd|c [FLAGS] + +// Flags: + +// --test [-t] test description +// --more [-m, -mr] This is a longer description that should break onto +// more than one line, or perhaps even more than one +// extra line with especially poor grammar and +// spellling. +// --none Ignore me. I do not exist. `, +// formatter.help, +// 'Correctly generated default help message.') + + t.expect(`test cmd|c [FLAGS] + +Flags: + + --test [-t] test description + --more [-m, -mr] This is a longer description that should break onto + more than one line, or perhaps even more than one + extra line with especially poor grammar and + spellling. + --none Ignore me. I do not exist. `.replace(/\s+|\t+|\n+/gi, ''), + formatter.help.replace(/\s+|\t+|\n+/gi, ''), + 'Correctly generated default help message.') + + t.end() +}) + +test('Subcommand Config', t => { + let ok = false + const cfg = { + name: 'account', + description: 'Perform operations on a user account.', + handler (meta, cb) { + console.log('TODO: Output account details') + }, + commands: [ + { + name: 'create', + description: 'Create a user account.', + arguments: '', + flags: { + name: { + alias: 'n', + description: 'Account display name' + }, + phone: { + alias: 'p', + description: 'Account phone number' + }, + avatar: { + alias: 'a', + description: 'Account avatar image URL' + } + }, + + handler (meta, cb) { + ok = true + } + } + ] + } + + const shell = new Shell({ + name: 'test', + commands: [cfg] + }) + + shell.exec('account create').then(r => { + t.ok(ok, 'Configuring subcommands does not throw an error') + }).catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Default command help (regression test)', t => { + const shell = new Shell({ + name: 'test', + version: '1.0.0', + disableHelp: true, + commands: [ + { + name: 'account', + description: 'Perform operations on a user account.', + handler: (meta, cb) => {}, + commands: [ + { + name: 'create', + description: 'Create a user account.', + arguments: '' + } + ] + } + ] + }) + + shell.exec('account') + + t.pass('Ran without error.') + t.end() +}) + +test('Basic Introspection', t => { + const shell = new Shell({ + name: 'test', + version: '1.0.0', + disableHelp: true, + commands: [ + { + name: 'account', + description: 'Perform operations on a user account.', + handler: (meta, cb) => { }, + commands: [ + { + name: 'create', + description: 'Create a user account.', + arguments: '' + } + ] + } + ] + }) + + t.ok(typeof shell.data === 'object', 'Generates a data object representing the shell.') + t.end() +}) + +test('Flag Default Configuration', t => { + const cmd = new Command({ + name: 'cmd', + alias: 'c', + flags: { + test: { + alias: 't', + description: 'test description' + }, + more: { + aliases: ['m', 'mr'], + description: 'This is a longer description that should break onto more than one line, or perhaps even more than one extra line with especially poor grammar and spellling.' + }, + none: { + description: 'Ignore me. I do not exist.' + } + } + }) + + let f = cmd.getFlagConfiguration('test') + t.ok( + f.aliases[0] === 't' && + f.description === 'test description' && + f.required === false && + f.type === 'string' && + f.options === null, + 'Returned default configuration items for a named flag.' + ) + + f = cmd.getFlagConfiguration('t') + t.ok( + f.aliases[0] === 't' && + f.description === 'test description' && + f.required === false && + f.type === 'string' && + f.options === null, + 'Returned default configuration items for an alias of a flag.' + ) + + t.end() +}) diff --git a/tests/02-middleware.js b/tests/02-middleware.js new file mode 100644 index 0000000..de92309 --- /dev/null +++ b/tests/02-middleware.js @@ -0,0 +1,253 @@ +import test from 'tappedout' +import { Shell, Middleware } from '@author.io/shell' + +test('Sync Middleware', t => { + const mw = new Middleware() + + mw.use((m, next) => { count++; next() }) + mw.use((m, next) => { count++; next() }) + mw.use((m, next) => { count++; next() }) + + let count = 0 + mw.run({ test: true }, () => { + t.ok(count === 3, `Ran 3 middleware operations. Recognized ${count}.`) + t.end() + }) +}) + +test('Async Middleware w/ Final Method', t => { + const mw = new Middleware() + + mw.use((m, next) => { count++; next() }) + mw.use((m, next) => setTimeout(() => { count++; next() }, 10)) + mw.use((m, next) => { count++; next() }) + + let count = 0 + mw.run({ test: true }, () => { + count++ + t.ok(count === mw.size + 1, `Ran ${mw.size + 1} total middleware operations (w/ final op) with one async operation. Recognized ${count}.`) + t.end() + }) +}) + +test('Async Middleware w/o Final Method', t => { + const mw = new Middleware() + + mw.use(next => { count++; next() }) + mw.use(next => { + setTimeout(() => { count++; next() }, 10) + }) + mw.use(next => { count++; next() }) + + let count = 0 + + mw.run() + setTimeout(() => { + t.ok(count === mw.size, `Ran ${mw.size} total middleware operations (w/o final op) with one async operation. Recognized ${count}.`) + t.end() + }, 300) +}) + +test('Basic Shell & Command Middleware', t => { + let ok = false + + const shell = new Shell({ + name: 'test', + commands: [{ + name: 'a', + handler () {}, + commands: [{ + name: 'b', + use: [(meta, next) => { count++; next() }], + handler () {}, + commands: [{ + name: 'c', + handler () { + ok = true + } + }] + }] + }] + }) + + shell.use((meta, next) => { count++; next() }) + + let count = 0 + shell.exec('a b c') + + t.ok(count === 2, `Expected 2 middleware operations to run. Recognized ${count}.`) + t.ok(ok, 'Handler executes at the end.') + t.end() +}) + +test('Command Specific Middleware', t => { + let ok = false + + const shell = new Shell({ + name: 'test', + commands: [{ + name: 'a', + handler () { }, + commands: [{ + name: 'b', + use: [(meta, next) => { count++; next() }], + handler () { }, + commands: [{ + name: 'c', + handler () { + ok = true + } + }] + }] + }] + }) + + shell.useWith(['a b'], (meta, next) => { count++; next() }) + + let count = 0 + shell.exec('a b c').then(r => { + t.ok(count === 2, `Expected 2 middleware operations to run. Recognized ${count}.`) + t.ok(ok, 'Handler executes at the end.') + t.end() + }) +}) + +test('Command Specific Middleware Exceptions', async t => { + let ok = false + + const shell = new Shell({ + name: 'test', + commands: [{ + name: 'a', + handler () { }, + commands: [{ + name: 'f', + handler () { } + }] + }, { + name: 'b', + handler () { }, + commands: [{ + name: 'e', + handler () {} + }] + }, { + name: 'c', + handler () { } + }, { + name: 'd', + handler () { + ok = true + } + }] + }) + + shell.useExcept(['b', 'c'], (meta, next) => { count++; next() }) + + let count = 0 + await shell.exec('a').catch(t.fail) + await shell.exec('b').catch(t.fail) + await shell.exec('c').catch(t.fail) + await shell.exec('d').catch(t.fail) + await shell.exec('b e').catch(t.fail) + await shell.exec('a f').catch(t.fail) + + t.ok(count === 3, `Expected 3 middleware operations to run. Recognized ${count}.`) + t.ok(ok, 'Handler executes at the end.') + t.end() +}) + +test('Basic Trailers', t => { + let ok = false + let after = 0 + + const shell = new Shell({ + name: 'test', + commands: [{ + name: 'a', + handler () { }, + commands: [{ + name: 'b', + use: [(meta, next) => { next() }], + handler () { }, + commands: [{ + name: 'c', + handler () { + ok = true + }, + trailer: [ + (meta, next) => { after++; next() }, + meta => { + t.ok(after === 1, `Expected 1 trailer to run. Recognized ${after}.`) + t.pass('Ran trailer.') + t.end() + } + ] + }] + }] + }] + }) + + shell.exec('a b c') +}) + +// test('Regression Test: Middleware Duplication', t => { +// let count = 0 + +// const shell = new Shell({ +// name: 'Metadoc CLI', +// version: '1.0.0', +// use: [ +// (meta, next) => { +// console.log('A') +// count++ +// next() +// } +// ], +// commands: [ +// { +// name: 'account', +// description: 'Perform operations on a user account.', +// handler: (meta, cb) => { }, +// commands: [ +// { +// name: 'create', +// description: 'Create a user account.', +// arguments: '', +// use: [(d, next) => { +// console.log('C') +// count++ +// next() +// }], +// commands: [ +// { +// name: 'bleh', +// handler () { }, +// commands: [{ +// name: 'more', +// handler (meta) { } +// }] +// } +// ], +// handler: (meta, cb) => { } +// } +// ] +// } +// ] +// }) + +// shell.use((meta, next) => { +// setTimeout(() => { +// console.log('B') +// count++ +// next() +// }, 10) +// }) + +// shell.exec('account create bleh').then(r => { +// setTimeout(() => { +// t.ok(count === 3, `Ran each middleware operation once. Expected 3 operations, recognized ${count}.`) +// }, 300) +// }).catch(e => t.fail(e.message)) +// .finally(() => t.end()) +// }) diff --git a/tests/03-help-defaults.js b/tests/03-help-defaults.js new file mode 100644 index 0000000..e5ab4e9 --- /dev/null +++ b/tests/03-help-defaults.js @@ -0,0 +1,99 @@ +import test from 'tappedout' +import { Shell, Middleware } from '@author.io/shell' + +// import fs from 'fs' + +let msg = '' +const cfg = () => { + return { + name: 'mycli', + description: 'My command line.', + commands: [{ + name: 'a', + description: 'A fine letter.', + handler () { }, + commands: [{ + name: 'b', + description: 'Beautiful commands.', + handler () { }, + commands: [{ + name: 'c', + description: 'Choices are great.', + handler (meta) { + msg = meta.command.help + // msg = meta.help.message + } + }] + }] + }] + } +} + +test('Request help via flag', t => { + const c = cfg() + const shell = new Shell(c) + + shell.exec('a b c --help') + .then(r => { + // fs.writeFileSync('./test.txt', Buffer.from(msg)) + t.ok(shell.getCommand('a b c').help === `mycli a b c + + Choices are great.`, 'Displayed correct default help message') + }) + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Default help when no handler exists', t => { + const c = cfg() + delete c.commands[0].commands[0].commands[0].handler + const shell = new Shell(c) + + shell.exec('a b c') + .then(r => { + t.ok(shell.getCommand('a b c').help === `mycli a b c + + Choices are great.`, 'Displayed correct default help message when no handler exists') + }) + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Display custom help when defined', t => { + const c = cfg() + c.commands[0].commands[0].commands[0].help = () => 'custom help is awesome' + const shell = new Shell(c) + + shell.exec('a b c') + .then(r => { + t.ok(msg === 'custom help is awesome', 'Displayed custom help message') + }) + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Disabled help', t => { + const c = cfg() + c.disableHelp = true + const shell = new Shell(c) + + shell.exec('a b c --help') + .then(r => { + // fs.writeFileSync('./test.txt', Buffer.from(msg)) + t.ok(shell.getCommand('a b c').help.trim() === '', `Expected no help message. Received "${msg}"`) + }) + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Display custom help when defined', t => { + const c = cfg() + const shell = new Shell(c) + + shell.exec('--help') + .then(r => { + t.pass('Passing --help directly to shell does not fail.') + }) + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) diff --git a/tests/04-metadata.js b/tests/04-metadata.js new file mode 100644 index 0000000..925ec13 --- /dev/null +++ b/tests/04-metadata.js @@ -0,0 +1,161 @@ +import test from 'tappedout' +import { Shell } from '@author.io/shell' + +test('Map unnamed argument', t => { + const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email', + handler (meta) { + t.ok(meta.data.hasOwnProperty('email'), 'Automapped data exists.') + t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) + } + }] + }) + + shell.exec('create me@domain.com') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Map unnamed arguments', t => { + const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email displayName', + handler (meta) { + t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') + t.expect('me@domain.com', meta.data.email, 'Recognized email') + t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName data exists.') + console.log(meta.data) + t.expect('John Doe', meta.data.displayName, 'Recognized displayName') + } + }] + }) + + shell.exec('create me@domain.com "John Doe"') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Map extra unnamed arguments as unknown', t => { + const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email displayName', + handler (meta) { + t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') + t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) + t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName data exists.') + t.ok(meta.data.displayName === 'John Doe', `Attribute named displayName expected a value of "John Doe". Received "${meta.data.displayName}".`) + t.ok(meta.data.hasOwnProperty('unknown1'), 'Automapped unknown property to generic name.') + t.ok(meta.data.unknown1 === 'test1', `Unknown attribute expected a value of "test1". Received "${meta.data.unknown1}".`) + t.ok(meta.data.hasOwnProperty('unknown2'), 'Automapped extra unknown property to generic name.') + t.ok(meta.data.unknown2 === 'test2', `Extra unknown attribute expected a value of "test2". Received "${meta.data.unknown2}".`) + } + }] + }) + + shell.exec('create me@domain.com "John Doe" test1 test2') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Map unnamed/unsupplied arguments as undefined', t => { + const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + arguments: 'email displayName', + handler (meta) { + t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') + t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) + t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName attribute exists.') + t.ok(meta.data.displayName === undefined, `Attribute named displayName expected a value of "undefined". Received "${meta.data.displayName}".`) + } + }] + }) + + shell.exec('create me@domain.com') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Map unnamed arguments when duplicate names are supplied', t => { + const shell = new Shell({ + name: 'account', + commands: [{ + name: 'create', + flags: { + email: { + alias: 'e' + } + }, + arguments: 'email displayName', + handler (meta) { + t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') + t.ok(Array.isArray(meta.data.email) && meta.data.email[1] === 'me@domain.com' && meta.data.email[0] === 'bob@other.com', `Attribute named email expected a value of "['me@domain.com', 'bob@other.com']". Received "[${meta.data.email.map(i => '\'' + i + '\'').reverse().join(', ')}]".`) + t.ok(meta.data.displayName === undefined, `Attribute named displayName expected a value of "undefined". Received "${meta.data.displayName}".`) + } + }] + }) + + shell.exec('create me@domain.com -e bob@other.com') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Ordered Named Arguments', t => { + const shell = new Shell({ + name: 'cli', + commands: [{ + name: 'account', + description: 'Perform operations on a user account.', + + handler (meta, cb) { + cb() + }, + + commands: [ + { + name: 'create', + description: 'Create a user account.', + arguments: ['email', 'password'], + + flags: { + name: { + alias: 'n', + description: 'Account display name' + }, + + phone: { + alias: 'p', + description: 'Account phone number' + }, + + avatar: { + alias: 'a', + description: 'Account avatar image URL' + }, + + validate: { + alias: 'v', + description: 'Validate email' + } + }, + + handler (meta, cb) { + t.ok(meta.flag('email') === 'test@domain.com', 'Correctly identifies first name argument') + t.ok(meta.flag('password') === 'pwd', 'Correct identifies second name argument') + t.end() + } + } + ] + }] + }) + + shell.exec('account create test@domain.com pwd') +}) diff --git a/tests/05-commonflags.js b/tests/05-commonflags.js new file mode 100644 index 0000000..6d199bb --- /dev/null +++ b/tests/05-commonflags.js @@ -0,0 +1,138 @@ +import test from 'tappedout' +import { Shell } from '@author.io/shell' + +test('Common flags (entire shell)', t => { + const shell = new Shell({ + name: 'account', + commonflags: { + typical: { + alias: 't', + description: 'A typical flag on all commands.', + default: true + } + }, + commands: [{ + name: 'create', + handler (meta) { + t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) + } + }] + }) + + shell.exec('create') + .catch(e => t.fail(e.message)) + .finally(() => t.end()) +}) + +test('Common flags (command-specific)', t => { + const shell = new Shell({ + name: 'account', + commonflags: { + typical: { + alias: 't', + description: 'A typical flag on all commands.', + default: true + } + }, + commands: [{ + name: 'create', + handler (meta) { + t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) + }, + commands: [{ + name: 'noop', + handler (meta) { + t.ok( + meta.flags.recognized.hasOwnProperty('typical') && + !meta.flags.recognized.hasOwnProperty('org') + , 'Recognized global flag but not those of other commands.' + ) + } + }, { + name: 'account', + commonflags: { + org: { + alias: 'o', + description: 'Indicates the account is for an org.', + type: Boolean, + default: false + } + }, + commands: [{ + name: 'test', + flags: { + any: { + alias: 'a', + description: 'a' + } + }, + handler (meta) { + t.ok( + meta.flags.recognized.hasOwnProperty('typical') && + meta.flags.recognized.hasOwnProperty('org') && + meta.flags.recognized.hasOwnProperty('any'), + 'Expected 3 flags (inherited)' + ) + } + }] + }] + }] + }) + + shell.exec('create account test') + .then(r => { + shell.exec('create noop') + .catch(e => console.log(e.stack) && t.fail(e.message)) + .finally(() => t.end()) + }) + .catch(e => console.log(e.stack) && t.fail(e.message) && t.end()) +}) + +test('Common flags (exclusions)', async t => { + const shell = new Shell({ + name: 'account', + commonflags: { + ignore: ['other'], + typical: { + alias: 't', + description: 'A typical flag on all commands.', + default: true + } + }, + commands: [{ + name: 'create', + handler (meta) { + t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) + } + }, { + name: 'delete', + handler (meta) { + t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) + } + }, { + name: 'other', + handler (meta) { + t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded.') + }, + commands: [{ + name: 'sub', + handler (meta) { + t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded from a sub command.') + }, + commands: [{ + name: 'cmd', + handler (meta) { + t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded from a nested sub command.') + } + }] + }] + }] + }) + + await shell.exec('create').catch(t.fail) + await shell.exec('delete').catch(t.fail) + await shell.exec('other').catch(t.fail) + await shell.exec('other sub').catch(t.fail) + await shell.exec('other sub cmd').catch(t.fail) + t.end() +}) diff --git a/tests/06-history.js b/tests/06-history.js new file mode 100644 index 0000000..0476e91 --- /dev/null +++ b/tests/06-history.js @@ -0,0 +1,37 @@ +import test from 'tappedout' +import { Shell } from '@author.io/shell' + +test('Basic History', t => { + const sh = new Shell({ + name: 'test', + maxhistory: 3, + commands: [{ + name: 'cmd', + handler () { } + }] + }) + + sh.exec('cmd a') + sh.exec('cmd b') + sh.exec('cmd c') + + t.ok(sh.history().length === 3, `Received a history of 3 items. Recognized ${sh.history().length}`) + + sh.exec('cmd d') + t.ok(sh.history().length === 3 && + sh.history()[0].input === 'cmd d' && + sh.history()[2].input === 'cmd b', + `Received a history of 3 items, respecting maximum history levels. Recognized ${sh.history().length}` + ) + + let c = sh.priorCommand() + t.ok(c === 'cmd d', `Recognized prior command (cmd d). Returned ${c}`) + c = sh.nextCommand() + t.ok(c === undefined, 'Next command returns undefined when it does not exist.') + c = sh.priorCommand(1) + t.ok(c === 'cmd c', `Recognized 2 prior commands (cmd b). Returned ${c}`) + c = sh.nextCommand() + t.ok(c === 'cmd d', 'Next command returns proper command when it exists.' + `cmd d = ${c}`) + + t.end() +}) diff --git a/tests/07-plugins.js b/tests/07-plugins.js new file mode 100644 index 0000000..29ed309 --- /dev/null +++ b/tests/07-plugins.js @@ -0,0 +1,113 @@ +import test from 'tappedout' +import { Shell } from '@author.io/shell' + + +test('Basic Plugins', t => { + const sh = new Shell({ + name: 'test', + plugins: { + test: (value) => { + return value += 1 + } + }, + commands: [{ + name: 'cmd', + handler (meta) { + t.ok(typeof meta.plugins === 'object', `meta.plugins should be an object. Recognized as ${typeof meta.plugins}.`) + t.ok(typeof meta.plugins.test === 'function', `Expected test plugin to be a function, recognized ${typeof meta.plugins.test}.`) + t.ok(meta.plugins.test(1) === 2, 'Plugin executes properly.') + + t.end() + } + }] + }) + + sh.exec('cmd').catch(t.fail) +}) + +test('Inherited Plugins', t => { + const sh = new Shell({ + name: 'test', + plugins: { + test: (value) => { + return value += 1 + } + }, + commands: [{ + name: 'cmd', + plugins: { + ten: 10 + }, + handler(meta) { + t.ok(typeof meta.plugins === 'object', `meta.plugins should be an object. Recognized as ${typeof meta.plugins}.`) + t.ok(typeof meta.plugins.ten === 'number', `Expected test plugin to be a number, recognized ${typeof meta.plugins.ten}.`) + t.ok(meta.plugins.test(1) === 2, 'Shell plugin (global) executes properly.') + + t.end() + } + }] + }) + + sh.exec('cmd').catch(t.fail) +}) + +test('Overriding Plugins', t => { + const sh = new Shell({ + name: 'test', + plugins: { + test: (value) => { + return value += 1 + } + }, + commands: [{ + name: 'cmd', + plugins: { + test: (value) => { + return value += 10 + } + }, + handler(meta) { + t.ok(meta.plugins.test(1) === 11, 'Overridden shell plugin (global) executes properly.') + t.ok(meta.shell.plugins.test(1) === 2, 'Original shell plugin (overridden) executes properly when executed from shell.') + t.end() + } + }] + }) + + sh.exec('cmd').catch(t.fail) +}) + +test('Overriding Plugins', t => { + const sh = new Shell({ + name: 'test', + plugins: { + test: (value) => { + return value += 1 + } + }, + commands: [{ + name: 'cmd', + plugins: { + test: (value) => { + return value += 10 + } + }, + commands: [{ + name: 'sub', + plugins: { + test: (value) => { + return value += 100 + } + }, + handler(meta) { + t.ok(meta.plugins.test(1) === 101, 'Overridden command plugin executes properly.') + t.ok(meta.command.parent.plugins.test(1) === 11, 'Original parent command plugin executes properly.') + t.ok(meta.shell.plugins.test(1) === 2, 'Original shell plugin (overridden) executes properly when executed from shell.') + t.end() + } + }] + }] + }) + + sh.exec('cmd sub').catch(t.fail) +}) diff --git a/tests/100-regression.js b/tests/100-regression.js new file mode 100644 index 0000000..762f27c --- /dev/null +++ b/tests/100-regression.js @@ -0,0 +1,121 @@ +import test from 'tappedout' +import { Shell } from '@author.io/shell' + +// The range error was caused by the underlying table library. +// When a default help message was generated, a negative column +// width (representing "infinite" width) was not being converted +// to the width of the content, causing a method to execute infinitely. +test('Extending shell creates RangeError for data attribute', t => { + class CLIClient extends Shell { + exec (input, cb) { + console.log(input) + super.exec(...arguments) + } + } + + const sh = new CLIClient({ + name: 'test', + commands: [{ + name: 'cmd', + handler () {} + }] + }) + + t.ok(sh.data.name === 'test', 'Extended shell still provides underlying data.') + t.end() +}) + +test('Metadata duplication of flag values when multiple values are allowed', t => { + const sh = new Shell({ + name: 'dev', + version: '1.0.0', + description: 'Control the manual test suite.', + + commands: [{ + name: 'run', + description: 'Run a specific test. Runs all known test files if no file is specified with a flag.', + flags: { + file: { + alias: 'f', + description: 'The file to load/execute.', + allowMultipleValues: true + } + }, + handler (meta) { + t.expect('a.js', meta.data.file[0], 'First value is correct') + t.expect('b.js', meta.data.file[1], 'Second value is correct') + t.expect(2, meta.data.file.length, 'Only two unique flag values provided.') + t.end() + } + }] + }) + + sh.exec('run -f a.js -f b.js').catch(t.fail) +}) + +test('Properly parsing input values with spaces', t => { + const sh = new Shell({ + name: 'test', + commands: [{ + name: 'run', + flags: { + connection: { + alias: 'c' + } + }, + handler(meta) { + t.expect('a connection', meta.data.connection, 'Support flag values with spaces') + t.end() + } + }] + }) + + sh.exec('run -c "a connection"').catch(t.fail) +}) + +test('Recognize flags with quotes', t => { + const input = 'run --connection "a connection" --save' + const sh = new Shell({ + name: 'test', + commands: [{ + name: 'run', + flags: { + connection: { + alias: 'c', + description: 'connection string', + type: 'string' + } + }, + handler(meta) { + t.expect('a connection', meta.data.connection, 'Support flag values with spaces') + t.end() + } + }] + }) + + sh.exec('run -c "a connection"').catch(t.fail) +}) + +test('Accept arrays with values containing spaces', t => { + const input = 'run --connection "a connection" --save' + const sh = new Shell({ + name: 'test', + commands: [{ + name: 'run', + flags: { + connection: { + alias: 'c', + description: 'connection string', + type: 'string' + } + }, + handler(meta) { + t.expect('a connection', meta.data.connection, 'Support flag values with spaces') + t.end() + } + }] + }) + + const argv = ["run", "-c", "a connection", "--save"] + sh.exec(argv).catch(t.fail) +})