├── examples ├── cli │ ├── package-lock.json │ ├── package.json │ ├── index.js │ └── README.md └── json │ ├── package-lock.json │ ├── index.js │ ├── package.json │ ├── README.md │ └── dir.json ├── src ├── index.js ├── middleware.js ├── utility.js ├── format.js ├── shell.js ├── base.js └── command.js ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── pr.yml │ └── deploy.yml ├── LICENSE.md ├── tests ├── 06-history.js ├── 03-help-defaults.js ├── 07-plugins.js ├── 100-regression.js ├── 05-commonflags.js ├── 04-metadata.js ├── 02-middleware.js └── 01-sanity.js ├── package.json └── README.md /examples/cli/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dir", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /examples/json/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jdir", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Command from './command.js' 2 | import Shell from './shell.js' 3 | import Middleware from './middleware.js' 4 | import { Formatter, Table } from './format.js' 5 | const all = { Shell, Command, Formatter, Table, Middleware } 6 | export { Command, Shell, Formatter, Table, Middleware, all as default } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .* 3 | _* 4 | !.github 5 | !.gitignore 6 | !.npmignore 7 | !.dockerignore 8 | !.browserslistrc 9 | !.eslintrc 10 | !.*.yml 11 | node_modules 12 | test/output/**/* 13 | test/*.txt 14 | /**/*.old 15 | test/package-lock.json 16 | build/package-lock.json 17 | *.cast 18 | *.tar.gz 19 | _BACKUP 20 | -------------------------------------------------------------------------------- /examples/json/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { Command, Shell } from '../../src/index.js' 5 | import { fileURLToPath } from 'url' 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'dir.json'))) 9 | const shell = new Shell(config) 10 | const cmd = process.argv.slice(2).join(' ').trim() 11 | shell.exec(cmd).catch(e => console.log(e.message || e)) 12 | -------------------------------------------------------------------------------- /examples/json/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jdir", 3 | "version": "1.0.0", 4 | "description": "A simple file system utility.", 5 | "main": "index.js", 6 | "bin": { 7 | "jdir": "index.js" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "type": "module", 13 | "private": true, 14 | "author": "Corey Butler", 15 | "license": "MIT", 16 | "engines": { 17 | "node": "^12.0.0" 18 | }, 19 | "dependencies": {} 20 | } 21 | -------------------------------------------------------------------------------- /examples/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dir", 3 | "version": "1.0.0", 4 | "description": "A simple file system utility.", 5 | "main": "index.js", 6 | "bin": { 7 | "dir": "index.js" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "type": "module", 13 | "private": true, 14 | "author": "Corey Butler", 15 | "license": "MIT", 16 | "engines": { 17 | "node": "^12.0.0" 18 | }, 19 | "dependencies": {}, 20 | "devDependencies": { 21 | "@author.io/node-shell-debug": "^1.5.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: coreybutler 4 | patreon: # coreybutler 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | export default class Middleware { 2 | constructor () { 3 | Object.defineProperties(this, { 4 | _data: { enumerable: false, configurable: false, value: [] }, 5 | go: { enumerable: false, configurable: false, writable: true, value: (...args) => { args.pop().apply(this, args) } } 6 | }) 7 | } 8 | 9 | get size () { return this._data.length } 10 | 11 | get data () { return this._data } 12 | 13 | use (method) { 14 | const methodBody = method.toString() 15 | if (methodBody.indexOf('[native code]') < 0) { 16 | this._data.push(methodBody) 17 | } 18 | 19 | this.go = (stack => (...args) => { 20 | const next = args.pop() 21 | stack(...args, () => { 22 | method.apply(this, [...args, next.bind(null, ...args)]) 23 | }) 24 | })(this.go) 25 | } 26 | 27 | run () { 28 | const args = Array.from(arguments) 29 | if (args.length === 0 || typeof args[args.length - 1] !== 'function') { 30 | args.push(() => {}) 31 | } 32 | 33 | this.go(...args) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/utility.js: -------------------------------------------------------------------------------- 1 | // const STRIP_EQUAL_SIGNS = /(\=+)(?=([^'"\\]*(\\.|['"]([^'"\\]*\\.)*[^'"\\]*['"]))*[^'"]*$)/g 2 | 3 | const SUBCOMMAND_PATTERN = /^([^"'][\S\b]+)[\s+]?([^-].*)$/i // eslint-disable-line no-useless-escape 4 | const FLAG_PATTERN = /((?:"[^"\\]*(?:\\[\S\s][^"\\]*)*"|'[^'\\]*(?:\\[\S\s][^'\\]*)*'|\/[^\/\\]*(?:\\[\S\s][^\/\\]*)*\/[gimy]*(?=\s|$)|(?:\\\s|\S))+)(?=\s|$)/g // eslint-disable-line no-useless-escape 5 | const METHOD_PATTERN = /^([\w]+\s?)\(.*\)\s?{/i // eslint-disable-line no-useless-escape 6 | const STRIP_QUOTE_PATTERN = /"([^"\\]*(\\.[^"\\]*)*)"|\'([^\'\\]*(\\.[^\'\\]*)*)\'/ig // eslint-disable-line no-useless-escape 7 | const COMMAND_PATTERN = /^(\w+)\s+([\s\S]+)?/i // eslint-disable-line no-useless-escape 8 | const CONSTANTS = Object.freeze({ 9 | SUBCOMMAND_PATTERN, 10 | FLAG_PATTERN, 11 | METHOD_PATTERN, 12 | STRIP_QUOTE_PATTERN, 13 | COMMAND_PATTERN 14 | }) 15 | 16 | export { 17 | CONSTANTS as default, 18 | CONSTANTS, 19 | SUBCOMMAND_PATTERN, 20 | FLAG_PATTERN, 21 | METHOD_PATTERN, 22 | STRIP_QUOTE_PATTERN, 23 | COMMAND_PATTERN 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Author.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/06-history.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell } from '@author.io/shell' 3 | 4 | test('Basic History', t => { 5 | const sh = new Shell({ 6 | name: 'test', 7 | maxhistory: 3, 8 | commands: [{ 9 | name: 'cmd', 10 | handler () { } 11 | }] 12 | }) 13 | 14 | sh.exec('cmd a') 15 | sh.exec('cmd b') 16 | sh.exec('cmd c') 17 | 18 | t.ok(sh.history().length === 3, `Received a history of 3 items. Recognized ${sh.history().length}`) 19 | 20 | sh.exec('cmd d') 21 | t.ok(sh.history().length === 3 && 22 | sh.history()[0].input === 'cmd d' && 23 | sh.history()[2].input === 'cmd b', 24 | `Received a history of 3 items, respecting maximum history levels. Recognized ${sh.history().length}` 25 | ) 26 | 27 | let c = sh.priorCommand() 28 | t.ok(c === 'cmd d', `Recognized prior command (cmd d). Returned ${c}`) 29 | c = sh.nextCommand() 30 | t.ok(c === undefined, 'Next command returns undefined when it does not exist.') 31 | c = sh.priorCommand(1) 32 | t.ok(c === 'cmd c', `Recognized 2 prior commands (cmd b). Returned ${c}`) 33 | c = sh.nextCommand() 34 | t.ok(c === 'cmd d', 'Next command returns proper command when it exists.' + `cmd d = ${c}`) 35 | 36 | t.end() 37 | }) 38 | -------------------------------------------------------------------------------- /tests/03-help-defaults.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell, Middleware } from '@author.io/shell' 3 | 4 | // import fs from 'fs' 5 | 6 | let msg = '' 7 | const cfg = () => { 8 | return { 9 | name: 'mycli', 10 | description: 'My command line.', 11 | commands: [{ 12 | name: 'a', 13 | description: 'A fine letter.', 14 | handler () { }, 15 | commands: [{ 16 | name: 'b', 17 | description: 'Beautiful commands.', 18 | handler () { }, 19 | commands: [{ 20 | name: 'c', 21 | description: 'Choices are great.', 22 | handler (meta) { 23 | msg = meta.command.help 24 | // msg = meta.help.message 25 | } 26 | }] 27 | }] 28 | }] 29 | } 30 | } 31 | 32 | test('Request help via flag', t => { 33 | const c = cfg() 34 | const shell = new Shell(c) 35 | 36 | shell.exec('a b c --help') 37 | .then(r => { 38 | // fs.writeFileSync('./test.txt', Buffer.from(msg)) 39 | t.ok(shell.getCommand('a b c').help === `mycli a b c 40 | 41 | Choices are great.`, 'Displayed correct default help message') 42 | }) 43 | .catch(e => t.fail(e.message)) 44 | .finally(() => t.end()) 45 | }) 46 | 47 | test('Default help when no handler exists', t => { 48 | const c = cfg() 49 | delete c.commands[0].commands[0].commands[0].handler 50 | const shell = new Shell(c) 51 | 52 | shell.exec('a b c') 53 | .then(r => { 54 | t.ok(shell.getCommand('a b c').help === `mycli a b c 55 | 56 | Choices are great.`, 'Displayed correct default help message when no handler exists') 57 | }) 58 | .catch(e => t.fail(e.message)) 59 | .finally(() => t.end()) 60 | }) 61 | 62 | test('Display custom help when defined', t => { 63 | const c = cfg() 64 | c.commands[0].commands[0].commands[0].help = () => 'custom help is awesome' 65 | const shell = new Shell(c) 66 | 67 | shell.exec('a b c') 68 | .then(r => { 69 | t.ok(msg === 'custom help is awesome', 'Displayed custom help message') 70 | }) 71 | .catch(e => t.fail(e.message)) 72 | .finally(() => t.end()) 73 | }) 74 | 75 | test('Disabled help', t => { 76 | const c = cfg() 77 | c.disableHelp = true 78 | const shell = new Shell(c) 79 | 80 | shell.exec('a b c --help') 81 | .then(r => { 82 | // fs.writeFileSync('./test.txt', Buffer.from(msg)) 83 | t.ok(shell.getCommand('a b c').help.trim() === '', `Expected no help message. Received "${msg}"`) 84 | }) 85 | .catch(e => t.fail(e.message)) 86 | .finally(() => t.end()) 87 | }) 88 | 89 | test('Display custom help when defined', t => { 90 | const c = cfg() 91 | const shell = new Shell(c) 92 | 93 | shell.exec('--help') 94 | .then(r => { 95 | t.pass('Passing --help directly to shell does not fail.') 96 | }) 97 | .catch(e => t.fail(e.message)) 98 | .finally(() => t.end()) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/07-plugins.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell } from '@author.io/shell' 3 | 4 | 5 | test('Basic Plugins', t => { 6 | const sh = new Shell({ 7 | name: 'test', 8 | plugins: { 9 | test: (value) => { 10 | return value += 1 11 | } 12 | }, 13 | commands: [{ 14 | name: 'cmd', 15 | handler (meta) { 16 | t.ok(typeof meta.plugins === 'object', `meta.plugins should be an object. Recognized as ${typeof meta.plugins}.`) 17 | t.ok(typeof meta.plugins.test === 'function', `Expected test plugin to be a function, recognized ${typeof meta.plugins.test}.`) 18 | t.ok(meta.plugins.test(1) === 2, 'Plugin executes properly.') 19 | 20 | t.end() 21 | } 22 | }] 23 | }) 24 | 25 | sh.exec('cmd').catch(t.fail) 26 | }) 27 | 28 | test('Inherited Plugins', t => { 29 | const sh = new Shell({ 30 | name: 'test', 31 | plugins: { 32 | test: (value) => { 33 | return value += 1 34 | } 35 | }, 36 | commands: [{ 37 | name: 'cmd', 38 | plugins: { 39 | ten: 10 40 | }, 41 | handler(meta) { 42 | t.ok(typeof meta.plugins === 'object', `meta.plugins should be an object. Recognized as ${typeof meta.plugins}.`) 43 | t.ok(typeof meta.plugins.ten === 'number', `Expected test plugin to be a number, recognized ${typeof meta.plugins.ten}.`) 44 | t.ok(meta.plugins.test(1) === 2, 'Shell plugin (global) executes properly.') 45 | 46 | t.end() 47 | } 48 | }] 49 | }) 50 | 51 | sh.exec('cmd').catch(t.fail) 52 | }) 53 | 54 | test('Overriding Plugins', t => { 55 | const sh = new Shell({ 56 | name: 'test', 57 | plugins: { 58 | test: (value) => { 59 | return value += 1 60 | } 61 | }, 62 | commands: [{ 63 | name: 'cmd', 64 | plugins: { 65 | test: (value) => { 66 | return value += 10 67 | } 68 | }, 69 | handler(meta) { 70 | t.ok(meta.plugins.test(1) === 11, 'Overridden shell plugin (global) executes properly.') 71 | t.ok(meta.shell.plugins.test(1) === 2, 'Original shell plugin (overridden) executes properly when executed from shell.') 72 | t.end() 73 | } 74 | }] 75 | }) 76 | 77 | sh.exec('cmd').catch(t.fail) 78 | }) 79 | 80 | test('Overriding Plugins', t => { 81 | const sh = new Shell({ 82 | name: 'test', 83 | plugins: { 84 | test: (value) => { 85 | return value += 1 86 | } 87 | }, 88 | commands: [{ 89 | name: 'cmd', 90 | plugins: { 91 | test: (value) => { 92 | return value += 10 93 | } 94 | }, 95 | commands: [{ 96 | name: 'sub', 97 | plugins: { 98 | test: (value) => { 99 | return value += 100 100 | } 101 | }, 102 | handler(meta) { 103 | t.ok(meta.plugins.test(1) === 101, 'Overridden command plugin executes properly.') 104 | t.ok(meta.command.parent.plugins.test(1) === 11, 'Original parent command plugin executes properly.') 105 | t.ok(meta.shell.plugins.test(1) === 2, 'Original shell plugin (overridden) executes properly when executed from shell.') 106 | t.end() 107 | } 108 | }] 109 | }] 110 | }) 111 | 112 | sh.exec('cmd sub').catch(t.fail) 113 | }) 114 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: 'Run Tests' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | name: Checkout Code 15 | 16 | - name: List Directory Contents (for Troubleshooting) 17 | run: | 18 | pwd 19 | ls -l 20 | 21 | - uses: actions/setup-node@v1 22 | name: Setup Node.js 23 | with: 24 | node-version: '15' 25 | 26 | - uses: actions/cache@v2 27 | name: Establish npm Cache 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-node- 33 | 34 | - uses: actions/cache@v2 35 | name: Establish Docker Cache 36 | id: cache 37 | with: 38 | path: docker-cache 39 | key: ${{ runner.os }}-docker-${{ github.sha }} 40 | restore-keys: | 41 | ${{ runner.os }}-docker- 42 | 43 | - name: Load cached Docker layers 44 | run: | 45 | if [ -d "docker-cache" ]; then 46 | cat docker-cache/x* > my-image.tar 47 | docker load < my-image.tar 48 | rm -rf docker-cache 49 | fi 50 | 51 | - name: Download Dev Tooling 52 | id: setup 53 | run: | 54 | echo ${{ secrets.GH_DOCKER_TOKEN }} | docker login https://docker.pkg.github.com -u ${{ secrets.GH_DOCKER_USER }} --password-stdin 55 | base=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-base/tags?page_size=1'|jq '."results"[]["name"]') 56 | base=$(sed -e 's/^"//' -e 's/"$//' <<<"$base") 57 | echo Retrieving author/dev/dev-base:$base 58 | docker pull author/dev-base:$base 59 | # docker pull docker.pkg.github.com/author/dev/dev-base:$base 60 | 61 | deno=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-deno/tags?page_size=1'|jq '."results"[]["name"]') 62 | deno=$(sed -e 's/^"//' -e 's/"$//' <<<"$deno") 63 | echo Retrieving author/dev/dev-deno:$deno 64 | # docker pull docker.pkg.github.com/author/dev/dev-deno:$deno 65 | docker pull author/dev-deno:$deno 66 | 67 | browser=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-browser/tags?page_size=1'|jq '."results"[]["name"]') 68 | browser=$(sed -e 's/^"//' -e 's/"$//' <<<"$browser") 69 | echo Retrieving author/dev/dev-browser:$browser 70 | # docker pull docker.pkg.github.com/author/dev/dev-browser:$browser 71 | docker pull author/dev-browser:$browser 72 | 73 | node=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-node/tags?page_size=1'|jq '."results"[]["name"]') 74 | node=$(sed -e 's/^"//' -e 's/"$//' <<<"$node") 75 | echo Retrieving author/dev/dev-node:$node 76 | # docker pull docker.pkg.github.com/author/dev/dev-node:$node 77 | docker pull author/dev-node:$node 78 | 79 | # 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(' '))" 80 | version=$(npm show @author.io/dev version) 81 | echo $version 82 | npm i -g @author.io/dev@$version 83 | dev -v 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | 87 | - name: Test 88 | if: success() 89 | run: | 90 | dev -v 91 | npm run ci -------------------------------------------------------------------------------- /tests/100-regression.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell } from '@author.io/shell' 3 | 4 | // The range error was caused by the underlying table library. 5 | // When a default help message was generated, a negative column 6 | // width (representing "infinite" width) was not being converted 7 | // to the width of the content, causing a method to execute infinitely. 8 | test('Extending shell creates RangeError for data attribute', t => { 9 | class CLIClient extends Shell { 10 | exec (input, cb) { 11 | console.log(input) 12 | super.exec(...arguments) 13 | } 14 | } 15 | 16 | const sh = new CLIClient({ 17 | name: 'test', 18 | commands: [{ 19 | name: 'cmd', 20 | handler () {} 21 | }] 22 | }) 23 | 24 | t.ok(sh.data.name === 'test', 'Extended shell still provides underlying data.') 25 | t.end() 26 | }) 27 | 28 | test('Metadata duplication of flag values when multiple values are allowed', t => { 29 | const sh = new Shell({ 30 | name: 'dev', 31 | version: '1.0.0', 32 | description: 'Control the manual test suite.', 33 | 34 | commands: [{ 35 | name: 'run', 36 | description: 'Run a specific test. Runs all known test files if no file is specified with a flag.', 37 | flags: { 38 | file: { 39 | alias: 'f', 40 | description: 'The file to load/execute.', 41 | allowMultipleValues: true 42 | } 43 | }, 44 | handler (meta) { 45 | t.expect('a.js', meta.data.file[0], 'First value is correct') 46 | t.expect('b.js', meta.data.file[1], 'Second value is correct') 47 | t.expect(2, meta.data.file.length, 'Only two unique flag values provided.') 48 | t.end() 49 | } 50 | }] 51 | }) 52 | 53 | sh.exec('run -f a.js -f b.js').catch(t.fail) 54 | }) 55 | 56 | test('Properly parsing input values with spaces', t => { 57 | const sh = new Shell({ 58 | name: 'test', 59 | commands: [{ 60 | name: 'run', 61 | flags: { 62 | connection: { 63 | alias: 'c' 64 | } 65 | }, 66 | handler(meta) { 67 | t.expect('a connection', meta.data.connection, 'Support flag values with spaces') 68 | t.end() 69 | } 70 | }] 71 | }) 72 | 73 | sh.exec('run -c "a connection"').catch(t.fail) 74 | }) 75 | 76 | test('Recognize flags with quotes', t => { 77 | const input = 'run --connection "a connection" --save' 78 | const sh = new Shell({ 79 | name: 'test', 80 | commands: [{ 81 | name: 'run', 82 | flags: { 83 | connection: { 84 | alias: 'c', 85 | description: 'connection string', 86 | type: 'string' 87 | } 88 | }, 89 | handler(meta) { 90 | t.expect('a connection', meta.data.connection, 'Support flag values with spaces') 91 | t.end() 92 | } 93 | }] 94 | }) 95 | 96 | sh.exec('run -c "a connection"').catch(t.fail) 97 | }) 98 | 99 | test('Accept arrays with values containing spaces', t => { 100 | const input = 'run --connection "a connection" --save' 101 | const sh = new Shell({ 102 | name: 'test', 103 | commands: [{ 104 | name: 'run', 105 | flags: { 106 | connection: { 107 | alias: 'c', 108 | description: 'connection string', 109 | type: 'string' 110 | } 111 | }, 112 | handler(meta) { 113 | t.expect('a connection', meta.data.connection, 'Support flag values with spaces') 114 | t.end() 115 | } 116 | }] 117 | }) 118 | 119 | const argv = ["run", "-c", "a connection", "--save"] 120 | sh.exec(argv).catch(t.fail) 121 | }) 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@author.io/shell", 3 | "version": "1.9.2", 4 | "description": "A micro-framework for creating CLI-like experiences. This supports Node.js and browsers.", 5 | "main": "./src/index.js", 6 | "module": "./index.js", 7 | "exports": { 8 | "import": "./index.js", 9 | "default": "./index.js" 10 | }, 11 | "browser": "./index.js", 12 | "scripts": { 13 | "start": "dev workspace", 14 | "test": "npm run test:node && npm run test:deno && npm run test:browser && npm run report:syntax && npm run report:size", 15 | "test:node": "dev test -rt node tests/*.js", 16 | "test:node:sanity": "dev test -rt node tests/01-sanity.js", 17 | "test:node:base": "dev test -rt node tests/02-base.js", 18 | "test:node:relationships": "dev test -rt node tests/06-relationships.js", 19 | "test:node:regression": "dev test -rt node tests/100-regression.js", 20 | "test:browser": "dev test -rt browser tests/*.js", 21 | "test:browser:sanity": "dev test -rt browser tests/01-sanity.js", 22 | "test:browser:base": "dev test -rt browser tests/02-base.js", 23 | "test:deno": "dev test -rt deno tests/*.js", 24 | "test:deno:sanity": "dev test -rt deno tests/01-sanity.js", 25 | "manually": "dev test -rt manual tests/*.js", 26 | "build": "dev build --verbose", 27 | "report:syntax": "dev report syntax --pretty", 28 | "report:size": "dev report size ./.dist/**/*.js ./.dist/**/*.js.map", 29 | "report:compat": "dev report compatibility ./src/**/*.js", 30 | "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.\"", 31 | "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", 32 | "x": "esbuild ./src/index.js --format esm --bundle --minify --outfile=.dist/shell/index.js" 33 | }, 34 | "keywords": [ 35 | "cli", 36 | "args", 37 | "arg", 38 | "shell" 39 | ], 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/author/shell" 43 | }, 44 | "dev": { 45 | "ignorecircular": [ 46 | "src/command.js", 47 | "src/shell.js" 48 | ], 49 | "replace": { 50 | "<#REPLACE_VERSION#>": "package.version" 51 | }, 52 | "volume": [ 53 | "./node_modules/@author.io/arg:/node_modules/@author.io/arg", 54 | "./node_modules/@author.io/table:/node_modules/@author.io/table" 55 | ], 56 | "alias": { 57 | "@author.io/shell": "/app/.dist/@author.io/shell/index.js", 58 | "@author.io/arg": "/node_modules/@author.io/arg/index.js", 59 | "@author.io/table": "/node_modules/@author.io/table/index.js" 60 | }, 61 | "ci": { 62 | "verbose": true, 63 | "peer": true, 64 | "embed": [ 65 | "@author.io/arg", 66 | "@author.io/table" 67 | ] 68 | } 69 | }, 70 | "author": { 71 | "name": "Corey Butler", 72 | "url": "http://coreybutler.com" 73 | }, 74 | "license": "MIT", 75 | "type": "module", 76 | "files": [ 77 | "*.js" 78 | ], 79 | "engines": { 80 | "node": ">=13.5.0" 81 | }, 82 | "standard": { 83 | "parser": "babel-eslint", 84 | "ignore": [ 85 | "_*", 86 | "_**/*", 87 | ".**/*", 88 | "node_modules", 89 | "karma.conf.js", 90 | "karma.conf.cjs", 91 | "build.js" 92 | ], 93 | "globals": [ 94 | "window", 95 | "global", 96 | "globalThis" 97 | ] 98 | }, 99 | "devDependencies": { 100 | "esbuild": "^0.14.10", 101 | "@author.io/arg": "^1.3.23", 102 | "@author.io/dev": "^1.1.5", 103 | "@author.io/table": "^1.0.3" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /examples/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node -r source-map-support/register 2 | 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { Command, Shell, Formatter } from '../../src/index.js' 6 | import { fileURLToPath } from 'url' 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 8 | 9 | const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, './package.json'))) 10 | 11 | const shell = new Shell({ 12 | name: Object.keys(pkg.bin)[0], 13 | description: pkg.description, 14 | version: pkg.version, 15 | // autohelp: false, 16 | defaultMethod (data) { 17 | console.log(data) 18 | }, 19 | commands: [{ 20 | name: 'list', 21 | alias: 'ls', 22 | description: 'List files of a directory. Optionally specify a long format.', 23 | flags: { 24 | l: { 25 | description: 'Long format.', 26 | type: Boolean, 27 | default: false 28 | } 29 | }, 30 | handler: (data) => { 31 | let dir = process.cwd() 32 | 33 | if (data.flags.unrecognized.length > 0) { 34 | dir = path.resolve(data.flags.unrecognized[0]) 35 | } 36 | 37 | const out = fs.readdirSync(dir) 38 | .map(item => { 39 | if (data.flags.recognized.l) { 40 | return ((fs.statSync(path.join(dir, item)).isDirectory() ? '>' : '-') + ' ' + item).trim() 41 | } 42 | 43 | return item 44 | }) 45 | .join(data.flags.recognized.l ? '\n' : ', ') 46 | 47 | console.log(out) 48 | } 49 | }, new Command({ 50 | name: 'export', 51 | description: 'Export a file representation of a directory.', 52 | // Uncomment this to see how it works 53 | // handler (data) { 54 | // console.log(data) 55 | // console.log(this.help) 56 | // }, 57 | commands: [{ 58 | name: 'json', 59 | description: 'Generate a JSON representation of the directory.', 60 | flags: { 61 | array: { 62 | alias: 'a', 63 | type: 'boolean', 64 | default: false, 65 | description: 'Generate an array. This is a pretty long message for such a short explanation. Kinda Rube Goldbergish.' 66 | } 67 | }, 68 | handler (data) { 69 | if (data.help.requested) { 70 | return console.log(this.help) 71 | } 72 | 73 | let dir = process.cwd() 74 | 75 | if (data.flags.unrecognized.length > 0) { 76 | dir = path.resolve(data.flags.unrecognized[0]) 77 | } 78 | 79 | const contents = fs.readdirSync(dir) 80 | 81 | if (data.flags.recognized.array) { 82 | return console.log(contents) 83 | } 84 | 85 | const result = Object.defineProperty({}, dir, { 86 | enumerable: true, 87 | value: contents 88 | }) 89 | 90 | console.log(JSON.stringify(result, null, 2)) 91 | } 92 | }] 93 | })] 94 | }) 95 | 96 | shell.add(new Command({ 97 | name: 'mkdir', 98 | aliases: ['md', 'm'], 99 | description: 'Make a new directory.', 100 | flags: { 101 | p: { 102 | type: 'boolean', 103 | default: false, 104 | description: 'Recursively create the directory if it does not already exist.', 105 | alias: 'R' 106 | } 107 | }, 108 | handler (data) { 109 | console.log('...make a directory...') 110 | console.log(data) 111 | } 112 | })) 113 | 114 | 115 | shell.add(new Command({ 116 | name: 'doc', 117 | description: 'Output the metadoc of this shell.', 118 | flags: { 119 | file: { 120 | alias: 'f', 121 | type: String 122 | } 123 | }, 124 | handler (meta) { 125 | let data = this.shell.data 126 | 127 | if (meta.flag('file') !== null) { 128 | fs.writeFileSync(path.resolve(meta.flag('file')), JSON.stringify(data, null, 2)) 129 | } else { 130 | console.log(data) 131 | } 132 | } 133 | })) 134 | 135 | shell.use((data, next) => { 136 | console.log('This middleware runs on every command.') 137 | next() 138 | }) 139 | 140 | const cmd = process.argv.slice(2).join(' ').trim() 141 | // console.log(cmd) 142 | shell.exec(cmd).catch(e => console.log(e.message || e)) -------------------------------------------------------------------------------- /tests/05-commonflags.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell } from '@author.io/shell' 3 | 4 | test('Common flags (entire shell)', t => { 5 | const shell = new Shell({ 6 | name: 'account', 7 | commonflags: { 8 | typical: { 9 | alias: 't', 10 | description: 'A typical flag on all commands.', 11 | default: true 12 | } 13 | }, 14 | commands: [{ 15 | name: 'create', 16 | handler (meta) { 17 | t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) 18 | } 19 | }] 20 | }) 21 | 22 | shell.exec('create') 23 | .catch(e => t.fail(e.message)) 24 | .finally(() => t.end()) 25 | }) 26 | 27 | test('Common flags (command-specific)', t => { 28 | const shell = new Shell({ 29 | name: 'account', 30 | commonflags: { 31 | typical: { 32 | alias: 't', 33 | description: 'A typical flag on all commands.', 34 | default: true 35 | } 36 | }, 37 | commands: [{ 38 | name: 'create', 39 | handler (meta) { 40 | t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) 41 | }, 42 | commands: [{ 43 | name: 'noop', 44 | handler (meta) { 45 | t.ok( 46 | meta.flags.recognized.hasOwnProperty('typical') && 47 | !meta.flags.recognized.hasOwnProperty('org') 48 | , 'Recognized global flag but not those of other commands.' 49 | ) 50 | } 51 | }, { 52 | name: 'account', 53 | commonflags: { 54 | org: { 55 | alias: 'o', 56 | description: 'Indicates the account is for an org.', 57 | type: Boolean, 58 | default: false 59 | } 60 | }, 61 | commands: [{ 62 | name: 'test', 63 | flags: { 64 | any: { 65 | alias: 'a', 66 | description: 'a' 67 | } 68 | }, 69 | handler (meta) { 70 | t.ok( 71 | meta.flags.recognized.hasOwnProperty('typical') && 72 | meta.flags.recognized.hasOwnProperty('org') && 73 | meta.flags.recognized.hasOwnProperty('any'), 74 | 'Expected 3 flags (inherited)' 75 | ) 76 | } 77 | }] 78 | }] 79 | }] 80 | }) 81 | 82 | shell.exec('create account test') 83 | .then(r => { 84 | shell.exec('create noop') 85 | .catch(e => console.log(e.stack) && t.fail(e.message)) 86 | .finally(() => t.end()) 87 | }) 88 | .catch(e => console.log(e.stack) && t.fail(e.message) && t.end()) 89 | }) 90 | 91 | test('Common flags (exclusions)', async t => { 92 | const shell = new Shell({ 93 | name: 'account', 94 | commonflags: { 95 | ignore: ['other'], 96 | typical: { 97 | alias: 't', 98 | description: 'A typical flag on all commands.', 99 | default: true 100 | } 101 | }, 102 | commands: [{ 103 | name: 'create', 104 | handler (meta) { 105 | t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) 106 | } 107 | }, { 108 | name: 'delete', 109 | handler (meta) { 110 | t.ok(meta.flag('typical'), `Retrieved common flag value. Expected true, received ${meta.flag('typical')}`) 111 | } 112 | }, { 113 | name: 'other', 114 | handler (meta) { 115 | t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded.') 116 | }, 117 | commands: [{ 118 | name: 'sub', 119 | handler (meta) { 120 | t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded from a sub command.') 121 | }, 122 | commands: [{ 123 | name: 'cmd', 124 | handler (meta) { 125 | t.ok(!meta.flags.recognized.hasOwnProperty('typical'), 'The common flag "typical" was successfully excluded from a nested sub command.') 126 | } 127 | }] 128 | }] 129 | }] 130 | }) 131 | 132 | await shell.exec('create').catch(t.fail) 133 | await shell.exec('delete').catch(t.fail) 134 | await shell.exec('other').catch(t.fail) 135 | await shell.exec('other sub').catch(t.fail) 136 | await shell.exec('other sub cmd').catch(t.fail) 137 | t.end() 138 | }) 139 | -------------------------------------------------------------------------------- /src/format.js: -------------------------------------------------------------------------------- 1 | import Table from '@author.io/table' 2 | import Command from './command.js' 3 | import Shell from './shell.js' 4 | 5 | class Formatter { 6 | #data = null 7 | #tableWidth = 80 8 | #colAlign = [] // Defaults to ['l', 'l', 'l'] 9 | #colWidth = ['20%', '15%', '65%'] 10 | 11 | constructor (data) { 12 | this.#data = data 13 | } 14 | 15 | set width (value) { 16 | this.#tableWidth = value < 20 ? 20 : value 17 | } 18 | 19 | set columnWidths (value) { 20 | this.#colWidth = value 21 | } 22 | 23 | set columnAlignment (value) { 24 | this.#colAlign = value 25 | } 26 | 27 | get usage () { 28 | const desc = this.#data.description.trim() 29 | 30 | if (this.#data instanceof Command) { 31 | const aliases = this.#data.aliases 32 | 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(' ') : ''}`] 33 | 34 | if (this.#data.__processors.size > 0) { 35 | out[out.length - 1] += (this.#data.arguments.size > 0 || this.#data.__flagConfig.size > 0 ? ' |' : '') + ' [COMMAND]' 36 | } 37 | 38 | if (desc.trim().length > 0 && out !== desc) { 39 | out.push(new Table([[desc.trim().replace(/\n/gi, '\n ')]], null, null, this.#tableWidth, [2, 0, 1, 1]).output) 40 | } 41 | 42 | return out.join('\n') 43 | } else if (this.#data instanceof Shell) { 44 | 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() 45 | } 46 | 47 | return '' 48 | } 49 | 50 | get subcommands () { 51 | const rows = Array.from(this.#data.__processors.values()).map(cmd => { 52 | const nm = [cmd.name].concat(cmd.aliases) 53 | return [nm.join('|'), cmd.description] 54 | }) 55 | 56 | const result = [] 57 | 58 | if (rows.length > 0) { 59 | const table = new Table(rows, this.#colAlign, ['25%', '75%'], this.#tableWidth, [2]) 60 | result.push('\nCommands:\n') 61 | result.push(table.output) 62 | } 63 | 64 | return result.join('\n') 65 | } 66 | 67 | get help () { 68 | const usage = this.usage.trim() 69 | 70 | if (this.#data instanceof Command) { 71 | const flags = this.#data.__flagConfig 72 | const rows = [] 73 | 74 | if (flags.size > 0) { 75 | flags.forEach((cfg, flag) => { 76 | let aliases = Array.from(cfg.aliases || cfg.alias || []) 77 | aliases = aliases.length === 0 ? '' : '[' + aliases.map(a => `-${a}`).join(', ') + ']' 78 | 79 | let dsc = [cfg.description || ''] 80 | 81 | if (cfg.hasOwnProperty('options') && this.#data.describeOptions) { // eslint-disable-line no-prototype-builtins 82 | dsc.push(`Options: ${cfg.options.join(', ')}.`) 83 | } 84 | 85 | if (cfg.hasOwnProperty('allowMultipleValues') && cfg.allowMultipleValues === true && this.#data.describeMultipleValues) { // eslint-disable-line no-prototype-builtins 86 | dsc.push('Can be used multiple times.') 87 | } 88 | 89 | if (cfg.hasOwnProperty('default') && this.#data.describeDefault) { // eslint-disable-line no-prototype-builtins 90 | dsc.push(`(Default: ${cfg.default.toString()})`) 91 | } 92 | 93 | if (cfg.hasOwnProperty('required') && cfg.required === true && this.#data.describeRequired) { // eslint-disable-line no-prototype-builtins 94 | dsc.unshift('Required.') 95 | } 96 | 97 | dsc = dsc.join(' ').trim() 98 | 99 | rows.push(['--' + flag, aliases || '', dsc || '']) 100 | }) 101 | } 102 | 103 | const table = new Table(rows, this.#colAlign, this.#colWidth, this.#tableWidth, [2, 0, usage.length > 0 ? 1 : 0, 0]) 104 | 105 | let subcommands = '\n' + this.subcommands 106 | if (subcommands.trim().length === 0) { 107 | subcommands = '' 108 | } 109 | return usage + (flags.size > 0 ? '\n\nFlags:\n' + table.output : '') + subcommands 110 | } else if (this.#data instanceof Shell) { 111 | return [usage, this.subcommands].join('\n') 112 | } 113 | 114 | return '' 115 | } 116 | } 117 | 118 | export { Formatter as default, Formatter, Table } 119 | -------------------------------------------------------------------------------- /examples/cli/README.md: -------------------------------------------------------------------------------- 1 | # CLI example 2 | 3 | To try this out: 4 | 5 | 1. Clone the repo 6 | 1. Navigate to the `examples/cli` directory in your terminal/console. 7 | 1. Run `npm link`. This will make the command available globally. 8 | 9 | > 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. 10 | 11 | Next, start using it. 12 | 13 | > The name of the shell is defined in the package.json file (`bin` attribute). 14 | 15 | ```sh 16 | examples/cli> dir 17 | ``` 18 | 19 | You should see the following output: 20 | 21 | ```sh 22 | dir 1.0.0 23 | A simple file system utility. 24 | 25 | - list [ls] : List files of a directory. Optionally specify a 26 | long format. 27 | - export : Export a file representation of a directory. 28 | Has an additional subcommand. 29 | - mkdir [md, m] : Make a new directory. 30 | ``` 31 | 32 | ### Try the list Command 33 | 34 | ```sh 35 | examples/cli> dir list 36 | ``` 37 | _Output:_ 38 | ```sh 39 | README.md, index.js, package-lock.json, package.json 40 | ``` 41 | 42 | #### Same command, as an alias & a flag: 43 | 44 | ```sh 45 | examples/cli> dir ls -l 46 | ``` 47 | _`-l` is the "long format" flag defined in the list command (see index.js)._ 48 | 49 | _Output:_ 50 | ```sh 51 | - README.md 52 | - index.js 53 | - package-lock.json 54 | - package.json 55 | ``` 56 | 57 | ### Explore Another Command 58 | 59 | ```sh 60 | dir md -R /path/to/dir 61 | ``` 62 | 63 | ```sh 64 | ...make a directory... 65 | { 66 | command: 'mkdir', 67 | input: '-R /path/to/dir', 68 | flags: { 69 | recognized: { p: true }, 70 | unrecognized: [ '/path/to/dir' ] 71 | }, 72 | valid: true, 73 | violations: [], 74 | help: { requested: false } 75 | } 76 | ``` 77 | 78 | **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. 79 | 80 | Run the same command again with the help flag: 81 | ```sh 82 | dir md -R /path/to/dir --help 83 | ``` 84 | 85 | _Output:_ 86 | 87 | ```sh 88 | dir mkdir [OPTIONS] 89 | 90 | Make a new directory. 91 | 92 | Options: 93 | 94 | -p [-R] : Recursively create the directory if it does not 95 | already exist. 96 | ``` 97 | 98 | 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. 99 | 100 | _With `autohelp: false`_ 101 | 102 | ```sh 103 | { 104 | command: 'mkdir', 105 | input: '-R /path/to/dir -h', 106 | flags: { recognized: { p: true }, unrecognized: [ '/path/to/dir' ] }, 107 | valid: true, 108 | violations: [], 109 | help: { 110 | requested: true, 111 | message: 'dir mkdir [OPTIONS]\n' + 112 | '\n' + 113 | ' Make a new directory.\n' + 114 | '\n' + 115 | 'Options:\n' + 116 | '\n' + 117 | ' -p [-R] : Recursively create the directory if it does not\n' + 118 | ' already exist.\n' 119 | } 120 | } 121 | ``` 122 | 123 | > **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. 124 | 125 | ### Subcommands 126 | 127 | There is an example subcommand called `json`, which is part of the `export` family of commands. 128 | 129 | In this app, running the main command, `export`, isn't supposed to do anything. It shows the help message: 130 | 131 | ```sh 132 | dir export 133 | ``` 134 | 135 | _Output:_ 136 | ```sh 137 | dir export [OPTIONS] 138 | 139 | Export a file representation of a directory. 140 | 141 | Options: 142 | 143 | json: Generate a JSON representation of the directory. 144 | ``` 145 | 146 | The `json` **subcommand** is a little more interesting. It is supposed to display the file system as a JSON object. 147 | 148 | ```sh 149 | dir export json 150 | ``` 151 | 152 | _Outputs:_ 153 | 154 | ```sh 155 | { 156 | "/.../@author.io/shell/examples/cli": [ 157 | "README.md", 158 | "index.js", 159 | "package-lock.json", 160 | "package.json" 161 | ] 162 | } 163 | ``` 164 | 165 | You can see all of the subcommand features by applying the `--help` flag to the command: 166 | 167 | ```sh 168 | dir export json -help 169 | ``` 170 | 171 | _Outputs:_ 172 | 173 | ```sh 174 | dir export json [OPTIONS] 175 | 176 | Generate a JSON representation of the directory. 177 | 178 | Options: 179 | 180 | -array [-a] : Generate an array. This is a pretty long message 181 | for such a short explanation. Kinda Rube 182 | Goldbergish. 183 | ``` 184 | 185 | Notice there is an `array` flag for the `json` subcommand. 186 | 187 | ```sh 188 | dir export json -a 189 | ``` 190 | 191 | _Outputs:_ 192 | 193 | ```sh 194 | [ 'README.md', 'index.js', 'package-lock.json', 'package.json' ] 195 | ``` 196 | 197 | -------------------------------------------------------------------------------- /examples/json/README.md: -------------------------------------------------------------------------------- 1 | # CLI example 2 | 3 | To try this out: 4 | 5 | 1. Clone the repo 6 | 1. Navigate to the `examples/cli` directory in your terminal/console. 7 | 1. Run `npm link`. This will make the command available globally. 8 | 9 | > 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. 10 | 11 | Next, start using it. 12 | 13 | > The name of the shell is defined in the package.json file (`bin` attribute). 14 | 15 | ```sh 16 | examples/cli> dir 17 | ``` 18 | 19 | You should see the following output: 20 | 21 | ```sh 22 | dir 1.0.0 23 | A simple file system utility. 24 | 25 | - list [ls] : List files of a directory. Optionally specify a 26 | long format. 27 | - export : Export a file representation of a directory. 28 | Has an additional subcommand. 29 | - mkdir [md, m] : Make a new directory. 30 | ``` 31 | 32 | ### Try the list Command 33 | 34 | ```sh 35 | examples/cli> dir list 36 | ``` 37 | _Output:_ 38 | ```sh 39 | README.md, index.js, package-lock.json, package.json 40 | ``` 41 | 42 | #### Same command, as an alias & a flag: 43 | 44 | ```sh 45 | examples/cli> dir ls -l 46 | ``` 47 | _`-l` is the "long format" flag defined in the list command (see index.js)._ 48 | 49 | _Output:_ 50 | ```sh 51 | - README.md 52 | - index.js 53 | - package-lock.json 54 | - package.json 55 | ``` 56 | 57 | ### Explore Another Command 58 | 59 | ```sh 60 | dir md -R /path/to/dir 61 | ``` 62 | 63 | ```sh 64 | ...make a directory... 65 | { 66 | command: 'mkdir', 67 | input: '-R /path/to/dir', 68 | flags: { 69 | recognized: { p: true }, 70 | unrecognized: [ '/path/to/dir' ] 71 | }, 72 | valid: true, 73 | violations: [], 74 | help: { requested: false } 75 | } 76 | ``` 77 | 78 | **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. 79 | 80 | Run the same command again with the help flag: 81 | ```sh 82 | dir md -R /path/to/dir --help 83 | ``` 84 | 85 | _Output:_ 86 | 87 | ```sh 88 | dir mkdir [OPTIONS] 89 | 90 | Make a new directory. 91 | 92 | Options: 93 | 94 | -p [-R] : Recursively create the directory if it does not 95 | already exist. 96 | ``` 97 | 98 | 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. 99 | 100 | _With `autohelp: false`_ 101 | 102 | ```sh 103 | { 104 | command: 'mkdir', 105 | input: '-R /path/to/dir -h', 106 | flags: { recognized: { p: true }, unrecognized: [ '/path/to/dir' ] }, 107 | valid: true, 108 | violations: [], 109 | help: { 110 | requested: true, 111 | message: 'dir mkdir [OPTIONS]\n' + 112 | '\n' + 113 | ' Make a new directory.\n' + 114 | '\n' + 115 | 'Options:\n' + 116 | '\n' + 117 | ' -p [-R] : Recursively create the directory if it does not\n' + 118 | ' already exist.\n' 119 | } 120 | } 121 | ``` 122 | 123 | > **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. 124 | 125 | ### Subcommands 126 | 127 | There is an example subcommand called `json`, which is part of the `export` family of commands. 128 | 129 | In this app, running the main command, `export`, isn't supposed to do anything. It shows the help message: 130 | 131 | ```sh 132 | dir export 133 | ``` 134 | 135 | _Output:_ 136 | ```sh 137 | dir export [OPTIONS] 138 | 139 | Export a file representation of a directory. 140 | 141 | Options: 142 | 143 | json: Generate a JSON representation of the directory. 144 | ``` 145 | 146 | The `json` **subcommand** is a little more interesting. It is supposed to display the file system as a JSON object. 147 | 148 | ```sh 149 | dir export json 150 | ``` 151 | 152 | _Outputs:_ 153 | 154 | ```sh 155 | { 156 | "/.../@author.io/shell/examples/cli": [ 157 | "README.md", 158 | "index.js", 159 | "package-lock.json", 160 | "package.json" 161 | ] 162 | } 163 | ``` 164 | 165 | You can see all of the subcommand features by applying the `--help` flag to the command: 166 | 167 | ```sh 168 | dir export json -help 169 | ``` 170 | 171 | _Outputs:_ 172 | 173 | ```sh 174 | dir export json [OPTIONS] 175 | 176 | Generate a JSON representation of the directory. 177 | 178 | Options: 179 | 180 | -array [-a] : Generate an array. This is a pretty long message 181 | for such a short explanation. Kinda Rube 182 | Goldbergish. 183 | ``` 184 | 185 | Notice there is an `array` flag for the `json` subcommand. 186 | 187 | ```sh 188 | dir export json -a 189 | ``` 190 | 191 | _Outputs:_ 192 | 193 | ```sh 194 | [ 'README.md', 'index.js', 'package-lock.json', 'package.json' ] 195 | ``` 196 | 197 | -------------------------------------------------------------------------------- /tests/04-metadata.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell } from '@author.io/shell' 3 | 4 | test('Map unnamed argument', t => { 5 | const shell = new Shell({ 6 | name: 'account', 7 | commands: [{ 8 | name: 'create', 9 | arguments: 'email', 10 | handler (meta) { 11 | t.ok(meta.data.hasOwnProperty('email'), 'Automapped data exists.') 12 | t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) 13 | } 14 | }] 15 | }) 16 | 17 | shell.exec('create me@domain.com') 18 | .catch(e => t.fail(e.message)) 19 | .finally(() => t.end()) 20 | }) 21 | 22 | test('Map unnamed arguments', t => { 23 | const shell = new Shell({ 24 | name: 'account', 25 | commands: [{ 26 | name: 'create', 27 | arguments: 'email displayName', 28 | handler (meta) { 29 | t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') 30 | t.expect('me@domain.com', meta.data.email, 'Recognized email') 31 | t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName data exists.') 32 | console.log(meta.data) 33 | t.expect('John Doe', meta.data.displayName, 'Recognized displayName') 34 | } 35 | }] 36 | }) 37 | 38 | shell.exec('create me@domain.com "John Doe"') 39 | .catch(e => t.fail(e.message)) 40 | .finally(() => t.end()) 41 | }) 42 | 43 | test('Map extra unnamed arguments as unknown', t => { 44 | const shell = new Shell({ 45 | name: 'account', 46 | commands: [{ 47 | name: 'create', 48 | arguments: 'email displayName', 49 | handler (meta) { 50 | t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') 51 | t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) 52 | t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName data exists.') 53 | t.ok(meta.data.displayName === 'John Doe', `Attribute named displayName expected a value of "John Doe". Received "${meta.data.displayName}".`) 54 | t.ok(meta.data.hasOwnProperty('unknown1'), 'Automapped unknown property to generic name.') 55 | t.ok(meta.data.unknown1 === 'test1', `Unknown attribute expected a value of "test1". Received "${meta.data.unknown1}".`) 56 | t.ok(meta.data.hasOwnProperty('unknown2'), 'Automapped extra unknown property to generic name.') 57 | t.ok(meta.data.unknown2 === 'test2', `Extra unknown attribute expected a value of "test2". Received "${meta.data.unknown2}".`) 58 | } 59 | }] 60 | }) 61 | 62 | shell.exec('create me@domain.com "John Doe" test1 test2') 63 | .catch(e => t.fail(e.message)) 64 | .finally(() => t.end()) 65 | }) 66 | 67 | test('Map unnamed/unsupplied arguments as undefined', t => { 68 | const shell = new Shell({ 69 | name: 'account', 70 | commands: [{ 71 | name: 'create', 72 | arguments: 'email displayName', 73 | handler (meta) { 74 | t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') 75 | t.ok(meta.data.email === 'me@domain.com', `Attribute named email expected a value of "me@domain.com". Received "${meta.data.email}".`) 76 | t.ok(meta.data.hasOwnProperty('displayName'), 'Automapped displayName attribute exists.') 77 | t.ok(meta.data.displayName === undefined, `Attribute named displayName expected a value of "undefined". Received "${meta.data.displayName}".`) 78 | } 79 | }] 80 | }) 81 | 82 | shell.exec('create me@domain.com') 83 | .catch(e => t.fail(e.message)) 84 | .finally(() => t.end()) 85 | }) 86 | 87 | test('Map unnamed arguments when duplicate names are supplied', t => { 88 | const shell = new Shell({ 89 | name: 'account', 90 | commands: [{ 91 | name: 'create', 92 | flags: { 93 | email: { 94 | alias: 'e' 95 | } 96 | }, 97 | arguments: 'email displayName', 98 | handler (meta) { 99 | t.ok(meta.data.hasOwnProperty('email'), 'Automapped email data exists.') 100 | 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(', ')}]".`) 101 | t.ok(meta.data.displayName === undefined, `Attribute named displayName expected a value of "undefined". Received "${meta.data.displayName}".`) 102 | } 103 | }] 104 | }) 105 | 106 | shell.exec('create me@domain.com -e bob@other.com') 107 | .catch(e => t.fail(e.message)) 108 | .finally(() => t.end()) 109 | }) 110 | 111 | test('Ordered Named Arguments', t => { 112 | const shell = new Shell({ 113 | name: 'cli', 114 | commands: [{ 115 | name: 'account', 116 | description: 'Perform operations on a user account.', 117 | 118 | handler (meta, cb) { 119 | cb() 120 | }, 121 | 122 | commands: [ 123 | { 124 | name: 'create', 125 | description: 'Create a user account.', 126 | arguments: ['email', 'password'], 127 | 128 | flags: { 129 | name: { 130 | alias: 'n', 131 | description: 'Account display name' 132 | }, 133 | 134 | phone: { 135 | alias: 'p', 136 | description: 'Account phone number' 137 | }, 138 | 139 | avatar: { 140 | alias: 'a', 141 | description: 'Account avatar image URL' 142 | }, 143 | 144 | validate: { 145 | alias: 'v', 146 | description: 'Validate email' 147 | } 148 | }, 149 | 150 | handler (meta, cb) { 151 | t.ok(meta.flag('email') === 'test@domain.com', 'Correctly identifies first name argument') 152 | t.ok(meta.flag('password') === 'pwd', 'Correct identifies second name argument') 153 | t.end() 154 | } 155 | } 156 | ] 157 | }] 158 | }) 159 | 160 | shell.exec('account create test@domain.com pwd') 161 | }) 162 | -------------------------------------------------------------------------------- /examples/json/dir.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dir", 3 | "description": "A simple file system utility.", 4 | "version": "1.0.0", 5 | "commands": { 6 | "list": { 7 | "description": "List files of a directory. Optionally specify a long format.", 8 | "help": "dir list [OPTIONS]\n\n List files of a directory. Optionally specify a long format.\n\nOptions:\n\n -l : Long format.\n", 9 | "usage": "dir list [OPTIONS]\n\n List files of a directory. Optionally specify a long format.", 10 | "aliases": [ 11 | "ls" 12 | ], 13 | "flags": { 14 | "l": { 15 | "description": "Long format.", 16 | "default": false, 17 | "aliases": [] 18 | } 19 | }, 20 | "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 }", 21 | "commands": {}, 22 | "middleware": [] 23 | }, 24 | "export": { 25 | "description": "Export a file representation of a directory.", 26 | "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", 27 | "usage": "dir export [OPTIONS]\n\n Export a file representation of a directory.", 28 | "aliases": [], 29 | "flags": {}, 30 | "handler": "(data, cb) => {\n if (data.help && data.help.requested) {\n console.log(data.help.message)\n }\n\n cb && cb(data)\n }", 31 | "commands": { 32 | "json": { 33 | "description": "Generate a JSON representation of the directory.", 34 | "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", 35 | "usage": "dir export json [OPTIONS]\n\n Generate a JSON representation of the directory.", 36 | "aliases": [], 37 | "flags": { 38 | "array": { 39 | "type": "boolean", 40 | "default": false, 41 | "description": "Generate an array. This is a pretty long message for such a short explanation. Kinda Rube Goldbergish.", 42 | "aliases": [ 43 | "a" 44 | ] 45 | } 46 | }, 47 | "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 }", 48 | "commands": {}, 49 | "middleware": [] 50 | } 51 | }, 52 | "middleware": [] 53 | }, 54 | "mkdir": { 55 | "description": "Make a new directory.", 56 | "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", 57 | "usage": "dir mkdir [OPTIONS]\n\n Make a new directory.", 58 | "aliases": [ 59 | "md", 60 | "m" 61 | ], 62 | "flags": { 63 | "p": { 64 | "type": "boolean", 65 | "default": false, 66 | "description": "Recursively create the directory if it does not already exist.", 67 | "aliases": [ 68 | "R" 69 | ] 70 | } 71 | }, 72 | "handler": "function (data) {\n console.log('...make a directory...')\n console.log(data)\n }", 73 | "commands": {}, 74 | "middleware": [] 75 | }, 76 | "doc": { 77 | "description": "Output the metadoc of this shell.", 78 | "help": "dir doc [OPTIONS]\n\n Output the metadoc of this shell.\n\nOptions:\n\n -file [-f] \n", 79 | "usage": "dir doc [OPTIONS]\n\n Output the metadoc of this shell.", 80 | "aliases": [], 81 | "flags": { 82 | "file": { 83 | "name": "file", 84 | "aliases": [ 85 | "f" 86 | ] 87 | }, 88 | "help": { 89 | "description": "Display doc help.", 90 | "aliases": [ 91 | "h" 92 | ], 93 | "default": false, 94 | "type": "boolean", 95 | "name": "help" 96 | } 97 | }, 98 | "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 }", 99 | "commands": {}, 100 | "middleware": [] 101 | } 102 | }, 103 | "middleware": [ 104 | "(data, next) => {\n console.log('This middleware runs on every command.')\n next()\n}" 105 | ], 106 | "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", 107 | "usage": "dir 1.0.0\n\n A simple file system utility.", 108 | "defaultHandler": "(data, cb) => {\n if (data.help && data.help.requested) {\n console.log(data.help.message)\n }\n\n cb && cb(data)\n }", 109 | "authohelp": true, 110 | "runtime": "node", 111 | "maxHistoryItems": 100 112 | } -------------------------------------------------------------------------------- /tests/02-middleware.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Shell, Middleware } from '@author.io/shell' 3 | 4 | test('Sync Middleware', t => { 5 | const mw = new Middleware() 6 | 7 | mw.use((m, next) => { count++; next() }) 8 | mw.use((m, next) => { count++; next() }) 9 | mw.use((m, next) => { count++; next() }) 10 | 11 | let count = 0 12 | mw.run({ test: true }, () => { 13 | t.ok(count === 3, `Ran 3 middleware operations. Recognized ${count}.`) 14 | t.end() 15 | }) 16 | }) 17 | 18 | test('Async Middleware w/ Final Method', t => { 19 | const mw = new Middleware() 20 | 21 | mw.use((m, next) => { count++; next() }) 22 | mw.use((m, next) => setTimeout(() => { count++; next() }, 10)) 23 | mw.use((m, next) => { count++; next() }) 24 | 25 | let count = 0 26 | mw.run({ test: true }, () => { 27 | count++ 28 | t.ok(count === mw.size + 1, `Ran ${mw.size + 1} total middleware operations (w/ final op) with one async operation. Recognized ${count}.`) 29 | t.end() 30 | }) 31 | }) 32 | 33 | test('Async Middleware w/o Final Method', t => { 34 | const mw = new Middleware() 35 | 36 | mw.use(next => { count++; next() }) 37 | mw.use(next => { 38 | setTimeout(() => { count++; next() }, 10) 39 | }) 40 | mw.use(next => { count++; next() }) 41 | 42 | let count = 0 43 | 44 | mw.run() 45 | setTimeout(() => { 46 | t.ok(count === mw.size, `Ran ${mw.size} total middleware operations (w/o final op) with one async operation. Recognized ${count}.`) 47 | t.end() 48 | }, 300) 49 | }) 50 | 51 | test('Basic Shell & Command Middleware', t => { 52 | let ok = false 53 | 54 | const shell = new Shell({ 55 | name: 'test', 56 | commands: [{ 57 | name: 'a', 58 | handler () {}, 59 | commands: [{ 60 | name: 'b', 61 | use: [(meta, next) => { count++; next() }], 62 | handler () {}, 63 | commands: [{ 64 | name: 'c', 65 | handler () { 66 | ok = true 67 | } 68 | }] 69 | }] 70 | }] 71 | }) 72 | 73 | shell.use((meta, next) => { count++; next() }) 74 | 75 | let count = 0 76 | shell.exec('a b c') 77 | 78 | t.ok(count === 2, `Expected 2 middleware operations to run. Recognized ${count}.`) 79 | t.ok(ok, 'Handler executes at the end.') 80 | t.end() 81 | }) 82 | 83 | test('Command Specific Middleware', t => { 84 | let ok = false 85 | 86 | const shell = new Shell({ 87 | name: 'test', 88 | commands: [{ 89 | name: 'a', 90 | handler () { }, 91 | commands: [{ 92 | name: 'b', 93 | use: [(meta, next) => { count++; next() }], 94 | handler () { }, 95 | commands: [{ 96 | name: 'c', 97 | handler () { 98 | ok = true 99 | } 100 | }] 101 | }] 102 | }] 103 | }) 104 | 105 | shell.useWith(['a b'], (meta, next) => { count++; next() }) 106 | 107 | let count = 0 108 | shell.exec('a b c').then(r => { 109 | t.ok(count === 2, `Expected 2 middleware operations to run. Recognized ${count}.`) 110 | t.ok(ok, 'Handler executes at the end.') 111 | t.end() 112 | }) 113 | }) 114 | 115 | test('Command Specific Middleware Exceptions', async t => { 116 | let ok = false 117 | 118 | const shell = new Shell({ 119 | name: 'test', 120 | commands: [{ 121 | name: 'a', 122 | handler () { }, 123 | commands: [{ 124 | name: 'f', 125 | handler () { } 126 | }] 127 | }, { 128 | name: 'b', 129 | handler () { }, 130 | commands: [{ 131 | name: 'e', 132 | handler () {} 133 | }] 134 | }, { 135 | name: 'c', 136 | handler () { } 137 | }, { 138 | name: 'd', 139 | handler () { 140 | ok = true 141 | } 142 | }] 143 | }) 144 | 145 | shell.useExcept(['b', 'c'], (meta, next) => { count++; next() }) 146 | 147 | let count = 0 148 | await shell.exec('a').catch(t.fail) 149 | await shell.exec('b').catch(t.fail) 150 | await shell.exec('c').catch(t.fail) 151 | await shell.exec('d').catch(t.fail) 152 | await shell.exec('b e').catch(t.fail) 153 | await shell.exec('a f').catch(t.fail) 154 | 155 | t.ok(count === 3, `Expected 3 middleware operations to run. Recognized ${count}.`) 156 | t.ok(ok, 'Handler executes at the end.') 157 | t.end() 158 | }) 159 | 160 | test('Basic Trailers', t => { 161 | let ok = false 162 | let after = 0 163 | 164 | const shell = new Shell({ 165 | name: 'test', 166 | commands: [{ 167 | name: 'a', 168 | handler () { }, 169 | commands: [{ 170 | name: 'b', 171 | use: [(meta, next) => { next() }], 172 | handler () { }, 173 | commands: [{ 174 | name: 'c', 175 | handler () { 176 | ok = true 177 | }, 178 | trailer: [ 179 | (meta, next) => { after++; next() }, 180 | meta => { 181 | t.ok(after === 1, `Expected 1 trailer to run. Recognized ${after}.`) 182 | t.pass('Ran trailer.') 183 | t.end() 184 | } 185 | ] 186 | }] 187 | }] 188 | }] 189 | }) 190 | 191 | shell.exec('a b c') 192 | }) 193 | 194 | // test('Regression Test: Middleware Duplication', t => { 195 | // let count = 0 196 | 197 | // const shell = new Shell({ 198 | // name: 'Metadoc CLI', 199 | // version: '1.0.0', 200 | // use: [ 201 | // (meta, next) => { 202 | // console.log('A') 203 | // count++ 204 | // next() 205 | // } 206 | // ], 207 | // commands: [ 208 | // { 209 | // name: 'account', 210 | // description: 'Perform operations on a user account.', 211 | // handler: (meta, cb) => { }, 212 | // commands: [ 213 | // { 214 | // name: 'create', 215 | // description: 'Create a user account.', 216 | // arguments: '', 217 | // use: [(d, next) => { 218 | // console.log('C') 219 | // count++ 220 | // next() 221 | // }], 222 | // commands: [ 223 | // { 224 | // name: 'bleh', 225 | // handler () { }, 226 | // commands: [{ 227 | // name: 'more', 228 | // handler (meta) { } 229 | // }] 230 | // } 231 | // ], 232 | // handler: (meta, cb) => { } 233 | // } 234 | // ] 235 | // } 236 | // ] 237 | // }) 238 | 239 | // shell.use((meta, next) => { 240 | // setTimeout(() => { 241 | // console.log('B') 242 | // count++ 243 | // next() 244 | // }, 10) 245 | // }) 246 | 247 | // shell.exec('account create bleh').then(r => { 248 | // setTimeout(() => { 249 | // t.ok(count === 3, `Ran each middleware operation once. Expected 3 operations, recognized ${count}.`) 250 | // }, 300) 251 | // }).catch(e => t.fail(e.message)) 252 | // .finally(() => t.end()) 253 | // }) 254 | -------------------------------------------------------------------------------- /tests/01-sanity.js: -------------------------------------------------------------------------------- 1 | import test from 'tappedout' 2 | import { Command, Shell, Formatter } from '@author.io/shell' 3 | // import fs from 'fs' 4 | 5 | test('Sanity Check - Shell', t => { 6 | const shell = new Shell({ 7 | name: 'test' 8 | }) 9 | 10 | t.ok(shell instanceof Shell, 'Basic shell instantiates correctly.') 11 | t.ok(Object.getOwnPropertySymbols(globalThis).filter(s => globalThis[s] instanceof Shell).length === 1, 'The shell is discoverable.') 12 | t.end() 13 | }) 14 | 15 | test('Sanity Check - Command', t => { 16 | const mirror = new Command({ 17 | name: 'mirror', 18 | description: 'Search metadoc for all the things.', 19 | alias: 'search', 20 | handler: (input, cb) => { 21 | console.log(`Mirroring input: ${input}`) 22 | 23 | cb && cb() 24 | } 25 | }) 26 | 27 | t.ok(mirror instanceof Command, 'Command initialized successfully.') 28 | 29 | const CLI = new Shell({ 30 | name: 'test', 31 | description: 'test', 32 | commands: [ 33 | mirror, 34 | { 35 | name: 'test', 36 | description: 'Test command which has no custom handler.' 37 | } 38 | ] 39 | }) 40 | 41 | t.ok(CLI instanceof Shell, 'Shell initialized with commands successfully.') 42 | 43 | CLI.exec('test').then(data => { 44 | t.pass('Default handler fires.') 45 | t.end() 46 | }).catch(e => { 47 | t.fail(e.message) 48 | t.end() 49 | }) 50 | }) 51 | 52 | test('Output Formatting', t => { 53 | const shell = new Shell({ 54 | name: 'test' 55 | }) 56 | 57 | const cmd = new Command({ 58 | name: 'cmd', 59 | alias: 'c', 60 | flags: { 61 | test: { 62 | alias: 't', 63 | description: 'test description' 64 | }, 65 | more: { 66 | aliases: ['m', 'mr'], 67 | 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.' 68 | }, 69 | none: { 70 | description: 'Ignore me. I do not exist.' 71 | } 72 | } 73 | }) 74 | 75 | shell.add(cmd) 76 | 77 | const formatter = new Formatter(cmd) 78 | formatter.width = 80 79 | 80 | t.ok(formatter instanceof Formatter, 'Basic formatter instantiates correctly.') 81 | // fs.writeFileSync('./test.txt', Buffer.from(formatter.help)) 82 | // console.log(formatter.help) 83 | // t.expect(`test cmd|c [FLAGS] 84 | 85 | // Flags: 86 | 87 | // --test [-t] test description 88 | // --more [-m, -mr] This is a longer description that should break onto 89 | // more than one line, or perhaps even more than one 90 | // extra line with especially poor grammar and 91 | // spellling. 92 | // --none Ignore me. I do not exist. `, 93 | // formatter.help, 94 | // 'Correctly generated default help message.') 95 | 96 | t.expect(`test cmd|c [FLAGS] 97 | 98 | Flags: 99 | 100 | --test [-t] test description 101 | --more [-m, -mr] This is a longer description that should break onto 102 | more than one line, or perhaps even more than one 103 | extra line with especially poor grammar and 104 | spellling. 105 | --none Ignore me. I do not exist. `.replace(/\s+|\t+|\n+/gi, ''), 106 | formatter.help.replace(/\s+|\t+|\n+/gi, ''), 107 | 'Correctly generated default help message.') 108 | 109 | t.end() 110 | }) 111 | 112 | test('Subcommand Config', t => { 113 | let ok = false 114 | const cfg = { 115 | name: 'account', 116 | description: 'Perform operations on a user account.', 117 | handler (meta, cb) { 118 | console.log('TODO: Output account details') 119 | }, 120 | commands: [ 121 | { 122 | name: 'create', 123 | description: 'Create a user account.', 124 | arguments: '', 125 | flags: { 126 | name: { 127 | alias: 'n', 128 | description: 'Account display name' 129 | }, 130 | phone: { 131 | alias: 'p', 132 | description: 'Account phone number' 133 | }, 134 | avatar: { 135 | alias: 'a', 136 | description: 'Account avatar image URL' 137 | } 138 | }, 139 | 140 | handler (meta, cb) { 141 | ok = true 142 | } 143 | } 144 | ] 145 | } 146 | 147 | const shell = new Shell({ 148 | name: 'test', 149 | commands: [cfg] 150 | }) 151 | 152 | shell.exec('account create').then(r => { 153 | t.ok(ok, 'Configuring subcommands does not throw an error') 154 | }).catch(e => t.fail(e.message)) 155 | .finally(() => t.end()) 156 | }) 157 | 158 | test('Default command help (regression test)', t => { 159 | const shell = new Shell({ 160 | name: 'test', 161 | version: '1.0.0', 162 | disableHelp: true, 163 | commands: [ 164 | { 165 | name: 'account', 166 | description: 'Perform operations on a user account.', 167 | handler: (meta, cb) => {}, 168 | commands: [ 169 | { 170 | name: 'create', 171 | description: 'Create a user account.', 172 | arguments: '' 173 | } 174 | ] 175 | } 176 | ] 177 | }) 178 | 179 | shell.exec('account') 180 | 181 | t.pass('Ran without error.') 182 | t.end() 183 | }) 184 | 185 | test('Basic Introspection', t => { 186 | const shell = new Shell({ 187 | name: 'test', 188 | version: '1.0.0', 189 | disableHelp: true, 190 | commands: [ 191 | { 192 | name: 'account', 193 | description: 'Perform operations on a user account.', 194 | handler: (meta, cb) => { }, 195 | commands: [ 196 | { 197 | name: 'create', 198 | description: 'Create a user account.', 199 | arguments: '' 200 | } 201 | ] 202 | } 203 | ] 204 | }) 205 | 206 | t.ok(typeof shell.data === 'object', 'Generates a data object representing the shell.') 207 | t.end() 208 | }) 209 | 210 | test('Flag Default Configuration', t => { 211 | const cmd = new Command({ 212 | name: 'cmd', 213 | alias: 'c', 214 | flags: { 215 | test: { 216 | alias: 't', 217 | description: 'test description' 218 | }, 219 | more: { 220 | aliases: ['m', 'mr'], 221 | 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.' 222 | }, 223 | none: { 224 | description: 'Ignore me. I do not exist.' 225 | } 226 | } 227 | }) 228 | 229 | let f = cmd.getFlagConfiguration('test') 230 | t.ok( 231 | f.aliases[0] === 't' && 232 | f.description === 'test description' && 233 | f.required === false && 234 | f.type === 'string' && 235 | f.options === null, 236 | 'Returned default configuration items for a named flag.' 237 | ) 238 | 239 | f = cmd.getFlagConfiguration('t') 240 | t.ok( 241 | f.aliases[0] === 't' && 242 | f.description === 'test description' && 243 | f.required === false && 244 | f.type === 'string' && 245 | f.options === null, 246 | 'Returned default configuration items for an alias of a flag.' 247 | ) 248 | 249 | t.end() 250 | }) 251 | -------------------------------------------------------------------------------- /src/shell.js: -------------------------------------------------------------------------------- 1 | import Command from './command.js' 2 | import Base from './base.js' 3 | import { COMMAND_PATTERN } from './utility.js' 4 | 5 | export default class Shell extends Base { 6 | #middlewareGroups = new Map() 7 | #history = [] 8 | #maxHistoryItems 9 | #version 10 | #cursor = 0 11 | #tabWidth 12 | #runtime = globalThis.hasOwnProperty('window') // eslint-disable-line no-prototype-builtins 13 | ? 'browser' 14 | : ( 15 | globalThis.hasOwnProperty('process') && // eslint-disable-line no-prototype-builtins 16 | globalThis.process.release && 17 | globalThis.process.release.name 18 | ? globalThis.process.release.name 19 | : 'unknown' 20 | ) 21 | 22 | constructor (cfg = { maxhistory: 100 }) { 23 | super(cfg) 24 | 25 | this.initializeHelpAnnotations(cfg) 26 | 27 | this.__commonflags = cfg.commonflags || {} 28 | 29 | if (cfg.hasOwnProperty('use') && Array.isArray(cfg.use)) { // eslint-disable-line no-prototype-builtins 30 | cfg.use.forEach(code => this.initializeMiddleware(code)) 31 | } 32 | 33 | if (cfg.hasOwnProperty('trailer') && Array.isArray(cfg.trailer)) { // eslint-disable-line no-prototype-builtins 34 | cfg.trailer.forEach(code => this.initializeTrailer(code)) 35 | } 36 | 37 | this.#version = cfg.version || '1.0.0' 38 | this.#maxHistoryItems = cfg.maxhistory || cfg.maxHistoryItems || 100 39 | this.#tabWidth = cfg.hasOwnProperty('tabWidth') ? cfg.tabWidth : 4 // eslint-disable-line no-prototype-builtins 40 | 41 | // This sets a global symbol that dev tools can find. 42 | globalThis[Symbol('SHELL_INTEGRATIONS')] = this 43 | } 44 | 45 | get data () { 46 | const commands = super.data 47 | 48 | return { 49 | name: this.name, 50 | description: this.description, 51 | version: this.version, 52 | commands, 53 | use: this.middleware.data, 54 | trailer: this.trailers.data, 55 | help: this.help, 56 | usage: this.usage, 57 | defaultHandler: this.defaultHandler.toString(), 58 | disableHelp: !this.autohelp, 59 | runtime: this.#runtime, 60 | maxHistoryItems: this.#maxHistoryItems 61 | } 62 | } 63 | 64 | get version () { 65 | return this.#version || 'Unknown' 66 | } 67 | 68 | set tableWidth (value) { 69 | this.__width = value 70 | } 71 | 72 | get tableWidth () { 73 | return this.__width 74 | } 75 | 76 | history (count = null) { 77 | if (this.#history.length === 0) { 78 | return [] 79 | } 80 | 81 | return count === null ? this.#history.slice() : this.#history.slice(0, count) 82 | } 83 | 84 | priorCommand (count = 0) { 85 | if (this.#history.length === 0) { 86 | return null 87 | } 88 | 89 | if (count < 0) { 90 | return this.nextCommand(Math.abs(count)) 91 | } 92 | 93 | count = count % this.#history.length 94 | 95 | this.#cursor += count 96 | 97 | if (this.#cursor >= this.#history.length) { 98 | this.#cursor = this.#history.length - 1 99 | } 100 | 101 | return this.#history[this.#cursor].input 102 | } 103 | 104 | nextCommand (count = 1) { 105 | if (this.#history.length === 0) { 106 | return null 107 | } 108 | 109 | if (count < 0) { 110 | return this.priorCommand(Math.abs(count)) 111 | } 112 | 113 | count = count % this.#history.length 114 | 115 | this.#cursor -= count 116 | if (this.#cursor < 0) { 117 | this.#cursor = 0 118 | return undefined 119 | } 120 | 121 | return this.#history[this.#cursor].input 122 | } 123 | 124 | useWith (commands) { 125 | if (arguments.length < 2) { 126 | throw new Error('useWith([\'command\', \'command\'], fn) requires two or more arguments.') 127 | } 128 | 129 | commands = typeof commands === 'string' ? commands.split(/\s+/) : commands 130 | 131 | if (!Array.isArray(commands) || commands.filter(c => typeof c !== 'string').length > 0) { 132 | throw new Error(`The first argument of useWith must be a string or array of strings. Received ${typeof commands}`) 133 | } 134 | 135 | const fns = Array.from(arguments).slice(1) 136 | 137 | commands.forEach(cmd => this.#middlewareGroups.set(cmd.trim(), (this.#middlewareGroups.get(cmd.trim()) || []).concat(fns))) 138 | } 139 | 140 | useExcept (commands) { 141 | if (arguments.length < 2) { 142 | throw new Error('useExcept([\'command\', \'command\'], fn) requires two or more arguments.') 143 | } 144 | 145 | commands = typeof commands === 'string' ? commands.split(/\s+/) : commands 146 | 147 | if (!Array.isArray(commands) || commands.filter(c => typeof c !== 'string').length > 0) { 148 | throw new Error(`The first argument of useExcept must be a string or array of strings. Received ${typeof commands}`) 149 | } 150 | 151 | const fns = Array.from(arguments).slice(1) 152 | const all = new Set(this.commandlist.map(i => i.toLowerCase())) 153 | 154 | commands.forEach(cmd => { 155 | all.delete(cmd) 156 | for (const c of all) { 157 | if (c.indexOf(cmd) === 0) { 158 | all.delete(c) 159 | } 160 | } 161 | }) 162 | 163 | this.useWith(Array.from(all), ...fns) 164 | } 165 | 166 | async exec (input, callback) { 167 | // The array check exists because people are passing process.argv.slice(2) into this 168 | // method, often forgetting to join the values into a string. 169 | if (Array.isArray(input)) { 170 | input = input.map(i => { 171 | if (i.indexOf(' ') >= 0 && !/^[\"\'].+ [\"\']$/.test(i)) { 172 | return `"${i}"` 173 | } else { 174 | return i 175 | } 176 | }).join(' ') 177 | } 178 | 179 | this.#history.unshift({ input, time: new Date().toLocaleString() }) 180 | 181 | if (this.#history.length > this.#maxHistoryItems) { 182 | this.#history.pop() 183 | } 184 | 185 | let parsed = COMMAND_PATTERN.exec(input + ' ') 186 | 187 | if (parsed === null) { 188 | if (input.indexOf('version') !== -1 || input.indexOf('-v') !== -1) { 189 | return console.log(this.version) 190 | } else if (input.indexOf('help') !== -1) { 191 | return console.log(this.help) 192 | } 193 | 194 | return Command.stderr(this.help) 195 | } 196 | 197 | parsed = parsed.filter(item => item !== undefined) 198 | 199 | const cmd = parsed[1] 200 | const args = parsed.length > 2 ? parsed[2] : '' 201 | // const command = null 202 | 203 | const action = this.__commands.get(cmd) 204 | 205 | if (!action) { 206 | if (cmd.toLowerCase() === 'version') { 207 | return console.log(this.version) 208 | } 209 | 210 | return Command.stderr(this.help) 211 | } 212 | 213 | const processor = this.__processors.get(action) 214 | 215 | if (!processor) { 216 | return Command.stderr('Command not found.') 217 | } 218 | 219 | const term = processor.getTerminalCommand(args) 220 | return await Command.reply(await term.command.run(term.arguments, callback)) 221 | } 222 | 223 | getCommandMiddleware (cmd) { 224 | const results = [] 225 | cmd.split(/\s+/).forEach((c, i, a) => { 226 | const r = this.#middlewareGroups.get(a.slice(0, i + 1).join(' ')) 227 | r && results.push(r.flat(Infinity)) 228 | }) 229 | 230 | return results.flat(Infinity) 231 | } 232 | 233 | clearHistory () { 234 | this.#history = [] 235 | } 236 | 237 | // Clear the terminal 238 | clear () { 239 | this.#history = [] 240 | console.clear() 241 | // .write('\x1b[0f') // regular clear 242 | // .write('\x1b[2J') // full clear 243 | // .write('\033[0;0f') //ubuntu 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Tag, Release, & Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: 'Release' 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Checkout updated source code 14 | - uses: actions/checkout@v2 15 | name: Checkout Code 16 | 17 | - uses: actions/setup-node@v1 18 | name: Setup Node.js 19 | with: 20 | node-version: '16' 21 | 22 | - uses: actions/cache@v2 23 | name: Establish Docker Cache 24 | id: cache 25 | with: 26 | path: docker-cache 27 | key: ${{ runner.os }}-docker-${{ github.sha }} 28 | restore-keys: | 29 | ${{ runner.os }}-docker- 30 | 31 | - name: Load cached Docker layers 32 | run: | 33 | if [ -d "docker-cache" ]; then 34 | cat docker-cache/x* > my-image.tar 35 | docker load < my-image.tar 36 | rm -rf docker-cache 37 | fi 38 | 39 | - name: Setup Build Tooling 40 | id: setup 41 | run: | 42 | base=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-base/tags?page_size=1'|jq '."results"[]["name"]') 43 | base=$(sed -e 's/^"//' -e 's/"$//' <<<"$base") 44 | echo Retrieving author/dev/dev-base:$base 45 | docker pull author/dev-base:$base 46 | 47 | deno=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-deno/tags?page_size=1'|jq '."results"[]["name"]') 48 | deno=$(sed -e 's/^"//' -e 's/"$//' <<<"$deno") 49 | echo Retrieving author/dev/dev-deno:$deno 50 | docker pull author/dev-deno:$deno 51 | 52 | browser=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-browser/tags?page_size=1'|jq '."results"[]["name"]') 53 | browser=$(sed -e 's/^"//' -e 's/"$//' <<<"$browser") 54 | echo Retrieving author/dev/dev-browser:$browser 55 | docker pull author/dev-browser:$browser 56 | 57 | node=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-node/tags?page_size=1'|jq '."results"[]["name"]') 58 | node=$(sed -e 's/^"//' -e 's/"$//' <<<"$node") 59 | echo Retrieving author/dev/dev-node:$node 60 | docker pull author/dev-node:$node 61 | 62 | # 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(' '))" 63 | version=$(npm show @author.io/dev version) 64 | echo $version 65 | npm i -g @author.io/dev@$version 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | 69 | - name: Test 70 | if: success() 71 | run: | 72 | npm i 73 | dev -v 74 | npm run ci 75 | 76 | - name: Tag 77 | id: autotagger 78 | if: success() 79 | uses: butlerlogic/action-autotag@stable 80 | with: 81 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 82 | 83 | # If the new version/tag is a pre-release (i.e. 1.0.0-beta.1), create 84 | # an environment variable indicating it is a prerelease. 85 | - name: Pre-release 86 | if: steps.autotagger.outputs.tagname != '' 87 | run: | 88 | if [[ "${{ steps.autotagger.output.version }}" == *"-"* ]]; then echo "::set-env IS_PRERELEASE=true";else echo "::set-env IS_PRERELEASE=''";fi 89 | 90 | - name: Release 91 | id: create_release 92 | if: steps.autotagger.outputs.tagname != '' 93 | uses: actions/create-release@v1.0.0 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | with: 97 | tag_name: ${{ steps.autotagger.outputs.tagname }} 98 | release_name: ${{ steps.autotagger.outputs.tagname }} 99 | body: ${{ steps.autotagger.outputs.tagmessage }} 100 | draft: false 101 | prerelease: env.IS_PRERELEASE != '' 102 | 103 | # Build tarballs of the module code. 104 | - name: Build Release Artifacts 105 | id: build_release 106 | if: steps.create_release.outputs.id != '' 107 | run: | 108 | ln -s /node_modules ./node_modules 109 | dev build --pack --mode ci --peer 110 | cp -rf .dist ./dist 111 | 112 | # Upload tarballs to the release. 113 | - name: Upload Release Artifacts 114 | uses: AButler/upload-release-assets@v2.0 115 | if: steps.create_release.outputs.id != '' 116 | with: 117 | files: '.dist/*.tar.gz' 118 | repo-token: ${{ secrets.GITHUB_TOKEN }} 119 | release-tag: ${{ steps.autotagger.outputs.tagname }} 120 | 121 | - name: Publish to npm 122 | id: publish_npm 123 | if: steps.autotagger.outputs.tagname != '' 124 | uses: author/action-publish@stable 125 | with: 126 | scan: ./dist 127 | env: 128 | REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} 129 | 130 | - name: Rollback Release 131 | if: failure() && steps.create_release.outputs.id != '' 132 | uses: author/action-rollback@stable 133 | with: 134 | tag: ${{ steps.autotagger.outputs.tagname }} 135 | env: 136 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 137 | 138 | - name: Failure Notification 139 | if: failure() && steps.create_release.outputs.id != '' 140 | env: 141 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 142 | SLACK_USERNAME: Github # Optional. (defaults to webhook app) 143 | SLACK_CHANNEL: author # Optional. (defaults to webhook) 144 | SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" 145 | uses: Ilshidur/action-slack@master 146 | with: 147 | args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} failed to publish and was rolled back.' # Optional 148 | 149 | - name: Success Notification 150 | if: success() && steps.autotagger.outputs.tagname != '' 151 | env: 152 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 153 | SLACK_USERNAME: Github # Optional. (defaults to webhook app) 154 | SLACK_CHANNEL: author # Optional. (defaults to webhook) 155 | SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" 156 | uses: Ilshidur/action-slack@master 157 | with: 158 | args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} published to npm.' # Optional 159 | 160 | - name: Inaction Notification 161 | if: steps.autotagger.outputs.tagname == '' 162 | env: 163 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 164 | SLACK_USERNAME: Github # Optional. (defaults to webhook app) 165 | SLACK_CHANNEL: author # Optional. (defaults to webhook) 166 | 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) 167 | uses: Ilshidur/action-slack@master 168 | with: 169 | args: "New code was added to author/shell master branch." # Optional 170 | 171 | 172 | 173 | 174 | 175 | # # If the version has changed, create a new git tag for it. 176 | # - name: Tag 177 | # id: autotagger 178 | # uses: butlerlogic/action-autotag@master 179 | # env: 180 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 181 | 182 | # # The remaining steps all depend on whether or not 183 | # # a new tag was created. There is no need to release/publish 184 | # # updates until the code base is in a releaseable state. 185 | 186 | # # If the new version/tag is a pre-release (i.e. 1.0.0-beta.1), create 187 | # # an environment variable indicating it is a prerelease. 188 | # - name: Pre-release 189 | # if: steps.autotagger.outputs.tagname != '' 190 | # run: | 191 | # if [[ "${{ steps.autotagger.output.version }}" == *"-"* ]]; then echo "::set-env IS_PRERELEASE=true";else echo "::set-env IS_PRERELEASE=''";fi 192 | 193 | # # Create a github release 194 | # # This will create a snapshot of the module, 195 | # # available in the "Releases" section on Github. 196 | # - name: Release 197 | # id: create_release 198 | # if: steps.autotagger.outputs.tagname != '' 199 | # uses: actions/create-release@v1.0.0 200 | # env: 201 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 202 | # with: 203 | # tag_name: ${{ steps.autotagger.outputs.tagname }} 204 | # release_name: ${{ steps.autotagger.outputs.tagname }} 205 | # body: ${{ steps.autotagger.outputs.tagmessage }} 206 | # draft: false 207 | # prerelease: env.IS_PRERELEASE != '' 208 | 209 | # - uses: actions/setup-node@v1 210 | # if: steps.create_release.outputs.id != '' 211 | # with: 212 | # node-version: '13' 213 | 214 | # # Build tarballs of the module code. 215 | # - name: Build Release Artifacts 216 | # id: build_release 217 | # if: steps.create_release.outputs.id != '' 218 | # run: | 219 | # npm install 220 | # cd ./build && npm install && cd ../ 221 | # npm run build --if-present 222 | # for d in .dist/*/*/ ; do tar -cvzf ${d%%/}-${{ steps.autotagger.outputs.version }}.tar.gz ${d%%}*; done; 223 | # if [[ ${{ github.ref }} == *"-"* ]]; then echo ::set-output isprerelease=true;else echo ::set-output isprerelease=false;fi 224 | # # Upload tarballs to the release. 225 | # - name: Upload Release Artifacts 226 | # uses: AButler/upload-release-assets@v2.0 227 | # if: steps.create_release.outputs.id != '' 228 | # with: 229 | # files: './.dist/**/*.tar.gz' 230 | # repo-token: ${{ secrets.GITHUB_TOKEN }} 231 | # release-tag: ${{ steps.autotagger.outputs.tagname }} 232 | 233 | # # Build npm packages 234 | # - name: Build Module Artifacts 235 | # id: build_npm 236 | # if: steps.create_release.outputs.id != '' 237 | # run: | 238 | # npm install 239 | # cd ./build && npm install && cd ../ 240 | # npm run build --if-present 241 | # # Use this action to publish a single module to npm. 242 | # - name: Publish 243 | # id: publish_npm 244 | # if: steps.autotagger.outputs.tagname != '' 245 | # uses: author/action-publish@master 246 | # with: 247 | # scan: .dist 248 | # env: 249 | # REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} 250 | 251 | # - name: Rollback Release 252 | # if: failure() && steps.create_release.outputs.id != '' 253 | # uses: author/action-rollback@stable 254 | # with: 255 | # tag: ${{ steps.autotagger.outputs.tagname }} 256 | # env: 257 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 258 | 259 | # - name: Failure Notification 260 | # if: failure() && steps.create_release.outputs.id != '' 261 | # env: 262 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 263 | # SLACK_USERNAME: Github # Optional. (defaults to webhook app) 264 | # SLACK_CHANNEL: author # Optional. (defaults to webhook) 265 | # SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" 266 | # uses: Ilshidur/action-slack@master 267 | # with: 268 | # args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} failed to publish and was rolled back.' # Optional 269 | 270 | # - name: Success Notification 271 | # if: success() && steps.autotagger.outputs.tagname != '' 272 | # env: 273 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 274 | # SLACK_USERNAME: Github # Optional. (defaults to webhook app) 275 | # SLACK_CHANNEL: author # Optional. (defaults to webhook) 276 | # SLACK_AVATAR: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/320px-Npm-logo.svg.png" 277 | # uses: Ilshidur/action-slack@master 278 | # with: 279 | # args: '@author.io/shell ${{ steps.autotagger.outputs.tagname }} published to npm.' # Optional 280 | 281 | # - name: Inaction Notification 282 | # if: steps.autotagger.outputs.tagname == '' 283 | # env: 284 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 285 | # SLACK_USERNAME: Github # Optional. (defaults to webhook app) 286 | # SLACK_CHANNEL: author # Optional. (defaults to webhook) 287 | # 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) 288 | # uses: Ilshidur/action-slack@master 289 | # with: 290 | # args: "New code was added to author/shell master branch." # Optional -------------------------------------------------------------------------------- /src/base.js: -------------------------------------------------------------------------------- 1 | import Middleware from './middleware.js' 2 | import Formatter from './format.js' 3 | import Shell from './shell.js' 4 | import Command from './command.js' 5 | 6 | export default class Base { 7 | #plugins = {} 8 | #url = null 9 | #support = null 10 | #formattedDefaultHelp 11 | #description 12 | #customUsage 13 | #customHelp 14 | #arguments = new Set() 15 | #autohelp = true 16 | #processors = new Map() 17 | #commands = new Map() 18 | #width = 80 19 | #name = 'Unknown' 20 | #middleware = new Middleware() 21 | #trailer = new Middleware() 22 | #commonflags = {} 23 | #display = { 24 | // These are all null, representing they're NOT configured. 25 | Default: null, 26 | Options: null, 27 | MultipleValues: null, 28 | Required: null 29 | } 30 | 31 | #hasCustomDefaultHandler = false 32 | 33 | #defaultHandler = function (meta) { 34 | if (this.parent !== null && this.parent.hasCustomDefaultHandler) { 35 | return this.parent.defaultHandler(...arguments) 36 | } else if (this.shell && this.shell !== null && this.shell.hasCustomDefaultHandler) { 37 | return this.shell.defaultHandler(...arguments) 38 | } 39 | 40 | if (this.#autohelp) { 41 | console.log(this.help) 42 | } 43 | } 44 | 45 | constructor (cfg = {}) { 46 | if (typeof cfg !== 'object') { 47 | throw new Error('Invalid command configuration. Expected an object.') 48 | } 49 | 50 | if (!cfg.hasOwnProperty('name')) { // eslint-disable-line no-prototype-builtins 51 | throw new Error('Invalid command configuration. A "name" attribute is required.') 52 | } 53 | 54 | if (cfg.hasOwnProperty('help')) { // eslint-disable-line no-prototype-builtins 55 | this.#customHelp = cfg.help 56 | } 57 | 58 | if (cfg.hasOwnProperty('usage')) { // eslint-disable-line no-prototype-builtins 59 | this.#customUsage = cfg.usage 60 | } 61 | 62 | if (cfg.hasOwnProperty('disablehelp') && !cfg.hasOwnProperty('disableHelp')) { // eslint-disable-line no-prototype-builtins 63 | cfg.disableHelp = cfg.disablehelp 64 | } 65 | 66 | if (cfg.hasOwnProperty('disableHelp') && cfg.disableHelp === true) { // eslint-disable-line no-prototype-builtins 67 | this.#autohelp = false 68 | } 69 | 70 | if (typeof cfg.help === 'function' || typeof cfg.help === 'string') { 71 | this.help = cfg.help 72 | } 73 | 74 | if (typeof cfg.usage === 'function' || typeof cfg.usage === 'string') { 75 | this.usage = cfg.usage 76 | } 77 | 78 | if (cfg.hasOwnProperty('defaultHandler') && cfg.defaultHandler.toString() !== this.#defaultHandler.toString()) { // eslint-disable-line no-prototype-builtins 79 | this.defaultHandler = cfg.defaultHandler 80 | } 81 | 82 | if (typeof cfg.arguments === 'string') { 83 | cfg.arguments = cfg.arguments.split(/\s+|\t+|\,+|\;+/).map(arg => arg.trim()) // eslint-disable-line no-useless-escape 84 | } 85 | 86 | if (Array.isArray(cfg.arguments)) { 87 | this.#arguments = new Set(cfg.arguments) 88 | } 89 | 90 | if (typeof cfg.url === 'string') { 91 | this.#url = cfg.url 92 | } 93 | 94 | if (typeof cfg.support === 'string') { 95 | this.#support = cfg.support 96 | } 97 | 98 | this.#name = (cfg.name || 'unknown').trim().split(/\s+/)[0] 99 | this.#description = cfg.description || null 100 | 101 | if (Array.isArray(cfg.commands)) { 102 | cfg.commands.forEach(cmd => this.add(cmd)) 103 | } else if (typeof cfg.commands === 'object') { 104 | for (const key in cfg.commands) { 105 | const data = cfg.commands[key] 106 | data.name = key 107 | this.add(data) 108 | } 109 | } 110 | 111 | if (cfg.hasOwnProperty('middleware')) { // eslint-disable-line no-prototype-builtins 112 | console.warn('The "middleware" attribute has been replaced with the "use" attribute.') 113 | cfg.use = cfg.middleware 114 | delete cfg.middleware 115 | } 116 | 117 | if (!cfg.hasOwnProperty('commonflag')) { // eslint-disable-line no-prototype-builtins 118 | if (cfg.hasOwnProperty('commonFlag')) { // eslint-disable-line no-prototype-builtins 119 | cfg.commonflag = cfg.commonFlag 120 | } else if (cfg.hasOwnProperty('commonflags')) { // eslint-disable-line no-prototype-builtins 121 | cfg.commonflag = cfg.commonflags 122 | } else if (cfg.hasOwnProperty('commonFlag')) { // eslint-disable-line no-prototype-builtins 123 | cfg.commonflag = cfg.commonFlag 124 | } else if (cfg.hasOwnProperty('commonFlags')) { // eslint-disable-line no-prototype-builtins 125 | cfg.commonflag = cfg.commonFlags 126 | } 127 | } 128 | 129 | if (cfg.hasOwnProperty('commonflag')) { // eslint-disable-line no-prototype-builtins 130 | if (typeof cfg.commonflag !== 'object') { 131 | throw new Error('The "commonflag" configuration attribute must be an object.') 132 | } 133 | } 134 | 135 | if (typeof cfg.plugins === 'object') { 136 | this.#plugins = cfg.plugins 137 | } 138 | 139 | Object.defineProperties(this, { 140 | __processors: { 141 | enumerable: false, 142 | get () { 143 | return this.#processors 144 | } 145 | }, 146 | __commands: { 147 | enumerable: false, 148 | get () { 149 | return this.#commands 150 | } 151 | }, 152 | __width: { 153 | enumerable: false, 154 | get () { 155 | return this.#width 156 | }, 157 | set (v) { 158 | this.#width = v || 80 159 | } 160 | }, 161 | __commonflags: { 162 | enumerable: false, 163 | get () { 164 | return this.#commonflags 165 | }, 166 | set (value) { 167 | this.#commonflags = value 168 | } 169 | }, 170 | arguments: { 171 | enumerable: false, 172 | get () { 173 | return this.#arguments 174 | } 175 | }, 176 | initializeMiddleware: { 177 | enumerable: false, 178 | configurable: false, 179 | writable: false, 180 | value: code => { 181 | if (typeof code === 'string') { 182 | this.use(Function('return ' + code)()) // eslint-disable-line no-new-func 183 | } else if (typeof code === 'function') { 184 | this.use(code) 185 | } else { 186 | throw new Error('Invalid middleware: ' + code.toString()) 187 | } 188 | } 189 | }, 190 | initializeTrailer: { 191 | enumerable: false, 192 | configurable: false, 193 | writable: false, 194 | value: code => { 195 | if (typeof code === 'string') { 196 | this.trailer(Function('return ' + code)()) // eslint-disable-line no-new-func 197 | } else if (typeof code === 'function') { 198 | this.trailer(code) 199 | } else { 200 | throw new Error('Invalid trailer: ' + code.toString()) 201 | } 202 | } 203 | }, 204 | initializeHelpAnnotations: { 205 | enumerable: false, 206 | configurable: false, 207 | writable: false, 208 | value: cfg => { 209 | if (cfg.hasOwnProperty('describeDefault') && typeof cfg.describeDefault === 'boolean') { // eslint-disable-line no-prototype-builtins 210 | this.#display.Default = cfg.describeDefault 211 | } 212 | if (cfg.hasOwnProperty('describeOptions') && typeof cfg.describeOptions === 'boolean') { // eslint-disable-line no-prototype-builtins 213 | this.#display.Options = cfg.describeOptions 214 | } 215 | if (cfg.hasOwnProperty('describeMultipleValues') && typeof cfg.describeMultipleValues === 'boolean') { // eslint-disable-line no-prototype-builtins 216 | this.#display.MultipleValues = cfg.describeMultipleValues 217 | } 218 | if (cfg.hasOwnProperty('describeRequired') && typeof cfg.describeRequired === 'boolean') { // eslint-disable-line no-prototype-builtins 219 | this.#display.Required = cfg.describeRequired 220 | } 221 | } 222 | } 223 | }) 224 | 225 | this.updateHelp() 226 | } 227 | 228 | get plugins () { 229 | return this.#plugins 230 | } 231 | 232 | // @readonly 233 | get name () { 234 | return this.#name || 'Unknown' 235 | } 236 | 237 | // @readonly 238 | get description () { 239 | return this.#description || this.usage || '' 240 | } 241 | 242 | // @readonly 243 | get url () { 244 | return this.URL 245 | } 246 | 247 | // @readonly 248 | get URL () { 249 | const uri = (this.#url || '').trim() 250 | 251 | if (uri.length === 0) { 252 | if (this.hasOwnProperty('parent')) { // eslint-disable-line no-prototype-builtins 253 | return this.parent.URL 254 | } else if (this instanceof Command) { 255 | return this.shell.URL 256 | } 257 | } 258 | 259 | return uri 260 | } 261 | 262 | // @readonly 263 | get support () { 264 | const support = (this.#support || '').trim() 265 | 266 | if (support.length === 0) { 267 | if (this.hasOwnProperty('parent')) { // eslint-disable-line no-prototype-builtins 268 | return this.parent.support 269 | } else if (this instanceof Command) { 270 | return this.shell.support 271 | } 272 | } 273 | 274 | return support 275 | } 276 | 277 | get autohelp () { 278 | return this.#autohelp 279 | } 280 | 281 | set autohelp (value) { 282 | if (typeof value !== 'boolean') { 283 | return 284 | } 285 | this.#autohelp = value 286 | this.#processors.forEach(cmd => { cmd.autohelp = value }) 287 | } 288 | 289 | updateHelp () { 290 | this.#formattedDefaultHelp = new Formatter(this) 291 | this.#formattedDefaultHelp.width = this.#width 292 | } 293 | 294 | describeHelp (attr, prop) { 295 | if (this.#display[prop] !== null) { 296 | return this.#display[prop] 297 | } 298 | 299 | if (this instanceof Command) { 300 | if (this.shell && this.shell !== null && this.shell[attr] !== null) { 301 | return this.shell[attr] 302 | } 303 | 304 | if (this.parent !== null) { 305 | return this.parent[attr] 306 | } 307 | } 308 | 309 | return true 310 | } 311 | 312 | get describeDefault () { 313 | return this.describeHelp('describeDefault', 'Default') 314 | } 315 | 316 | get describeOptions () { 317 | return this.describeHelp('describeOptions', 'Options') 318 | } 319 | 320 | get describeMultipleValues () { 321 | return this.describeHelp('describeMultipleValues', 'MultipleValues') 322 | } 323 | 324 | get describeRequired () { 325 | return this.describeHelp('describeRequired', 'Required') 326 | } 327 | 328 | get usage () { 329 | if (this.#customUsage !== null) { 330 | return typeof this.#customUsage === 'function' ? this.#customUsage() : this.#customUsage 331 | } 332 | 333 | return this.#formattedDefaultHelp.usage 334 | } 335 | 336 | set usage (value) { 337 | if (typeof value === 'string' && value.trim().length === 0) { 338 | value = null 339 | } 340 | 341 | this.#customUsage = value 342 | 343 | this.updateHelp() 344 | } 345 | 346 | get help () { 347 | if (this.#customHelp) { 348 | return typeof this.#customHelp === 'function' ? this.#customHelp(this) : this.#customHelp 349 | } 350 | 351 | if (!this.autohelp) { 352 | return '' 353 | } 354 | 355 | return this.#formattedDefaultHelp.help 356 | } 357 | 358 | set help (value) { 359 | if (typeof value === 'string' && value.trim().length === 0) { 360 | value = null 361 | } 362 | 363 | this.#customHelp = value 364 | 365 | this.updateHelp() 366 | } 367 | 368 | // @private 369 | set defaultHandler (value) { 370 | if (typeof value === 'function') { 371 | this.#defaultHandler = value 372 | this.#hasCustomDefaultHandler = true 373 | this.#processors.forEach(cmd => { cmd.defaultProcessor = value }) 374 | } else { 375 | throw new Error(`Invalid default method (must be a function, not "${typeof value}").`) 376 | } 377 | } 378 | 379 | get defaultHandler () { 380 | return this.#defaultHandler 381 | } 382 | 383 | // @private 384 | get hasCustomDefaultHandler () { 385 | return this.#hasCustomDefaultHandler 386 | } 387 | 388 | get data () { 389 | const commands = {} 390 | 391 | Array.from(this.#processors.values()).forEach(cmd => { 392 | const data = cmd.data 393 | const name = data.name 394 | delete data.name 395 | commands[name] = data 396 | }) 397 | 398 | return commands 399 | } 400 | 401 | get middleware () { 402 | return this.#middleware 403 | } 404 | 405 | get trailers () { 406 | return this.#trailer 407 | } 408 | 409 | get commands () { 410 | return this.#processors 411 | } 412 | 413 | get commandlist () { 414 | const list = new Set() 415 | this.commands.forEach(cmd => { 416 | list.add(cmd.name) 417 | cmd.commandlist.forEach(subcmd => list.add(`${cmd.name} ${subcmd}`)) 418 | }) 419 | 420 | return Array.from(list).sort() 421 | } 422 | 423 | getCommand (name = null) { 424 | if (!name) { 425 | return null 426 | } 427 | 428 | const names = name.split(/\s+/i) 429 | let cmd = this.#commands.get(names.shift()) 430 | if (cmd) { 431 | cmd = this.#processors.get(cmd) 432 | for (const nm of names) { 433 | cmd = cmd.getCommand(nm) 434 | } 435 | } 436 | 437 | return cmd instanceof Command ? cmd : null 438 | } 439 | 440 | remove () { 441 | for (const cmd of arguments) { 442 | if (typeof cmd === 'symbol') { 443 | this.#processors.delete(cmd) 444 | this.#commands.forEach(oid => oid === cmd && this.#commands.delete(oid)) 445 | } 446 | 447 | if (typeof cmd === 'string') { 448 | const OID = this.#commands.get(cmd) 449 | if (OID) { 450 | this.remove(OID) 451 | } 452 | } 453 | } 454 | } 455 | 456 | use () { 457 | for (const arg of arguments) { 458 | if (typeof arg !== 'function') { 459 | throw new Error(`All "use()" arguments must be valid functions.\n${arg.toString().substring(0, 50)} ${arg.toString().length > 50 ? '...' : ''}`) 460 | } 461 | 462 | this.#middleware.use(arg) 463 | } 464 | 465 | this.#processors.forEach(subCmd => subCmd.use(...arguments)) 466 | } 467 | 468 | trailer () { 469 | this.#trailer = this.#trailer || new Middleware() 470 | 471 | for (const arg of arguments) { 472 | if (typeof arg !== 'function') { 473 | throw new Error(`All "trailer()" arguments must be valid functions.\n${arg.toString().substring(0, 50)} ${arg.toString().length > 50 ? '...' : ''}`) 474 | } 475 | 476 | this.#trailer.use(arg) 477 | } 478 | 479 | this.#processors.forEach(subCmd => subCmd.trailer(...arguments)) 480 | } 481 | 482 | add () { 483 | for (let command of arguments) { 484 | if (!(command instanceof Command)) { 485 | if (typeof command === 'object') { 486 | command = new Command(command) 487 | } else { 488 | throw new Error('Invalid argument. Only "Command" instances may be added to the processor.') 489 | } 490 | } 491 | 492 | command.autohelp = this.autohelp 493 | 494 | if (this instanceof Shell) { 495 | command.shell = this 496 | } else if (this instanceof Command) { 497 | command.parent = this 498 | } 499 | 500 | this.#processors.set(command.OID, command) 501 | this.#commands.set(command.name, command.OID) 502 | 503 | command.aliases.forEach(alias => this.#commands.set(alias, command.OID)) 504 | } 505 | } 506 | } 507 | -------------------------------------------------------------------------------- /src/command.js: -------------------------------------------------------------------------------- 1 | import { Parser } from '../node_modules/@author.io/arg/index.js' 2 | // import { Parser } from '@author.io/arg' 3 | import Shell from './shell.js' 4 | import Base from './base.js' 5 | import { METHOD_PATTERN, FLAG_PATTERN, STRIP_QUOTE_PATTERN } from './utility.js' 6 | 7 | export default class Command extends Base { 8 | #pattern 9 | #oid 10 | #aliases = new Set() 11 | #fn 12 | #flagConfig = {} 13 | #parent = null 14 | #shell = null 15 | 16 | constructor (cfg = {}) { 17 | if (cfg.hasOwnProperty('handler')) { // eslint-disable-line no-prototype-builtins 18 | if (typeof cfg.handler === 'string') { 19 | cfg.handler = Function('return (' + cfg.handler.replace('function anonymous', 'function') + ').call(this)').call(globalThis) // eslint-disable-line no-new-func 20 | } 21 | 22 | if (typeof cfg.handler !== 'function') { 23 | throw new Error('Invalid command configuration. A "handler" function is required.') 24 | } 25 | } 26 | 27 | super(cfg) 28 | 29 | if (cfg.hasOwnProperty('use') && Array.isArray(cfg.use)) { // eslint-disable-line no-prototype-builtins 30 | cfg.use.forEach(code => this.initializeMiddleware(code)) 31 | } 32 | 33 | if (cfg.hasOwnProperty('trailer') && Array.isArray(cfg.trailer)) { // eslint-disable-line no-prototype-builtins 34 | cfg.trailer.forEach(code => this.initializeTrailer(code)) 35 | } 36 | 37 | this.initializeHelpAnnotations(cfg) 38 | 39 | this.#fn = cfg.handler 40 | this.#oid = Symbol(((cfg.name || cfg.usage) || cfg.pattern) || 'command') 41 | this.#pattern = cfg.pattern || /[\s\S]+/i 42 | 43 | if (cfg.alias && !cfg.aliases) { 44 | cfg.aliases = typeof cfg.alias === 'string' ? [cfg.alias] : (Array.isArray(cfg.alias) ? cfg.alias : Array.from(cfg.alias)) 45 | delete cfg.alias 46 | } 47 | 48 | if (cfg.aliases) { 49 | if (!Array.isArray(cfg.aliases)) { 50 | throw new Error('The alias property only accepts an array.') 51 | } 52 | 53 | this.#aliases = new Set(cfg.aliases) 54 | } 55 | 56 | if (cfg.flags) { 57 | if (typeof cfg.flags !== 'object') { 58 | throw new Error(`Invalid flag configuration (expected and object, received ${typeof cfg.flags}).`) 59 | } 60 | 61 | for (const [key, value] of Object.entries(cfg.flags)) { 62 | if (value.hasOwnProperty('alias')) { // eslint-disable-line no-prototype-builtins 63 | value.aliases = value.aliases || [] 64 | 65 | if (Array.isArray(value.alias)) { 66 | value.aliases = Array.from(new Set(...value.aliases, ...value.alias)) 67 | if (value.aliases.filter(a => typeof a !== 'string') > 0) { 68 | throw new Error(`${key} flag aliases must be strings. Type failure on: ${value.aliases.filter(a => typeof a !== 'string').join(', ')}.`) 69 | } 70 | } else if (typeof value.alias === 'string') { 71 | value.aliases.push(value.alias) 72 | } else { 73 | throw new Error(`Aliases must be strings, not ${typeof value.alias} (${key} flag).`) 74 | } 75 | 76 | delete value.alias 77 | } 78 | } 79 | 80 | this.#flagConfig = cfg.flags 81 | } 82 | 83 | if (Array.isArray(cfg.subcommands)) { 84 | this.add(...cfg.subcommands) 85 | } 86 | 87 | const attributes = new Set([ 88 | 'commands', 89 | 'subcommands', 90 | 'plugins', 91 | 'defaultHandler', 92 | 'disableHelp', 93 | 'describeDefault', 94 | 'describeOptions', 95 | 'describeMultipleValues', 96 | 'describeRequired', 97 | 'flags', 98 | 'alias', 99 | 'aliases', 100 | 'description', 101 | 'help', 102 | 'usage', 103 | 'pattern', 104 | 'name', 105 | 'handler', 106 | 'middleware', 107 | 'use', 108 | 'arguments', 109 | 'commonflag', 110 | 'commonflags', 111 | 'trailer', 112 | 'url', 113 | 'support' 114 | ]) 115 | 116 | const unrecognized = Object.keys(cfg).filter(attribute => !attributes.has(attribute)) 117 | 118 | if (unrecognized.length > 0) { 119 | throw new Error(`Unrecognized configuration attribute(s): ${unrecognized.join(', ')}`) 120 | } 121 | 122 | const ignoreFlags = commonFlags => { 123 | if (commonFlags.ignore && (Array.isArray(commonFlags.ignore) || typeof commonFlags.ignore === 'string')) { 124 | const ignore = new Set(Array.isArray(commonFlags.ignore) ? commonFlags.ignore : [commonFlags.ignore]) 125 | // delete commonFlags.ignore 126 | const root = this.commandroot.replace(new RegExp(`^${this.shell.name}\\s+`, 'i'), '') 127 | 128 | for (const cmd of ignore) { 129 | if (root.startsWith(cmd)) { 130 | commonFlags = {} 131 | break 132 | } 133 | } 134 | } 135 | 136 | return commonFlags 137 | } 138 | 139 | Object.defineProperties(this, { 140 | __commonFlags: { 141 | enumerable: false, 142 | get () { 143 | let flags = ignoreFlags(this.__commonflags) // Object.assign({}, this.__commonflags, this.#flagConfig) 144 | 145 | if (this.parent !== null) { 146 | flags = Object.assign(flags, this.parent.__commonFlags) 147 | } 148 | 149 | const result = Object.assign({}, this.shell !== null ? ignoreFlags(this.shell.__commonflags) : {}, flags) 150 | 151 | if (Array.isArray(result.ignore) || typeof result.ignore === 'string') { 152 | delete result.ignore 153 | } 154 | 155 | return result 156 | } 157 | }, 158 | __flagConfig: { 159 | enumerable: false, 160 | get () { 161 | const flags = new Map(Object.entries(Object.assign(this.__commonFlags, this.#flagConfig || {}))) 162 | flags.delete('help') 163 | return flags 164 | } 165 | }, 166 | getTerminalCommand: { 167 | enumerable: false, 168 | configurable: false, 169 | writable: false, 170 | value: input => { 171 | const args = input.trim().split(/\t+|\s+/) 172 | let cmd = this 173 | 174 | while (args.length > 0) { 175 | const arg = args[0] 176 | const subcmd = cmd.getCommand(arg) 177 | if (subcmd) { 178 | cmd = subcmd 179 | args.shift() 180 | } else { 181 | break 182 | } 183 | } 184 | 185 | return { 186 | command: cmd, 187 | arguments: args.join(' ') 188 | } 189 | } 190 | } 191 | }) 192 | 193 | this.__commonflags = cfg.commonflags || {} 194 | 195 | this.__width = this.shell === null ? 80 : this.shell.tableWidth || 80 196 | 197 | this.updateHelp() 198 | } 199 | 200 | get data () { 201 | const commands = super.data 202 | 203 | let handler = (this.#fn || this.defaultHandler).toString() 204 | if (METHOD_PATTERN.test(handler)) { 205 | handler = handler.replace(METHOD_PATTERN.exec(handler)[1], 'function ') 206 | } 207 | 208 | const flags = Object.assign(this.__commonFlags, this.#flagConfig || {}) 209 | 210 | for (const [key, value] of Object.entries(flags)) { // eslint-disable-line no-unused-vars 211 | value.aliases = value.aliases || [] 212 | 213 | if (value.hasOwnProperty('alias')) { // eslint-disable-line no-prototype-builtins 214 | if (value.aliases.indexOf(value.alias) < 0) { 215 | value.aliases.push(value.alias) 216 | } 217 | } 218 | 219 | delete value.alias 220 | } 221 | 222 | // Apply any missing default values to flags. 223 | Object.keys(flags).forEach(name => { flags[name] = Object.assign(this.getFlagConfiguration(name), flags[name]) }) 224 | 225 | const data = { 226 | name: this.name, 227 | description: this.description, 228 | help: this.help, 229 | usage: this.usage, 230 | aliases: Array.from(this.#aliases), 231 | flags, 232 | handler, 233 | commands, 234 | disableHelp: !this.autohelp, 235 | use: this.middleware.data, 236 | trailer: this.trailers.data 237 | } 238 | 239 | for (const [key, value] of Object.entries(data.flags)) { // eslint-disable-line no-unused-vars 240 | delete value.alias 241 | } 242 | 243 | return data 244 | } 245 | 246 | set parent (cmd) { 247 | if (cmd instanceof Command) { 248 | this.#parent = cmd 249 | } else { 250 | 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.`) 251 | } 252 | } 253 | 254 | get parent () { 255 | return this.#parent 256 | } 257 | 258 | set shell (shell) { 259 | if (!shell) { 260 | throw new Error(`Cannot set shell of ${this.name} command to a non-Shell object.`) 261 | } 262 | if (shell instanceof Shell) { 263 | this.#shell = shell 264 | this.__width = this.shell === null ? 80 : shell.tableWidth 265 | } else { 266 | throw new Error(`Expected a Shell object, received a "${typeof shell}" object.`) 267 | } 268 | } 269 | 270 | get shell () { 271 | if (!this.#shell) { 272 | if (this.#parent) { 273 | return this.#parent.shell 274 | } 275 | 276 | return null 277 | } 278 | 279 | return this.#shell 280 | } 281 | 282 | get plugins () { 283 | return Object.assign({}, this.shell.plugins, this.parent ? this.parent.plugins : {}, super.plugins) 284 | } 285 | 286 | get commandroot () { 287 | if (this.#parent) { 288 | return `${this.#parent.commandroot} ${this.name}`.trim() 289 | } 290 | 291 | if (this.#shell) { 292 | return `${this.#shell.name} ${this.name}`.trim() 293 | } 294 | 295 | return this.name 296 | } 297 | 298 | set aliases (value) { 299 | if (!value) { 300 | this.#aliases = [] 301 | return 302 | } 303 | 304 | if (typeof value === 'object') { 305 | switch (value.constructor.name.toLowerCase()) { 306 | case 'map': 307 | value = Array.from(value.keys()) 308 | break 309 | case 'set': 310 | value = Array.from(value) 311 | break 312 | case 'object': 313 | value = Object.keys(value).filter(item => typeof item === 'string') 314 | break 315 | case 'array': 316 | break 317 | case 'string': 318 | value = value.split(/\s+/)[0] 319 | break 320 | default: 321 | throw new Error('Invalid alias value. Use an array of strings.') 322 | } 323 | } 324 | 325 | this.#aliases = Array.from(new Set(value)) // This conversion deduplicates the value 326 | } 327 | 328 | get aliases () { 329 | return Array.from(this.#aliases) || [] 330 | } 331 | 332 | get OID () { 333 | return this.#oid 334 | } 335 | 336 | addFlag (name, cfg) { 337 | if (typeof name !== 'string') { 338 | if (!cfg.hasOwnProperty('name')) { // eslint-disable-line no-prototype-builtins 339 | throw new Error('Invalid flag name (should be a string).') 340 | } else { 341 | name = cfg.name 342 | } 343 | } 344 | 345 | this.#flagConfig[name] = cfg 346 | } 347 | 348 | removeFlag (name) { 349 | delete this.#flagConfig[name] 350 | } 351 | 352 | getFlagConfiguration (name) { 353 | let flag = this.__flagConfig.get(name) 354 | if (!flag) { 355 | for (const [f, cfg] of this.__flagConfig) { // eslint-disable-line no-unused-vars 356 | if ((cfg.aliases && cfg.aliases.indexOf(name) >= 0) || (cfg.alias && cfg.alias === flag)) { 357 | flag = cfg 358 | break 359 | } 360 | } 361 | 362 | if (!flag) { 363 | return null 364 | } 365 | } 366 | 367 | return { 368 | description: flag.description, 369 | required: flag.hasOwnProperty('required') ? flag.required : false, // eslint-disable-line no-prototype-builtins 370 | aliases: flag.aliases || [flag.alias].filter(i => i !== null), 371 | type: flag.type === undefined ? 'string' : (typeof flag.type === 'string' ? flag.type : flag.type.name.toLowerCase()), 372 | options: flag.hasOwnProperty('options') ? flag.options : null, // eslint-disable-line no-prototype-builtins 373 | allowMultipleValues: flag.hasOwnProperty('allowMultipleValues') ? flag.allowMultipleValues : false // eslint-disable-line no-prototype-builtins 374 | } 375 | } 376 | 377 | supportsFlag (name) { 378 | return this.#flagConfig.hasOwnProperty(name) // eslint-disable-line no-prototype-builtins 379 | } 380 | 381 | deepParse (input) { 382 | const meta = this.parse(input) 383 | 384 | if (this.__commands.size === 0) { 385 | return meta 386 | } 387 | 388 | if (meta.input.trim().length === 0) { 389 | return meta 390 | } 391 | 392 | const args = meta.input.split(/\s+/) 393 | const subcmd = this.__commands.get(args.shift()) 394 | 395 | if (!subcmd) { 396 | return meta 397 | } 398 | 399 | return this.__processors.get(subcmd).deepParse(args.join(' ')) 400 | } 401 | 402 | parse (input) { 403 | // Parse the command input for flags 404 | const data = { command: this.name, input: input.trim() } 405 | const flagConfig = Object.assign(this.__commonFlags, this.#flagConfig || {}) 406 | 407 | if (!flagConfig.hasOwnProperty('help')) { // eslint-disable-line no-prototype-builtins 408 | flagConfig.help = { 409 | description: `Display ${this.name} help.`, 410 | // aliases: ['h'], 411 | default: false, 412 | type: 'boolean' 413 | } 414 | } 415 | 416 | const flags = Array.from(FLAG_PATTERN[Symbol.matchAll](input), x => x[0]) 417 | const parser = new Parser(flags, flagConfig) 418 | const pdata = parser.data 419 | const recognized = {} 420 | 421 | parser.recognizedFlags.forEach(flag => { recognized[flag] = pdata[flag] }) 422 | parser.unrecognizedFlags.forEach(arg => delete recognized[arg]) 423 | 424 | data.flags = { recognized, unrecognized: parser.unrecognizedFlags } 425 | data.valid = parser.valid 426 | data.violations = parser.violations 427 | 428 | data.parsed = {} 429 | if (Object.keys(pdata.flagSource).length > 0) { 430 | for (const [key, src] of Object.entries(pdata.flagSource)) { // eslint-disable-line no-unused-vars 431 | data.parsed[src.name] = src.inputName 432 | } 433 | } 434 | 435 | data.help = { 436 | requested: recognized.help 437 | } 438 | 439 | if (recognized.help) { 440 | data.help.message = this.help 441 | } 442 | 443 | const args = Array.from(this.arguments) 444 | 445 | Object.defineProperties(data, { 446 | flag: { 447 | enumerable: true, 448 | configurable: false, 449 | writable: false, 450 | value: name => { 451 | try { 452 | if (typeof name === 'number') { 453 | return Array.from(parser.unrecognizedFlags)[name] 454 | } else { 455 | if (data.flags.recognized.hasOwnProperty(pdata.flagSource[name].name)) { // eslint-disable-line no-prototype-builtins 456 | return data.flags.recognized[pdata.flagSource[name].name] 457 | } else { 458 | return pdata.flagSource[name].value 459 | } 460 | } 461 | } catch (e) { 462 | if (this.arguments.has(name)) { 463 | return Array.from(parser.unrecognizedFlags)[args.indexOf(name)] 464 | } 465 | 466 | return undefined 467 | } 468 | } 469 | }, 470 | command: { 471 | enumerable: true, 472 | get: () => this 473 | }, 474 | shell: { 475 | enumerable: true, 476 | get: () => this.shell 477 | }, 478 | data: { 479 | enumerable: true, 480 | get () { 481 | const uf = parser.unrecognizedFlags 482 | const result = Object.assign({}, recognized) 483 | delete result.help 484 | 485 | args.forEach((name, i) => { 486 | const value = uf[i] 487 | let normalizedValue = Object.keys(pdata).filter(key => key.toLowerCase() === value) 488 | normalizedValue = (normalizedValue.length > 0 ? normalizedValue.pop() : value) 489 | 490 | if (normalizedValue !== undefined) { 491 | normalizedValue = normalizedValue.trim() 492 | 493 | if (STRIP_QUOTE_PATTERN.test(normalizedValue)) { 494 | normalizedValue = normalizedValue.substring(1, normalizedValue.length - 1) 495 | } 496 | } 497 | 498 | if (result.hasOwnProperty(name)) { // eslint-disable-line no-prototype-builtins 499 | result[name] = Array.isArray(result[name]) ? result[name] : [result[name]] 500 | result[name].push(normalizedValue) 501 | } else { 502 | result[name] = normalizedValue 503 | } 504 | }) 505 | 506 | if (uf.length > args.length) { 507 | uf.slice(args.length) 508 | .forEach((flag, i) => { 509 | let name = `unknown${i + 1}` 510 | while (result.hasOwnProperty(name)) { // eslint-disable-line no-prototype-builtins 511 | const number = name.substring(7) 512 | name = 'unknown' + (parseInt(number) + 1) 513 | } 514 | 515 | result[name] = flag 516 | }) 517 | } 518 | 519 | return result 520 | } 521 | } 522 | }) 523 | 524 | Object.defineProperty(data.help, 'default', { 525 | enumerable: true, 526 | get: () => this.help 527 | }) 528 | 529 | return data 530 | } 531 | 532 | async run (input, callback) { 533 | const fn = (this.#fn || this.defaultHandler).bind(this) 534 | const data = typeof input === 'string' ? this.parse(input) : input 535 | 536 | arguments[0] = this.deepParse(input) 537 | arguments[0].plugins = this.plugins 538 | 539 | if (this.shell !== null) { 540 | const parentMiddleware = this.shell.getCommandMiddleware(this.commandroot.replace(new RegExp(`^${this.shell.name}`, 'i'), '').trim()) 541 | 542 | if (parentMiddleware.length > 0) { 543 | this.middleware.use(...parentMiddleware) 544 | } 545 | } 546 | 547 | const trailers = this.trailers 548 | 549 | if (arguments[0].help && arguments[0].help.requested) { 550 | console.log(this.help) 551 | 552 | if (trailers.size > 0) { 553 | trailers.run(arguments[0]) 554 | } 555 | 556 | return 557 | } 558 | 559 | // No subcommand was recognized 560 | if (this.middleware.size > 0) { 561 | this.middleware.run(arguments[0], async meta => await Command.reply(fn(meta, callback))) 562 | 563 | if (trailers.size > 0) { 564 | trailers.run(arguments[0]) 565 | } 566 | 567 | return 568 | } 569 | 570 | // Command.reply(fn(arguments[0], callback)) 571 | data.plugins = this.plugins 572 | Command.reply(fn(data, callback)) 573 | 574 | if (trailers.size > 0) { 575 | trailers.run(arguments[0]) 576 | } 577 | } 578 | 579 | static stderr (err) { 580 | if (err instanceof Error) { 581 | return new Promise((resolve, reject) => reject(err)).catch(console.error) 582 | } 583 | 584 | return new Promise((resolve, reject) => reject(err)).catch(console.error) 585 | } 586 | 587 | static reply (callback) { 588 | return new Promise((resolve, reject) => { 589 | try { 590 | if (typeof callback === 'function') { 591 | callback() 592 | } 593 | 594 | resolve() 595 | } catch (e) { 596 | reject(e) 597 | } 598 | }) 599 | } 600 | } 601 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @author.io/shell 2 | ![Version](https://img.shields.io/github/v/tag/author/shell?label=Latest&style=for-the-badge) 3 | 4 | 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. 5 | 6 | --- 7 | This library is now supported by this [Chrome CLI Devtools Extension](https://chrome.google.com/webstore/detail/cli/okpglddgmnblhbdpdcmodmacgcibgfkf): 8 | 9 | ![Devtools Extension](https://lh3.googleusercontent.com/WKZpJavmX4RRPyaVBFe6Vn88ZXJbjy9FCP_Mwyxo1JrWY78a9_Rh9c-sy4TawzIKy8xUmnXoxes=w640-h400-e365) 10 | 11 | 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). 12 | 13 | --- 14 | 15 | ## Uses 16 | 17 | There are two types of text-based apps: 18 | 19 | 1. **Single Purpose** (_a_ command) 20 | These are tools which may have multiple configuration options, but ultimately only do one thing. Examples include [node-tap](https://node-tap.org/), [mocha](https://mochajs.org/), [standard](https://standardjs.com/), prettier, etc. 21 | 22 | For example, node-tap can be run on a file, using syntax like `tap [options] []`. Ultimately, this utility serves one purpose. It just runs a configured tap process. 23 |
24 | 25 | 1. **Multipurpose** (a shell for _multiple_ commands) 26 | Other tools do more than configure a script. Consider npm, which has several subcommands (like `install`, `uninstall`, `info`, etc). Subcommands often behave like their own single purpose tool, with their own unique flags, and even subcommands of their own. Docker is a good example of this, which has an entire series of management subcommands. 27 | 28 | #### Is this "framework" overkill? 29 | 30 | **tl;dr** Use this library to create multipurpose tools. Use [@author.io/arg](https://github.com/author/arg) to create single purpose tools. 31 | 32 |
33 | Detailed Explanation 34 |
35 | 36 | 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. 37 | 38 | 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. 39 | 40 | `@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. 41 |
42 |
43 | 44 | **Think about how your tooling evolves...** 45 | 46 | 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. 47 | 48 | ## Differentiating Features 49 | 50 | 1. Supports **middleware** (express-style). 51 | 1. Supports **postware** (middleware that runs after a command). 52 | 1. **Customizable **help/usage** screens. 53 | 1. Produces **introspectable** JSON. Load a JSON config, have a working CLI. 54 | 1. Reusable **plugin** system. 55 | 1. Dynamically add/remove commands. 56 | 1. Track command execution **history**. 57 | 1. Define **universal flags** once, reuse in all commands. 58 | 59 |
60 | Also has better source & distribution code 61 | 62 | 1. Cross-runtime (browser, node, deno) 63 | 1. Separation of Concerns: Arg parsing and text formatting are separate microlibs. 64 | 1. Modern ES Module syntax 65 | 1. 40+ unit tests 66 | 67 |
68 | 69 | ## Basic Examples 70 | 71 | See the [Installation Guide](#installation) when you're ready to get started. 72 | 73 | There is a complete working example of a CLI app (with a mini tutorial) in the examples directory. 74 | 75 | _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._ 76 | 77 | ```javascript 78 | import { Shell, Command } from '@author.io/shell' 79 | 80 | // Define a command 81 | const ListCommand = new Command({ 82 | name: 'list', 83 | description: 'List the contents of the directory.', 84 | disableHelp: false, // Set to true to turn off default help messages for the entire shell (you can still provide your own). Defaults to false. 85 | // arguments are listed after the command in the default help screen. Ex: "dir list path" 86 | arguments: 'path', // Can be space/comma/tab/semicolon delimited or an array. 87 | alias: 'ls', 88 | // Any flag parsing options from the @author.io/arg library can be configured here. 89 | // See https://github.com/author/arg#configuration-methods for a list. 90 | flags: { 91 | long: { 92 | alias: 'l', 93 | description: 'Long format'. 94 | type: 'boolean', 95 | default: false 96 | }, 97 | rootDir: { 98 | description: 'The root directory to list.', 99 | aliases: ['input', 'in', 'src'], 100 | single: true, 101 | // validate: RegExp/Function (see github.com/author/arg) 102 | } 103 | }, 104 | handler (metadata, callback) { 105 | // ... this is where your command actually does something ... 106 | 107 | // Data comes from @author.io/arg lib. It looks like: 108 | // { 109 | // command: , 110 | // input: 'whatever user typed after "command"', 111 | // flags: { 112 | // recognized: {}, 113 | // unrecognized: [ 114 | // 'whatever', 115 | // 'user', 116 | // 'typed' 117 | // ] 118 | // }, 119 | // valid: false, 120 | // violations: [], 121 | // flag (name) { return String }, 122 | // data (getter) 123 | // } 124 | console.log(metadata) 125 | 126 | // A single flag's value can be retrieved with this helper method. 127 | console.log(metadata.flag('long')) 128 | 129 | // Any unrecognized flags can be retrieved by index number (0-based) 130 | console.log(metadata.flag(0)) // The first unrecognized flag... returns null if it doesn't exist 131 | 132 | // Execution callbacks are optional. If a callback is passed from the 133 | // execution context to this handler, it will run after the command 134 | // has finished processing 135 | // (kind of like "next" in Express). 136 | // Promises are also supported. 137 | callback && callback() 138 | } 139 | }) 140 | 141 | const shell = new Shell({ 142 | name: 'myapp', 143 | version: '1.0.0', 144 | description: 'My demo app.', 145 | // This middleware runs before all command handlers. 146 | use: [ 147 | (meta, next) => { ...; next() } 148 | ], 149 | // Trailers are like "post-middleware" that run after command handlers. 150 | trailer: [ 151 | (meta, next) => { ...; next() } 152 | (meta, next) => { console.log('All done!') } 153 | ], 154 | commands: [ 155 | // These can be instances of Command... 156 | list, 157 | 158 | // or just the configuration of a Command 159 | { 160 | name: 'find', 161 | description: 'Search metadoc for all the things.', 162 | alias: 'search', 163 | flags: { 164 | x: { 165 | type: 'string', 166 | required: true 167 | } 168 | }, 169 | handler: (data, cb) => { 170 | console.log(data) 171 | console.log(`Mirroring input: ${data.input}`) 172 | 173 | cb && cb() 174 | } 175 | // Subcommands are supported 176 | // , commands: [...] 177 | } 178 | ] 179 | }) 180 | 181 | // Run a command 182 | shell.exec('find "some query"') 183 | 184 | // Run a command using a promise. 185 | shell.exec('find "some query"').then(() => console.log('Done!)) 186 | 187 | // Run a command using a callback (the callback is passed to the command's handler function) 188 | shell.exec('find "some query"', () => console.log('Handled!')) 189 | 190 | // Run a command, pass a callback to the handler, and use a promise to determine when everything is done. 191 | shell.exec('find "some query"', () => console.log('Handled!')).then(() => console.log('Done!)) 192 | 193 | // Output the shell's default messages 194 | console.log(shell.help) 195 | console.log(shell.usage) 196 | console.log(shell.description) 197 | ``` 198 | 199 | ## Custom Handlers 200 | 201 | Each command has a handler function, which is responsible for doing something. This command receives a reference to the parsed flags. 202 | 203 | ```javascript 204 | { 205 | command: , 206 | input: 'Raw string of flags/arguments passed to the command', 207 | flags: { 208 | recognized: {}, 209 | unrecognized: [ 210 | 'whatever', 211 | 'user', 212 | 'typed' 213 | ] 214 | }, 215 | flag (name) { return }, 216 | valid: false, 217 | violations: [] 218 | } 219 | ``` 220 | 221 | - **command** is the command name. 222 | - **input** is the string typed in after the command (flags) 223 | - **flags** contains the parsed flags from the [@author.io/arg](https://github.com/author/arg) library. 224 | - **`flag()`** is a special method for retrieving the value of any flag (recognized or unrecognized). See below. 225 | - **valid** indicates whether the input conforms to the parsing rules. 226 | - **violations** is an array of strings, where each string represents a violation of the parsing rules. 227 | - **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) 228 | 229 |
230 | Understanding flag() 231 |
232 | 233 | 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: 234 | 235 | ```javascript 236 | const cmd = new Command({ 237 | name: 'demo', 238 | flags: { 239 | a: { type: String }, 240 | b: { type: String } 241 | }, 242 | handler: metadata => { 243 | console.log(`A is "${metadata.flags.recognized.a}"`) 244 | console.log(`B is "${metadata.flags.recognized.b}"`) 245 | } 246 | }) 247 | ``` 248 | 249 | Compare the example above to this cleaner version: 250 | 251 | ```javascript 252 | const cmd = new Command({ 253 | name: 'demo', 254 | flags: { 255 | a: { type: String }, 256 | b: { type: String } 257 | }, 258 | handler: metadata => { 259 | console.log(`A is "${metadata.flag('a')}"`) // <-- Here 260 | console.log(`B is "${metadata.flag('b')}"`) // <-- and here 261 | } 262 | }) 263 | ``` 264 | 265 | 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`. 266 |
267 | 268 |
269 | Understanding data 270 | 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_. 271 | 272 | For example, 273 | 274 | ```javascript 275 | const shell = new Shell({ 276 | name: 'account', 277 | commands: [{ 278 | name: 'create', 279 | arguments: 'email displayName', 280 | handler (meta) { 281 | console.log(meta.data) 282 | } 283 | }] 284 | }) 285 | 286 | shell.exec('create me@domain.com "John Doe" test1 test2') 287 | ``` 288 | 289 | _Output:_ 290 | 291 | ```json 292 | { 293 | "email": "me@domain.com", 294 | "displayName": "John Doe", 295 | "unknown1": "test1", 296 | "unknown2": "test2 297 | } 298 | ``` 299 | 300 | If there is a name conflict, the output will contain an array of values. For example: 301 | 302 | ```javascript 303 | const shell = new Shell({ 304 | name: 'account', 305 | commands: [{ 306 | name: 'create', 307 | arguments: 'email displayName', 308 | flags: { 309 | email: { 310 | alias: 'e' 311 | } 312 | }, 313 | handler (meta) { 314 | console.log(meta.data) 315 | } 316 | }] 317 | }) 318 | 319 | shell.exec('create me@domain.com -e bob@other.com') 320 | ``` 321 | 322 | _Output:_ 323 | 324 | ```json 325 | { 326 | "email": ["bob@other.com", "me@domain.com"], 327 | "displayName": "John Doe", 328 | } 329 | ``` 330 | 331 | > Notice the values from the known flags are _first_. 332 |
333 | 334 | ## Plugins 335 | 336 | Plugins expose functions, objects, and primitives to shell handlers. 337 | 338 | _Example:_ 339 | 340 | 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. 341 | 342 |
343 | Why would you do this? 344 |

345 | 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. 346 |

347 |

348 | 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. 349 |

350 |
351 |
352 | 353 | ```javascript 354 | import axios from 'axios' 355 | 356 | const sh = new Shell({ 357 | name: 'info', 358 | plugins: { 359 | httprequest: axios // replace this with any compatible library 360 | }, 361 | commands: [{ 362 | name: 'person', 363 | flags: { 364 | name: { 365 | description: 'Name of the person you want info about.', 366 | required: true 367 | } 368 | }, 369 | handler (meta) { 370 | meta.plugins.httprequest({ 371 | method: 'get', 372 | url: `http://api.com/person/${meta.flag('name')}` 373 | }).then(console.log).catch(console.error) 374 | } 375 | }, { 376 | name: 'group', 377 | flags: { 378 | name: { 379 | description: 'Name of the group you want info about.', 380 | required: true 381 | } 382 | }, 383 | handler (meta) { 384 | meta.plugins.httprequest({ 385 | method: 'get', 386 | url: `http://api.com/group/${meta.flag('name')}` 387 | }).then(console.log).catch(console.error) 388 | } 389 | }] 390 | }) 391 | ``` 392 | 393 | Commands will inherit plugins from the shell and any parent commands. It is possible to "override" a plugin in any specific command. 394 | 395 |
396 | Override Example 397 | 398 | ```javascript 399 | const sh = new Shell({ 400 | name: 'test', 401 | plugins: { 402 | test: value => { 403 | return value + 1 404 | } 405 | }, 406 | commands: [{ 407 | name: 'cmd', 408 | plugins: { 409 | test: value => { 410 | return value + 10 411 | } 412 | }, 413 | handler(meta) { 414 | console.log(meta.test(1)) // Outputs 11 415 | } 416 | }] 417 | }) 418 | ``` 419 | 420 |
421 | 422 | ## Universal Flags 423 | _Common flags are automatically applied to multiple commands._ 424 | 425 | 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). 426 | 427 |
428 | Apply a common flag to ALL commands 429 | To include the same flag on all commands, add a common flag to the shell. 430 | 431 | ```javascript 432 | const shell = new Shell({ 433 | name: 'mycli', 434 | commmonflags: { 435 | note: { 436 | alias: 'n', 437 | description: 'Save a note about the operation.' 438 | } 439 | }, 440 | commands: [{ 441 | name: 'create', 442 | flag: { 443 | writable: { 444 | alias: 'w', 445 | description: 'Make it writable.' 446 | } 447 | }, 448 | ... 449 | }, { 450 | name: 'read', 451 | ... 452 | }] 453 | }) 454 | 455 | shell.exec('create --help') 456 | shell.exec('read --help') 457 | ``` 458 | 459 | create output: 460 | ```sh 461 | mycli create 462 | 463 | Flags: 464 | note [-n] Save a note about the operation. 465 | writable [-w] Make it writable. 466 | ``` 467 | 468 | read` output: 469 | ```sh 470 | mycli read 471 | 472 | Flags: 473 | note [-n] Save a note about the operation. 474 | ``` 475 |
476 | 477 |
478 | Apply a common flag to a specific command/subcommands 479 | 480 | ```javascript 481 | const shell = new Shell({ 482 | name: 'mycli', 483 | commands: [{ 484 | name: 'create', 485 | commmonflags: { 486 | note: { 487 | alias: 'n', 488 | description: 'Save a note about the operation.' 489 | } 490 | }, 491 | flag: { 492 | writable: { 493 | alias: 'w', 494 | description: 'Make it writable.' 495 | } 496 | }, 497 | commands: [...] 498 | ... 499 | }, { 500 | name: 'read', 501 | description: 'Read a directory.', 502 | ... 503 | }] 504 | }) 505 | 506 | shell.exec('create --help') 507 | shell.exec('read --help') 508 | ``` 509 | 510 | _`create` output:_ 511 | ```sh 512 | mycli create 513 | 514 | Flags: 515 | note [-n] Save a note about the operation. 516 | writable [-w] Make it writable. 517 | ``` 518 | 519 | _`read` output:_ 520 | ```sh 521 | mycli read 522 | 523 | Read a directory. 524 | 525 | ``` 526 |
527 | 528 | #### Filtering Universal Flags 529 | 530 | 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. 531 | 532 |
533 | Cherry-picking example 534 |
535 | 536 | ```javascript 537 | const shell = new Shell({ 538 | name: 'mycli', 539 | commmonflags: { 540 | ignore: 'info', // This can also be an array of string. Fully qualified subcommands will also be respected. 541 | note: { 542 | alias: 'n', 543 | description: 'Save a note about the operation.' 544 | } 545 | }, 546 | commands: [{ 547 | name: 'create', 548 | handler () {} 549 | }, { 550 | name: 'read', 551 | handler () {} 552 | }, { 553 | name: 'update', 554 | handler () {} 555 | }, { 556 | name: 'delete', 557 | handler () {} 558 | }, { 559 | name: 'info', 560 | handler () {} 561 | }] 562 | }) 563 | ``` 564 | 565 | Any command, except `info`, will accepts/parse the `note` flag. 566 | 567 |
568 | 569 | ## Middleware 570 | 571 | When a command is called, it's handler function is executed. Sometimes it is desirable to pre-process one or more commands. The shell middleware feature supports "global" middleware and "assigned" middleware. 572 | 573 | ### Global Middleware 574 | 575 | This middleware is applied to all handlers, unilaterally. It is useful for catching syntax errors in commands, preprocessing data, and anything else you may want to do before the actual handler is executed. 576 | 577 | For example, the following middleware checks the input to determine if all of the appropriate flags have been set. If not, the violations are displayed and the handler is never run. If everything is correct, the `next()` method will continue processing. 578 | 579 | ```javascript 580 | shell.use(function (metadata, next) { 581 | if (!metadata.valid) { 582 | metadata.violations.forEach(violation => console.log(violation)) 583 | } else { 584 | next() 585 | } 586 | }) 587 | ``` 588 | 589 | No matter which command the user inputs, the global middleware methods are executed. 590 | 591 | ### Assigned Middleware 592 | 593 | This middleware is assigned to one or more commands. For example: 594 | 595 | ```javascript 596 | shell.useWith('demo', function (metadata, next) { 597 | if (metadata.flag('a') === null) { 598 | console.log('No "a" flag specified. This may slow down processing.') 599 | } 600 | 601 | next() 602 | }) 603 | ``` 604 | 605 | The code above would only run when the user inputs the `demo` command (or any `demo` subcommand). 606 | 607 | #### Command-Specific Assignments 608 | 609 | It is possible to assign middleware to more than one command at a time, and it is possible to target subcommands. For example: 610 | 611 | ```javascript 612 | shell.useWith(['demo', 'command subcommand'], function (metadata, next) { 613 | if (metadata.flag('a') === null) { 614 | console.log('I hope you know what you are doing!') 615 | } 616 | 617 | next() 618 | }) 619 | ``` 620 | 621 | 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. 622 | 623 | Assigned middleware can also be applied directly to a `Command` class. For example, 624 | 625 | ```javascript 626 | const cmd = new Command({ 627 | name: 'demo', 628 | flags: { 629 | a: { type: String }, 630 | b: { type: String } 631 | }, 632 | handler: metadata => { 633 | console.log(metadata) 634 | } 635 | }) 636 | 637 | cmd.use(function (metadata, next) { 638 | console.log(`this middleware is specific to the "${cmd.name}" command`) 639 | next() 640 | }) 641 | ``` 642 | 643 | #### Command-Exclusion Assignments 644 | 645 | 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. 646 | 647 | For example: 648 | 649 | ```javascript 650 | const shell = new Shell({ 651 | ..., 652 | commands: [{ 653 | name: 'add', 654 | handler (meta) { 655 | ... 656 | } 657 | }, { 658 | name: 'subtract', 659 | handler (meta) { 660 | ... 661 | } 662 | }, { 663 | name: 'info', 664 | handler (meta) { 665 | ... 666 | } 667 | }] 668 | }) 669 | 670 | shell.useExcept(['info], function (meta, next) { 671 | console.log(`this middleware is only applied to some math commands`) 672 | next() 673 | }) 674 | ``` 675 | 676 | In this example, the console statement would be displayed for all commands except the `info` command (and any info subcommands). 677 | 678 | ### Built-in "Middleware" 679 | 680 | Displaying help and version information is built-in (overridable). 681 | 682 | **Help** 683 | 684 | 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. 685 | 686 | **Shell Version** 687 | 688 | A `version` command is available on the shell. For example: 689 | 690 | ```sh 691 | $ cmd version 692 | 1.0.0 693 | ``` 694 | 695 | The following common flag variations map to the version command, producing the same output: 696 | 697 | ```sh 698 | $ cmd --version 699 | 1.0.0 700 | 701 | $ cmd -v 702 | 1.0.0 703 | ``` 704 | 705 | This can be overridden by creating a command called `version`, the same way any other command is created. 706 | 707 | ```javascript 708 | const v = new Command({ 709 | name: 'version', 710 | handler (meta) { 711 | console.log(this.shell.version) 712 | } 713 | }) 714 | 715 | shell.add(v) 716 | ``` 717 | 718 | ### Middleware Libraries 719 | 720 | 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. 721 | 722 | 1. [@author.io/shell-middleware](https://github.com/author/shell-middleware) 723 | 1. Submit a PR to add yours here. 724 | 725 | ### Trailers 726 | _(Postware/Afterware)_ 727 | 728 | Trailers operate just like middleware, but they execute _after_ the command handler is executed. 729 | 730 | ```javascript 731 | const shell = new Shell({ 732 | name: 'mycli', 733 | trailer: [ 734 | function () { console.log('Done!' ) } 735 | ], 736 | command: [{ 737 | name: 'dir', 738 | handler () { 739 | console.log('ls -l') 740 | }, 741 | // Subcommands 742 | commands: [{ 743 | name: 'perm', 744 | description: 'Permissions', 745 | handler () { 746 | console.log('Display permissions for a directory.') 747 | } 748 | }] 749 | }] 750 | }) 751 | 752 | // Execute the "dir" command 753 | shell.exec('dir') 754 | 755 | // Execute the "perm" subcommand 756 | shell.exec('dir perm') 757 | ``` 758 | 759 | _`dir` command output:_ 760 | ```sh 761 | ls -l 762 | Done! 763 | ``` 764 | 765 | _`dir perm` subcommand output:_ 766 | ```sh 767 | Display permissions for a directory. 768 | Done! 769 | ``` 770 | 771 | ### Customized Help/Usage Messages 772 | 773 | **Customizing Flag Appearance:** 774 | 775 | 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. 776 | 777 | 1. `describeDefault`: Display the default flag value. 778 | 1. `describeOptions`: List the valid options for a flag. 779 | 1. `describeMultipleValues`: Appends `Can be used multiple times.` to the flag description 780 | 1. `describeRequired`: Prepends `Required.` to the flag description whenever a flag is required. 781 | 782 |
783 | Example 784 |
785 | 786 | ```javascript 787 | const c = new Command({ 788 | name: '...', 789 | flags: { 790 | name: { 791 | alias: 'nm', 792 | required: true, 793 | default: 'Rad Dev', 794 | allowMultipleValues: true, 795 | options: ['Mr Awesome', 'Mrs Awesome', 'Rad Dev'], 796 | description: 'Specify a name.' 797 | } 798 | } 799 | }) 800 | ``` 801 | 802 | The help message for this flag would look like: 803 | 804 | ```sh 805 | Flags: 806 | -name ['nm'] Required. Specify a name. Options: Mr 807 | Awesome, Mrs Awesome, Rad Dev. Can be 808 | used multiple times. (Default Rad Dev) 809 | ``` 810 |
811 |
812 | 813 | **Customizing the Entire Message:** 814 | 815 | 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. 816 | 817 | ```javascript 818 | import { Shell, Command, Table } from '@author.io/shell' 819 | 820 | const shell = new Shell(...) 821 | shell.usage = '...' 822 | shell.help = () => { 823 | const rows = [ 824 | ['Command', 'Alias Names'], 825 | ['...', '...'] 826 | ] 827 | 828 | const table = new Table(rows) 829 | 830 | return shell.usage + '\n' + table.output 831 | } 832 | ``` 833 | 834 | **The `usage` and/or `help` attributes of an individual `Command` can also be set:** 835 | 836 | ```javascript 837 | import { Shell, Command, Table } from '@author.io/shell' 838 | 839 | const cmd = new Command(...) 840 | cmd.usage = '...' 841 | cmd.help = () => { 842 | const rows = [ 843 | ['Flags', 'Alias Names'], 844 | ['...', '...'] 845 | ] 846 | 847 | const table = new Table(rows) 848 | 849 | return cmd.usage + '\n' + table.output 850 | } 851 | ``` 852 | 853 | 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). 854 | 855 | ### Introspection/Metadata Generation 856 | 857 | A JSON metadoc can be produced from the shell: 858 | 859 | ```javascript 860 | console.log(shell.data) 861 | ``` 862 | 863 | 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. 864 | 865 | ### Autocompletion/Input Hints 866 | 867 | 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). 868 | 869 | Consider the following shell: 870 | 871 | ```javascript 872 | import HintPlugin from 'https://cdn.pika.dev/@author.io/browser-shell-hints' 873 | 874 | const shell = new Shell({ 875 | name: 'mycli', 876 | command: [{ 877 | name: 'dir', 878 | handler () { 879 | console.log('ls -l') 880 | }, 881 | // Subcommands 882 | commands: [{ 883 | name: 'perm', 884 | description: 'Permissions', 885 | handler () { 886 | console.log('Display permissions for a directory.') 887 | } 888 | }, { 889 | name: 'payload', 890 | description: 'Payload', 891 | handler () { 892 | console.log('Display payload/footprint for a directory.') 893 | } 894 | }] 895 | }] 896 | }) 897 | 898 | HintPlugin.apply(shell) // <-- Adds the hint method. 899 | 900 | // Help us figure out what we can do! 901 | console.log(shell.hint('dir p')) 902 | ``` 903 | 904 | _Output:_ 905 | 906 | ```sh 907 | { 908 | commands: ['perm', 'payload'], 909 | flags: [] 910 | } 911 | ``` 912 | 913 | The hint matches "**dir p**ermission" and "**dir p**ayload", but does not match any flags. 914 | 915 | If no options/hints are available, `null` is returned. 916 | 917 | 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. 918 | 919 | > There are many variations of autocompletion for different shells, which are not available in browsers (see our Devtools extension for browser completion). 920 | 921 | 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. 922 | 923 | ## Installation 924 | 925 | ### Node.js 926 | 927 |
928 | Modern (ES Modules) 929 |
930 | 931 | ```sh 932 | npm install @author.io/shell --save 933 | ``` 934 | 935 | 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. 936 |
937 | 938 |
939 | Legacy (CommonJS/require) 940 |
941 | 942 | DEPRECATED 943 | 944 | If you need to use the older CommonJS format (i.e. `require`), run `npm install @author.io/shell-legacy` instead. 945 |
946 | 947 | ### Browsers 948 | 949 | **CDN** 950 | 951 | ```javascript 952 | import { Shell, Command } from 'https://cdn.pika.dev/@author.io/shell' 953 | ``` 954 | 955 | 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). 956 | 957 | ### Debugging 958 | 959 | 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. 960 | 961 | ### Related Modules 962 | 963 | 1. [@author.io/table](https://github.com/author/table) - Used to generate the default usage/help messages for the shell and subcommands. 964 | 965 | **Sponsors (as of 2020)** 966 | 967 | 968 | 969 | 970 | 971 | 972 |
973 | --------------------------------------------------------------------------------