├── .eslintrc ├── .gitignore ├── .npmignore ├── .nycrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE.md ├── demo.gif ├── index.ts ├── logo128.png ├── package-lock.json ├── package.json ├── pg └── index.ts ├── presets ├── index.ts ├── one-of.ts └── url.ts ├── readme.md ├── src ├── cli-helper.ts ├── command.ts ├── completer.ts ├── decorator.ts ├── default-cli.ts ├── errors.ts ├── i18n.ts ├── option-helper.ts ├── option.ts ├── parser.ts ├── pipeline.ts ├── printer.ts ├── report.ts ├── type-logic.ts └── utils.ts ├── tests ├── e2e │ └── basics │ │ ├── Dockerfile │ │ ├── app │ │ ├── .gitignore │ │ ├── index.ts │ │ ├── install-completions.js │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── uninstall-completions.js │ │ ├── build │ │ └── test.ts └── unit │ ├── cli-helper.ts │ ├── command.ts │ ├── completer.ts │ ├── decorator.ts │ ├── i18n.ts │ ├── index.ts │ ├── option.ts │ ├── parser.ts │ ├── pipeline.ts │ ├── printer.ts │ ├── strip-ansi.ts │ └── utils.ts ├── todo ├── tsconfig.json └── typedoc.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "parserOptions": { 8 | "ecmaVersion": 2018, 9 | "sourceType": "module" 10 | }, 11 | "settings": {}, 12 | "overrides": [ 13 | { 14 | "files": ["*.ts", "*.tsx"], 15 | "excludedFiles": ["*.d.ts", "*.js"], 16 | "rules": { 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/camelcase": "off", 19 | "@typescript-eslint/no-use-before-define": "off", 20 | "no-undef": "off", 21 | "no-dupe-class-members": "off", 22 | "require-atomic-updates": "off" 23 | } 24 | }, 25 | { 26 | "files": ["tests/**"], 27 | "rules": { 28 | "@typescript-eslint/no-non-null-assertion": "off", 29 | "no-useless-escape": "off", 30 | 31 | "@typescript-eslint/no-explicit-any": "off", 32 | "@typescript-eslint/camelcase": "off", 33 | "@typescript-eslint/no-use-before-define": "off", 34 | "no-undef": "off", 35 | "no-dupe-class-members": "off", 36 | "no-empty": "off", 37 | "require-atomic-updates": "off" 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.js 3 | *.js.map 4 | *.d.ts 5 | coverage 6 | .nyc_output 7 | !typedoc.js 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests 2 | .eslintrc 3 | .gitignore 4 | todo 5 | .vscode 6 | coverage 7 | .nyc_output 8 | pg 9 | .travis.yml 10 | logo128.png 11 | demo.gif 12 | typedoc.js 13 | *.ts 14 | !*.d.ts 15 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**", 4 | "index.*" 5 | ], 6 | "exclude": [ 7 | "tests/*" 8 | ], 9 | "extends": "@istanbuljs/nyc-config-typescript", 10 | "check-coverage": true 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 10 5 | after_success: 6 | - './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' 7 | before_script: 8 | - npm install -g typescript && tsc 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9229 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Launch Program", 17 | "program": "${workspaceFolder}/pg/index.js", 18 | "outFiles": [ 19 | "${workspaceFolder}/**/*.js" 20 | ], 21 | "args": ["completion"] 22 | }, 23 | { 24 | "type": "node", 25 | "request": "launch", 26 | "name": "Debug tests", 27 | "program": "${workspaceFolder}/tests/unit/index.ts", 28 | "outFiles": [ 29 | "${workspaceFolder}/**/*.js" 30 | ], 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true 8 | }, 9 | "eslint.validate": [ 10 | "javascript", 11 | "javascriptreact", 12 | "typescript", 13 | "typescriptreact" 14 | ], 15 | "coverage-gutters.coverageReportFileName": "coverage/**/index.html", 16 | "coverage-gutters.lcovname": "coverage/lcov.info" 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2019] [https://github.com/int0h] 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 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/int0h/typed-cli/328a797d067f2545c20f4e27e0f45a84c6ffe887/demo.gif -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // main API: 2 | export {option} from './src/option-helper'; 3 | export {command, defaultCommand} from './src/command'; 4 | export {cli} from './src/default-cli'; 5 | 6 | // helper creators: 7 | export {createCliHelper} from './src/cli-helper'; 8 | export {createCommandHelper} from './src/command'; 9 | export {completeForCommandSet, completeForCliDecl} from './src/completer'; 10 | 11 | // types: 12 | export {Writer, Exiter, ArgvProvider, CreateCliHelperParams, CliHelper} from './src/cli-helper'; 13 | export {Parser} from './src/parser'; 14 | export {Printer} from './src/printer'; 15 | 16 | // defaults: 17 | export {defaultArgvProvider, defaultExiter, defaultPrinter, defaultWriter} from './src/default-cli'; 18 | export {decorators, chalkInstance} from './src/decorator'; 19 | export {locales} from './src/i18n'; 20 | import * as presets from './presets'; 21 | export {presets}; 22 | -------------------------------------------------------------------------------- /logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/int0h/typed-cli/328a797d067f2545c20f4e27e0f45a84c6ffe887/logo128.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-cli", 3 | "version": "1.2.0", 4 | "description": "", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "lint": "eslint . --ignore-path ./.gitignore --ext .ts", 9 | "test": "nyc node ./tests/unit/index.js", 10 | "test-report": "nyc -r lcov node ./tests/unit/index.js", 11 | "docs": "typedoc" 12 | }, 13 | "keywords": [ 14 | "cli", 15 | "terminal", 16 | "argv", 17 | "argument", 18 | "args", 19 | "command" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@types/node": "^16.11.6", 25 | "@types/string-argv": "^0.1.0", 26 | "@types/yargs-parser": "^20.2.1", 27 | "chalk": "^4.1.2", 28 | "string-argv": "^0.3.1", 29 | "tabtab": "git+https://github.com/int0h/tabtab.git#ba938481a264fcc60a21a3288b32485f28e7fcca", 30 | "yargs-parser": "^20.2.9" 31 | }, 32 | "devDependencies": { 33 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 34 | "@types/tape": "^4.13.2", 35 | "@typescript-eslint/eslint-plugin": "^5.3.0", 36 | "@typescript-eslint/parser": "^5.3.0", 37 | "coveralls": "^3.1.1", 38 | "eslint": "^8.1.0", 39 | "node-pty": "^0.10.1", 40 | "nyc": "^15.1.0", 41 | "source-map-support": "^0.5.20", 42 | "tape": "^5.3.1", 43 | "ts-node": "^10.4.0", 44 | "typedoc": "^0.22.7", 45 | "typescript": "^4.4.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pg/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import {cli, option, presets} from '../'; 3 | 4 | const data = cli({ 5 | name: 'calc', 6 | description: 'Calculate expressions', 7 | options: { 8 | operation: presets.oneOf(['+', '-', '*', '/'] as const) 9 | .alias('o') 10 | .required() 11 | .description('opeartion to be applied'), 12 | round: option.boolean.alias('r').description('rounds the result'), 13 | }, 14 | _: option.number.array() 15 | }); 16 | 17 | const OperatorMap = { 18 | '+': (prev: number, cur: number): number => prev + cur, 19 | '/': (prev: number, cur: number): number => prev / cur, 20 | '-': (prev: number, cur: number): number => prev - cur, 21 | '*': (prev: number, cur: number): number => prev * cur, 22 | }; 23 | 24 | // Type safe! 25 | // n1: number 26 | // n2: number 27 | // (place a cursor on a variable to see its type) 28 | const [n1, n2] = data._; 29 | 30 | // Type safe! 31 | // op: '+' | '-' | '*' | '/' 32 | const op = data.options.operation; 33 | 34 | console.log(`Calculating: ${n1} ${op} ${n2} = ${[n1, n2].reduce(OperatorMap[op])}`); 35 | -------------------------------------------------------------------------------- /presets/index.ts: -------------------------------------------------------------------------------- 1 | import oneOf from './one-of'; 2 | import url from './url'; 3 | 4 | export {oneOf, url}; 5 | -------------------------------------------------------------------------------- /presets/one-of.ts: -------------------------------------------------------------------------------- 1 | import { option } from '../'; 2 | import { objMap } from '../src/utils'; 3 | import { Option } from '../src/option'; 4 | import { allIssues } from '../src/errors'; 5 | 6 | type OneOfDecl = readonly string[] | { 7 | [key: string]: any | { 8 | description?: string; 9 | value?: any; 10 | }; 11 | } 12 | 13 | type OneOfDeclNorm = { 14 | [key: string]: { 15 | description: string; 16 | value: any; 17 | }; 18 | } 19 | 20 | type ResolveOneOfDeclType = T extends readonly string[] 21 | ? T[number] 22 | : { 23 | [key in keyof T]: T[key] extends {value: infer V} 24 | ? V 25 | : T[key] 26 | }[keyof T]; 27 | 28 | function fromPairs(pairs: [string, any][]): any { 29 | const res: any = {}; 30 | for (const [key, value] of pairs) { 31 | res[key] = value; 32 | } 33 | return res; 34 | } 35 | 36 | function normalizeDecl(decl: OneOfDecl): OneOfDeclNorm { 37 | if (Array.isArray(decl)) { 38 | const pairs = decl.map(key => [key, { 39 | description: '', 40 | value: key 41 | }]); 42 | return fromPairs(pairs as any); 43 | } 44 | return objMap(decl, (value, key) => { 45 | if (typeof value === 'string') { 46 | return { 47 | value, 48 | description: '' 49 | }; 50 | } 51 | return { 52 | value: key, 53 | description: '', 54 | ...value, 55 | } 56 | }); 57 | } 58 | 59 | const oneOf = (decl: T): Option<'string', false, false, ResolveOneOfDeclType> => { 60 | const normDecl = normalizeDecl(decl); 61 | const keys = Object.keys(normDecl); 62 | return option.string 63 | .label(keys.join('|')) 64 | .completer(partial => { 65 | return keys 66 | .filter(key => key.indexOf(partial) === 0) 67 | .map(key => { 68 | return { 69 | completion: key, 70 | description: normDecl[key].description 71 | }; 72 | }); 73 | }) 74 | .validate(value => { 75 | if (keys.includes(value)) { 76 | return; 77 | } 78 | throw new allIssues.TypeMismatchError(keys.map(k => `'${k}'`).join(' | '), value); 79 | }) as any; 80 | }; 81 | 82 | export default oneOf; 83 | -------------------------------------------------------------------------------- /presets/url.ts: -------------------------------------------------------------------------------- 1 | import url, { UrlWithStringQuery } from 'url'; 2 | 3 | import { Option } from '../src/option'; 4 | import { opt } from '../src/option'; 5 | 6 | const urlOption: () => Option<'string', boolean, boolean, UrlWithStringQuery> = () => opt('string') 7 | .label('url') 8 | .process('post', str => { 9 | return url.parse(str); 10 | }); 11 | 12 | export default urlOption; 13 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # typed-cli 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/int0h/typed-cli/badge.svg?branch=master)](https://coveralls.io/github/int0h/typed-cli?branch=master) 4 | 5 | ![typed-cli logo](./logo128.png) 6 | 7 | A library to help you create **type-safe** CLI's fast and simple. 8 | 9 | # Project state 10 | 11 | This project is still alive and maintained. But it is not in active development. 12 | 13 | # Usage example 14 | 15 | ```typescript 16 | import {cli, option} from 'typed-cli'; 17 | import app from './app'; 18 | 19 | const {options} = cli({ 20 | options: { 21 | env: option.oneOf(['dev', 'prod'] as const) 22 | .alias('e') 23 | .required() 24 | .description('app environment'), 25 | port: option.int 26 | .alias('p') 27 | .default(80) 28 | .description('port'), 29 | } 30 | }); 31 | 32 | // port: number 33 | // env: 'dev' | 'prod' 34 | const {port, env} = options; 35 | 36 | 37 | const DB_HOSTS = { 38 | dev: 'localhost:1234', 39 | prod: '1.2.3.4:9999', 40 | } as const; 41 | 42 | // Type safe! 43 | // no "No index signature with a parameter of type 'string'" kind of errors 44 | // because typeof env is 'dev' | 'prod' 45 | app.run({ 46 | db: DB_HOSTS[env], 47 | port 48 | }); 49 | ``` 50 | 51 | This code will behave like this: 52 | 53 | ![terminal-demo](./demo.gif) 54 | 55 | # Playground 56 | 57 | You can test it without installing _anything_ on your machine. Just go to the 58 | 59 | **[Interactive Demo](https://int0h.github.io/typed-cli-pg/index.html)** 60 | 61 | (⚠️it's about 20Mb of traffic). 62 | It has _interactive_ terminal and code editor, you can change the samples and see how it reacts. 63 | 64 | # Key features 65 | 66 | - **Type safety** 67 | - input validation (customizable) 68 | - help generation 69 | - printing reports for invalid data 70 | - **tab completions** 71 | - input data transformation 72 | - support for **commands** 73 | 74 | # Documentation 75 | The docs can be found here: [https://int0h.github.io/typed-cli-docs/](https://int0h.github.io/typed-cli-docs/) 76 | -------------------------------------------------------------------------------- /src/cli-helper.ts: -------------------------------------------------------------------------------- 1 | import { Parser, prepareCliDeclaration } from './parser'; 2 | import { Printer } from './printer'; 3 | import { CliDeclaration, ResolveCliDeclaration } from './type-logic'; 4 | import { isError } from './report'; 5 | import { CompleterOptions, tabtabCliDeclComplete, normalizeCompleterOptions } from './completer'; 6 | 7 | /** 8 | * Writer - is a function to be called by `typed-cli` 9 | * when it reports invalid data or printing help. 10 | * For the most cases `console.log` works fine. 11 | */ 12 | export type Writer = (str: string, logType: 'log' | 'error') => void; 13 | 14 | /** 15 | * Exiter - is a function that handles premature exit from program. 16 | * It will be fired when user inputs invalid data or 17 | * asked for help with `--help` flag. 18 | * It is `process.exit(...)` by default 19 | */ 20 | export type Exiter = (hasErrors: boolean) => void; 21 | 22 | /** 23 | * ArgvProvider - is a function to return argv. 24 | * It is `() => process.argv` by default. 25 | */ 26 | export type ArgvProvider = () => string[]; 27 | 28 | export type EnvProvider = () => Record; 29 | 30 | /** CliHelper - is a function that takes CLI declaration and returns data user inputed */ 31 | export type CliHelper = (decl: D) => ResolveCliDeclaration; 32 | 33 | export type CreateCliHelperParams = { 34 | writer: Writer; 35 | exiter: Exiter; 36 | argvProvider: ArgvProvider; 37 | envProvider:EnvProvider; 38 | printer: Printer; 39 | helpGeneration?: boolean; 40 | completer?: CompleterOptions | boolean; 41 | } 42 | 43 | /** 44 | * Creates a CliHelper. 45 | * `cli(...)` - is an example for CliHelper 46 | * @param params - helper configuration 47 | */ 48 | export function createCliHelper(params: CreateCliHelperParams): CliHelper { 49 | return (decl: D): ResolveCliDeclaration => { 50 | const {argvProvider, envProvider, exiter, printer, writer, helpGeneration, completer} = params; 51 | decl = prepareCliDeclaration(decl).decl as any; 52 | const parser = new Parser(decl); 53 | const argv = argvProvider(); 54 | if (completer) { 55 | const {completeCmd} = normalizeCompleterOptions(typeof completer === 'boolean' ? {} : completer); 56 | if (argv[0] === completeCmd) { 57 | tabtabCliDeclComplete(decl); 58 | exiter(false); 59 | throw new Error('exiter has failed'); 60 | } 61 | } 62 | if (helpGeneration) { 63 | if (argv.includes('--help')) { 64 | writer(printer.generateHelp(decl), 'log'); 65 | exiter(false); 66 | throw new Error('exiter has failed'); 67 | } 68 | } 69 | const {data, report} = parser.parse(argv, envProvider()); 70 | const printedReport = printer.stringifyReport(report); 71 | printedReport !== '' && writer(printedReport, 'error'); 72 | if (isError(report.issue)) { 73 | exiter(true); 74 | throw new Error('exiter has failed'); 75 | } 76 | return data as ResolveCliDeclaration; 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import { CliDeclaration, ResolveCliDeclaration } from "./type-logic"; 2 | import { createKebabAlias, findKeyCollision } from "./utils"; 3 | import { Parser, prepareCliDeclaration } from "./parser"; 4 | import { Writer, Exiter, ArgvProvider, EnvProvider } from "./cli-helper"; 5 | import { Printer } from "./printer"; 6 | import { isError } from "util"; 7 | import { Report, errorToReport } from "./report"; 8 | import { CompleterOptions, handleCompleterOptions, tabtabCommandDeclComplete } from "./completer"; 9 | import { allIssues } from "./errors"; 10 | 11 | /** 12 | * It can be used as a key in command set to set a default command. 13 | * Such command will be used if no command was provided. 14 | */ 15 | export const defaultCommand = Symbol('defaultCommand'); 16 | 17 | /** @hidden */ 18 | export type CommandSet = Record> & {[defaultCommand]?: CommandBuilder}; 19 | 20 | /** @hidden */ 21 | export type CommandHandler = (data: ResolveCliDeclaration) => void; 22 | 23 | /** @hidden */ 24 | export const _decl = Symbol('decl'); 25 | /** @hidden */ 26 | export const _subCommandSet = Symbol('subCommandSet'); 27 | /** @hidden */ 28 | export const _fn = Symbol('fn'); 29 | /** @hidden */ 30 | export const _aliases = Symbol('aliases'); 31 | /** @hidden */ 32 | export const _clone = Symbol('clone'); 33 | /** @hidden */ 34 | export const _match = Symbol('match'); 35 | 36 | export class CommandBuilder { 37 | [_decl]: D; 38 | [_fn]: CommandHandler; 39 | [_aliases]: string[] = []; 40 | [_subCommandSet]: CommandSet = {}; 41 | 42 | constructor(decl: D) { 43 | this[_decl] = decl; 44 | } 45 | 46 | /** @hidden */ 47 | [_clone] = (): CommandBuilder => { 48 | const cl = new CommandBuilder(this[_decl]); 49 | cl[_fn] = this[_fn]; 50 | cl[_aliases] = this[_aliases]; 51 | cl[_subCommandSet] = this[_subCommandSet]; 52 | return cl; 53 | } 54 | 55 | /** 56 | * Sets a command handler - a function to be called 57 | * if the input string matches against the command. 58 | * **Important:** a handler must be provided for a command, if you don't 59 | * want your program to do anything, just pass `() => {}` 60 | * @param fn - a function to be called for the command 61 | */ 62 | handle(fn: CommandHandler): CommandBuilder { 63 | const cl = this[_clone](); 64 | cl[_fn] = fn; 65 | return cl; 66 | } 67 | 68 | /** 69 | * Adds aliases to the command 70 | * @param aliases - alias list 71 | */ 72 | alias(...aliases: string[]): CommandBuilder { 73 | const cl = this[_clone](); 74 | cl[_aliases] = aliases; 75 | return cl; 76 | } 77 | 78 | /** 79 | * Sets sub-command for the current command. 80 | * The signature is similar to `T` in `cli.commands({}, )`. 81 | * 82 | * `git remote add` - is a "sub-command" where: 83 | * `git` - is a program, 84 | * `remote` - is a command 85 | * `add` - is sub-command of command `remote` 86 | * @param subCommandSet - map (dictionary) of sub-commands 87 | */ 88 | subCommands(subCommandSet: Record>): CommandBuilder { 89 | const cl = this[_clone](); 90 | cl[_subCommandSet] = { 91 | ...this[_subCommandSet], 92 | ...subCommandSet 93 | }; 94 | return cl; 95 | } 96 | 97 | /** @hidden */ 98 | [_match] = (cmdString: string): boolean => { 99 | return this[_aliases].includes(cmdString); 100 | } 101 | } 102 | 103 | function getCommandSetAliases(cs: CommandSet): string[] { 104 | let res: string[] = []; 105 | for (const key of Object.keys(cs)) { 106 | res = res.concat(cs[key][_aliases]); 107 | } 108 | return res; 109 | } 110 | 111 | /** @hidden */ 112 | export function prepareCommandSet(cs: C, cfg: CommandHelperParams = {}): C { 113 | const namePrefix = cfg.program ?? ''; 114 | const res: C = {} as C; 115 | for (const key of Object.keys(cs).sort()) { 116 | const cmd = cs[key][_clone](); 117 | if (!cmd[_fn]) { 118 | throw new Error('no handler was set for command <${key}>'); 119 | } 120 | cmd[_aliases] = [key, ...cmd[_aliases]]; 121 | const kebab = createKebabAlias(key); 122 | if (kebab) { 123 | cmd[_aliases].push(kebab); 124 | } 125 | cmd[_decl] = { 126 | ...prepareCliDeclaration(cmd[_decl]).decl, 127 | name: namePrefix + ' ' + key 128 | }; 129 | cmd[_subCommandSet] = prepareCommandSet(cmd[_subCommandSet], {...cfg, program: namePrefix + ' ' + key}); 130 | cmd[_decl].useEnv ??= cfg.useEnv; 131 | cmd[_decl].envPrefix ??= cfg.envPrefix; 132 | res[key as keyof C] = cmd as any; 133 | } 134 | const defCmd = cs[defaultCommand]; 135 | if (defCmd) { 136 | const cmd = defCmd[_clone](); 137 | if (!cmd[_fn]) { 138 | throw new Error('no handler was set for command <${key}>'); 139 | } 140 | cmd[_decl] = { 141 | ...prepareCliDeclaration(defCmd[_decl]).decl, 142 | name: namePrefix 143 | }; 144 | res[defaultCommand] = cmd; 145 | } 146 | const allAliases = getCommandSetAliases(res); 147 | const aliasCollision = findKeyCollision(allAliases); 148 | if (aliasCollision) { 149 | throw new Error(`alias colision for comand <${aliasCollision}>`); 150 | } 151 | return res; 152 | } 153 | 154 | export type ParseCommandSetParams = { 155 | cs: CommandSet; 156 | argv: string[]; 157 | env: Record; 158 | onReport: (report: Report) => void; 159 | onHelp?: (cmd: CommandBuilder) => void; 160 | } 161 | 162 | /** @hidden */ 163 | export function findMatchedCommand(argv: string[], cs: CommandSet): CommandBuilder | null { 164 | let matched: CommandBuilder | undefined = undefined; 165 | 166 | for (const command of Object.values(cs)) { 167 | if (command[_match](argv[0])) { 168 | matched = command; 169 | break; 170 | } 171 | } 172 | 173 | matched = matched || cs[defaultCommand]; 174 | 175 | if (!matched) { 176 | return null; 177 | } 178 | 179 | const childMatch = findMatchedCommand(argv.slice(1), matched[_subCommandSet]); 180 | 181 | return childMatch || matched; 182 | } 183 | 184 | function parseCommand(cmd: CommandBuilder, args: string[], env: Record, params: ParseCommandSetParams): void { 185 | const {onReport, onHelp} = params; 186 | const handledByChild = parseCommandSet({ 187 | cs: cmd[_subCommandSet], 188 | argv: args, 189 | env, 190 | onReport, 191 | onHelp 192 | }); 193 | if (handledByChild) { 194 | return; 195 | } 196 | if (onHelp && args.includes('--help')) { 197 | onHelp(cmd); 198 | return; 199 | } 200 | const parser = new Parser(cmd[_decl]); 201 | const {report, data} = parser.parse(args, env); 202 | if (report.issue !== null || report.children.length > 0) { 203 | onReport(report); 204 | } 205 | cmd[_fn](data as any); 206 | return; 207 | } 208 | 209 | /** @hidden */ 210 | export function parseCommandSet(params: ParseCommandSetParams): boolean { 211 | const {cs, argv, env} = params; 212 | const [commandName, ...args] = argv; 213 | for (const cmd of Object.values(cs)) { 214 | if (cmd[_match](commandName)) { 215 | parseCommand(cmd, args, env, params); 216 | return true; 217 | } 218 | } 219 | return false; 220 | } 221 | 222 | export type CreateCommandHelperParams = { 223 | writer: Writer; 224 | exiter: Exiter; 225 | argvProvider: ArgvProvider; 226 | envProvider: EnvProvider; 227 | printer: Printer; 228 | helpGeneration?: boolean; 229 | } 230 | 231 | export type CommandHelperParams = { 232 | /** program name */ 233 | program?: string; 234 | /** program description */ 235 | description?: string; 236 | /** `true` or completer config if tab complitions wanted */ 237 | completer?: CompleterOptions | boolean; 238 | /** parse options from environmental variables */ 239 | useEnv?: boolean; 240 | /** environmental variable prefix */ 241 | envPrefix?: string; 242 | } 243 | 244 | /** 245 | * Creates a CommandHelper. `cli.command` - is an example of CommandHelper 246 | * created with this function. 247 | * @param params 248 | */ 249 | export const createCommandHelper = (params: CreateCommandHelperParams) => 250 | (cfg: CommandHelperParams, cs: CommandSet): void => { 251 | cs = prepareCommandSet(cs, cfg); 252 | const {writer, exiter, argvProvider, envProvider, printer, helpGeneration} = params; 253 | const argv = argvProvider(); 254 | const env = envProvider(); 255 | if (cfg.completer) { 256 | const program = cfg.program; 257 | if (!program) { 258 | throw new Error('program name must be provided for completions'); 259 | } 260 | const handled = handleCompleterOptions(argv[0], cfg.completer, program, () => { 261 | tabtabCommandDeclComplete(cs); 262 | exiter(false); 263 | throw new Error('exiter has failed'); 264 | }, (hasErrors) => { 265 | exiter(hasErrors); 266 | throw new Error('exiter has failed'); 267 | }); 268 | if (handled) { 269 | return; 270 | } 271 | } 272 | const onReport = (report: Report): void => { 273 | const printedReport = printer.stringifyReport(report); 274 | printedReport !== '' && writer(printedReport, 'error'); 275 | if (isError(report.issue)) { 276 | exiter(true); 277 | throw new Error('exiter has failed'); 278 | } 279 | }; 280 | const onHelp = (cmd: CommandBuilder): void => { 281 | writer(printer.generateHelp(cmd[_decl]), 'log'); 282 | } 283 | const handled = parseCommandSet({cs, argv, env, onReport, onHelp}); 284 | if (handled) { 285 | return; 286 | } 287 | if (helpGeneration && argv.includes('--help')) { 288 | writer(printer.generateHelpForComands(cfg, cs), 'log'); 289 | exiter(false); 290 | throw new Error('exiter has failed'); 291 | } 292 | 293 | const defCmd = cs[defaultCommand as unknown as keyof CommandSet]; 294 | const firstCommand = argv[0]; 295 | const hasCommand = firstCommand && /^[^-]/.test(firstCommand); 296 | if (!hasCommand) { 297 | if (defCmd) { 298 | parseCommand(defCmd, argv, env, {cs, argv, env, onReport, onHelp}); 299 | return; 300 | } else { 301 | onReport(errorToReport(new allIssues.NoCommand())); 302 | } 303 | } 304 | 305 | onReport(errorToReport(new allIssues.InvalidCommand(firstCommand))); 306 | } 307 | 308 | /** 309 | * Defines a program command 310 | * @param decl - command declaration, which is basicly the same as program declaration passed to `cli()` 311 | */ 312 | export function command(decl: D): CommandBuilder { 313 | return new CommandBuilder(decl); 314 | } 315 | -------------------------------------------------------------------------------- /src/completer.ts: -------------------------------------------------------------------------------- 1 | import { CommandSet, findMatchedCommand, _decl, _aliases, _subCommandSet, defaultCommand } from './command'; 2 | import { CliDeclaration } from './type-logic'; 3 | import yargsParser from 'yargs-parser'; 4 | import { objMap } from './utils'; 5 | import { getOptData, OptData } from './option'; 6 | import {parseArgsStringToArgv} from 'string-argv'; 7 | import * as tabtab from 'tabtab'; 8 | // import {} from '' 9 | 10 | export type Completion = { 11 | completion: string; 12 | description: string; 13 | }; 14 | 15 | function completeForOptionValue(option: OptData, typedText: string): Completion[] { 16 | if (option.completer) { 17 | return option.completer(typedText); 18 | } 19 | return []; 20 | } 21 | 22 | function genOptionMap(decl: CliDeclaration): Record> { 23 | const res: Record> = {}; 24 | for (const opt of Object.values(decl.options || {})) { 25 | const optData = getOptData(opt); 26 | res[opt.name] = optData; 27 | for (const alias of optData.aliases) { 28 | res[alias] = optData; 29 | } 30 | } 31 | return res; 32 | } 33 | 34 | export function completeForCliDecl(decl: CliDeclaration, argv: string[], typedText: string): Completion[] { 35 | const parsed = yargsParser(argv, { 36 | alias: decl.options && objMap(decl.options, item => getOptData(item).aliases) 37 | }); 38 | const lastCmd = argv[argv.length - 1]; 39 | const optionMap = genOptionMap(decl); 40 | 41 | // option value 42 | if (lastCmd && lastCmd.startsWith('-')) { 43 | const optName = lastCmd.startsWith('--') 44 | ? lastCmd.slice(2) // removes '--' 45 | : lastCmd.slice(1); // removes '-' 46 | const option = optionMap[optName]; 47 | if (!option) { 48 | return []; 49 | } 50 | if (option.type !== 'boolean') { 51 | return completeForOptionValue(option, typedText); 52 | } 53 | } 54 | 55 | const getOptionNameCompletions = (partialName: string, longOpt: boolean): Completion[] => { 56 | const availableOptions = Object.entries(optionMap) 57 | // skip used opts 58 | .filter(([, optData]) => { 59 | if (optData.isArray) { 60 | return true; 61 | } 62 | return !parsed[optData.name]; 63 | }) 64 | // filter by text 65 | .filter(([key]) => key.indexOf(partialName) === 0) 66 | // skip shorthands if '--' is typed already 67 | .filter(([key]) => key.length > 1 || !longOpt); 68 | return availableOptions.map(([key, optData]) => { 69 | const prefix = key.length > 1 ? '--' : '-'; 70 | return { 71 | completion: prefix + key, 72 | description: optData.description 73 | }; 74 | }); 75 | }; 76 | 77 | const getArgumentCompletions = (): Completion[] => []; 78 | 79 | if (typedText === '') { 80 | return [ 81 | ...getOptionNameCompletions('', false), 82 | ...getArgumentCompletions() 83 | ] 84 | } else if (typedText.startsWith('-')) { 85 | return getOptionNameCompletions(typedText.replace(/^--?/, ''), typedText.startsWith('--')); 86 | } else { 87 | return getArgumentCompletions(); 88 | } 89 | } 90 | 91 | function completeCommands(cs: CommandSet, typedText: string): Completion[] { 92 | const res: Completion[] = []; 93 | for (const cmd of Object.values(cs)) { 94 | for (const alias of cmd[_aliases]) { 95 | if (alias.indexOf(typedText) === 0) { 96 | res.push({ 97 | completion: alias, 98 | description: cmd[_decl].description || '' 99 | }); 100 | } 101 | } 102 | } 103 | return res; 104 | } 105 | 106 | export function completeForCommandSet(cs: CommandSet, argv: string[], typedText: string): Completion[] { 107 | const matchedCommand = findMatchedCommand(argv, cs); 108 | if (!matchedCommand || matchedCommand === cs[defaultCommand]) { 109 | return completeCommands(cs, typedText); 110 | } 111 | const decl = matchedCommand[_decl]; 112 | return [ 113 | ...completeCommands(matchedCommand[_subCommandSet], typedText), 114 | ...completeForCliDecl(decl, argv, typedText), 115 | ]; 116 | } 117 | 118 | export function tabtabCommandDeclComplete(cs: CommandSet): void { 119 | const env = tabtab.parseEnv(process.env); 120 | const line = env.last.length > 0 121 | ? env.line.slice(0, -env.last.length) 122 | : env.line; 123 | const argv = parseArgsStringToArgv(line).slice(1); 124 | const completions = completeForCommandSet(cs, argv, env.last); 125 | tabtab.log( 126 | completions.map(c => ({ 127 | name: c.completion, 128 | description: c.description 129 | })) 130 | ); 131 | } 132 | 133 | export function tabtabCliDeclComplete(decl: CliDeclaration): void { 134 | const env = tabtab.parseEnv(process.env); 135 | const line = env.last.length > 0 136 | ? env.line.slice(0, -env.last.length) 137 | : env.line; 138 | const argv = parseArgsStringToArgv(line).slice(1); 139 | const completions = completeForCliDecl(decl, argv, env.last); 140 | tabtab.log( 141 | completions.map(c => ({ 142 | name: c.completion, 143 | description: c.description 144 | })) 145 | ); 146 | } 147 | 148 | export type CompleterOptions = { 149 | installCmd?: string; 150 | uninstallCmd?: string; 151 | completeCmd?: string; 152 | } 153 | 154 | export function normalizeCompleterOptions(opts: CompleterOptions): Required { 155 | return { 156 | installCmd: 'typed-cli--install-shell-completions', 157 | uninstallCmd: 'typed-cli--install-shell-completions', 158 | completeCmd: 'typed-cli--complete-input', 159 | ...opts 160 | } 161 | } 162 | 163 | export function handleCompleterOptions( 164 | cmd: string, 165 | opts: CompleterOptions | boolean, 166 | name: string | undefined, 167 | onComplete: () => void, 168 | exiter: (hasErrors: boolean) => void 169 | ): boolean { 170 | const completerOpts = normalizeCompleterOptions(typeof opts === 'boolean' ? {} : opts); 171 | if (cmd === completerOpts.installCmd) { 172 | if (!name) { 173 | throw new Error('name must be provided for completions'); 174 | } 175 | tabtab 176 | .install({ 177 | name: name, 178 | completer: name, 179 | completeCmd: completerOpts.completeCmd 180 | }) 181 | .then(() => exiter(false)) 182 | .catch(err => { 183 | console.error('INSTALL ERROR', err); 184 | exiter(true); 185 | }); 186 | return true; 187 | } 188 | if (cmd === completerOpts.uninstallCmd) { 189 | if (!name) { 190 | throw new Error('name must be provided for completions'); 191 | } 192 | tabtab 193 | .uninstall({ 194 | name: name, 195 | }) 196 | .then(() => exiter(false)) 197 | .catch(err => { 198 | console.error('UNINSTALL ERROR', err); 199 | exiter(true); 200 | }); 201 | return true; 202 | } 203 | if (cmd === completerOpts.completeCmd) { 204 | onComplete(); 205 | return true; 206 | } 207 | return false; 208 | } 209 | -------------------------------------------------------------------------------- /src/decorator.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export {chalk as chalkInstance}; 4 | 5 | type DecoratorFn = (text: string) => string; 6 | 7 | /** for TS only */ 8 | function __definePlainTextDecorator>(decorators: T): T { 9 | return decorators; 10 | } 11 | 12 | export const plain = __definePlainTextDecorator({ 13 | alias: s => s, 14 | type: s => s, 15 | optional: s => s, 16 | required: s => s, 17 | multiple: s => s, 18 | optionDescription: s => s, 19 | title: s => s, 20 | usageOption: s => s, 21 | command: s => s, 22 | errorLine: s => s, 23 | warningLine: s => s, 24 | invalidValue: s => s, 25 | commandPath: s => s, 26 | commandEnding: s => s, 27 | commandDescription: s => s, 28 | }); 29 | 30 | export type TextDecorator = typeof plain; 31 | 32 | export function defineTextDecorator>(decorators: T): T { 33 | return decorators; 34 | } 35 | 36 | export const fancy = defineTextDecorator({ 37 | alias: s => s, 38 | type: s => chalk.green(s), 39 | optional: s => chalk.yellow(s), 40 | required: s => chalk.redBright(s), 41 | multiple: s => chalk.cyan(s), 42 | optionDescription: s => chalk.dim(s), 43 | title: s => chalk.underline(s), 44 | usageOption: s => chalk.italic(s), 45 | command: s => chalk.bold(s), 46 | errorLine: s => '❌ ' + s, 47 | warningLine: s => '⚠️ ' + s, 48 | invalidValue: s => chalk.redBright(s), 49 | commandPath: s => chalk.dim(s), 50 | commandEnding: s => chalk.blueBright(s), 51 | commandDescription: s => chalk.reset(s), 52 | }); 53 | 54 | export const decorators = {fancy, plain}; 55 | -------------------------------------------------------------------------------- /src/default-cli.ts: -------------------------------------------------------------------------------- 1 | import { createCliHelper, ArgvProvider, Exiter, Writer, EnvProvider } from './cli-helper'; 2 | import { Printer } from './printer'; 3 | import { en_US } from './i18n'; 4 | import { fancy } from './decorator'; 5 | import { createCommandHelper } from './command'; 6 | import { CliDeclaration, ResolveCliDeclaration } from './type-logic'; 7 | 8 | export const defaultPrinter = new Printer({locale: en_US, decorator: fancy}); 9 | 10 | export const defaultArgvProvider: ArgvProvider = () => process.argv.slice(2); 11 | 12 | export const defaultEnvProvider: EnvProvider = () => process.env; 13 | 14 | export const defaultExiter: Exiter = hasErrors => process.exit(hasErrors ? 1 : 0); 15 | 16 | export const defaultWriter: Writer = (text, logType) => { 17 | switch (logType) { 18 | case 'error': console.error(text); return; 19 | case 'log': console.log(text); return; 20 | default: throw new Error('unknown logType'); 21 | } 22 | }; 23 | 24 | const cliHelper = createCliHelper({ 25 | printer: defaultPrinter, 26 | argvProvider: defaultArgvProvider, 27 | envProvider: defaultEnvProvider, 28 | exiter: defaultExiter, 29 | writer: defaultWriter, 30 | helpGeneration: true, 31 | completer: true 32 | }); 33 | 34 | export const setupCommands = createCommandHelper({ 35 | printer: defaultPrinter, 36 | envProvider: defaultEnvProvider, 37 | argvProvider: defaultArgvProvider, 38 | exiter: defaultExiter, 39 | writer: defaultWriter, 40 | helpGeneration: true 41 | }); 42 | 43 | export function cli(decl: D): ResolveCliDeclaration { 44 | return cliHelper(decl); 45 | } 46 | 47 | cli.commands = setupCommands; 48 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class BaseError extends Error { 2 | className!: keyof typeof allIssues; 3 | } 4 | 5 | export class BaseWarning { 6 | className!: keyof typeof allIssues; 7 | isWarning = true; 8 | } 9 | 10 | class EmptyRequiredOptionError extends BaseError { 11 | requiredOption: string; 12 | className = 'EmptyRequiredOptionError' as const; 13 | constructor(requiredOption: string) { 14 | super(); 15 | this.requiredOption = requiredOption; 16 | } 17 | } 18 | 19 | class TypeMismatchError extends BaseError { 20 | className = 'TypeMismatchError' as const; 21 | expected: string; 22 | received: string; 23 | constructor(expected: string, received: string) { 24 | super(); 25 | this.expected = expected; 26 | this.received = received; 27 | } 28 | } 29 | 30 | class IvalidOptionError extends BaseError { 31 | className = 'IvalidOptionError' as const; 32 | optionName: string; 33 | value: any; 34 | constructor(optionName: string, value: any) { 35 | super(); 36 | this.optionName = optionName; 37 | this.value = value; 38 | } 39 | } 40 | 41 | class SomeIvalidOptionsError extends BaseError { 42 | className = 'SomeIvalidOptionsError' as const; 43 | } 44 | 45 | class IvalidSomeArguemntsError extends BaseError { 46 | className = 'IvalidSomeArguemntsError' as const; 47 | } 48 | 49 | class IvalidArguemntError extends BaseError { 50 | className = 'IvalidArguemntError' as const; 51 | value: any; 52 | constructor(value: any) { 53 | super(); 54 | this.value = value; 55 | } 56 | } 57 | 58 | class TooManyArgumentsError extends BaseError { 59 | className = 'TooManyArgumentsError' as const; 60 | } 61 | 62 | class InvalidCommand extends BaseError { 63 | className = 'InvalidCommand' as const; 64 | commandName: any; 65 | constructor(commandName: any) { 66 | super(); 67 | this.commandName = commandName; 68 | } 69 | } 70 | 71 | class NoCommand extends BaseError { 72 | className = 'NoCommand' as const; 73 | } 74 | 75 | class IvalidInputError extends BaseError { 76 | className = 'IvalidInputError' as const; 77 | } 78 | 79 | class UnknownOptionWarning extends BaseWarning { 80 | className = 'UnknownOptionWarning' as const; 81 | optionName: string; 82 | constructor(optionName: string) { 83 | super(); 84 | this.optionName = optionName; 85 | } 86 | } 87 | 88 | export type IssueType = { 89 | [key in keyof typeof allIssues]: InstanceType<(typeof allIssues)[key]>; 90 | }[keyof typeof allIssues]; 91 | 92 | export const allIssues = { 93 | UnknownOptionWarning, 94 | EmptyRequiredOptionError, 95 | IvalidOptionError, 96 | IvalidSomeArguemntsError, 97 | IvalidArguemntError, 98 | TooManyArgumentsError, 99 | SomeIvalidOptionsError, 100 | IvalidInputError, 101 | TypeMismatchError, 102 | InvalidCommand, 103 | NoCommand 104 | } 105 | 106 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { allIssues } from './errors'; 2 | import { TextDecorator } from './decorator'; 3 | import { Issue } from './report'; 4 | 5 | export type LocaleFn = (decorator: TextDecorator, data?: any) => string; 6 | 7 | export type IssueLocaleFn = (decorator: TextDecorator, issue: Issue) => string; 8 | 9 | type IssueLocale = { 10 | [key in keyof typeof allIssues]: (issue: InstanceType<(typeof allIssues)[key]>, decorator: TextDecorator) => string; 11 | } 12 | 13 | function __declareEnglishTextsLocale>(locale: T): T { 14 | return locale; 15 | } 16 | 17 | /* eslint-disable @typescript-eslint/no-unused-vars */ 18 | export const en_US = { 19 | code: 'en_US', 20 | texts: __declareEnglishTextsLocale({ 21 | title_description: d => d.title('Description'), 22 | title_usage: d => d.title('Usage'), 23 | title_options: d => d.title('Options'), 24 | title_commands: d => d.title('Commands'), 25 | hint_commandHint: (d, {command} = {}) => `Type ${d.command(command)} --help for detailed documentation`, 26 | opt_required: d => 'required', 27 | opt_optional: d => 'optional', 28 | opt_multile: d => 'multiple', 29 | }), 30 | issues: { 31 | IvalidOptionError: (e, d) => `option <${d.invalidValue(e.optionName)}> is invalid`, 32 | EmptyRequiredOptionError: (e, d) => `it's required`, 33 | IvalidInputError: (e, d) => `provided arguments were not correct`, 34 | SomeIvalidOptionsError: (e, d) => `some of the options are invalid`, 35 | UnknownOptionWarning: (e, d) => `option <${d.invalidValue(e.optionName)}> is not supported`, 36 | TypeMismatchError: (e, d) => `expected <${e.expected}>, but received <${d.invalidValue(e.received)}>`, 37 | IvalidSomeArguemntsError: (e, d) => `some of the arguments are invalid`, 38 | IvalidArguemntError: (e, d) => `provided argument value <${d.invalidValue(e.value)}> is not valid`, 39 | TooManyArgumentsError: (e, d) => `the program supports only 1 argument but many were provided`, 40 | InvalidCommand: (e, d) => `command <${d.invalidValue(e.commandName)}> is not supported`, 41 | NoCommand: (e, d) => `no command was provided and no default command was set` 42 | } as IssueLocale 43 | }; 44 | /* eslint-enable @typescript-eslint/no-unused-vars */ 45 | 46 | export type Locale = typeof en_US; 47 | 48 | export function declareLocale(locale: Locale): Locale { 49 | return locale; 50 | } 51 | 52 | export const locales = {en_US}; 53 | -------------------------------------------------------------------------------- /src/option-helper.ts: -------------------------------------------------------------------------------- 1 | import { opt } from "./option"; 2 | import { oneOf, url } from "../presets"; 3 | 4 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 5 | export const option = { 6 | get int(){return opt('int')}, 7 | get number(){return opt('number')}, 8 | get boolean(){return opt('boolean')}, 9 | get string(){return opt('string')}, 10 | get any(){return opt('any')}, 11 | 12 | // presets: 13 | oneOf, 14 | get url() {return url()} 15 | }; 16 | -------------------------------------------------------------------------------- /src/option.ts: -------------------------------------------------------------------------------- 1 | import { Validator, Preprocessor, makeValidator, BooleanValidator } from './pipeline'; 2 | import { allIssues } from './errors'; 3 | import { Completion } from './completer'; 4 | 5 | type TypeMap = { 6 | number: number; 7 | int: number; 8 | string: string; 9 | boolean: boolean; 10 | any: number | string | boolean; 11 | } 12 | 13 | /** @hidden */ 14 | export type Types = keyof TypeMap; 15 | 16 | /** @hidden */ 17 | export type ResolveType = TypeMap[T]; 18 | 19 | const intrinsicPreProcessors: Partial> = { 20 | string: s => typeof s === 'number' ? String(s) : s 21 | } 22 | 23 | const intrinsicValidators: Partial>> = { 24 | number: n => n * 1 === n, 25 | int: n => Number.isInteger(n), 26 | string: s => typeof s === 'string', 27 | boolean: b => typeof b === 'boolean' 28 | } 29 | 30 | const optionDataKey = Symbol('__data'); 31 | 32 | type OptionCompleter = (partial: string) => Completion[]; 33 | 34 | /** @hidden */ 35 | export type OptData = { 36 | name: string; 37 | type: Types; 38 | labelName: string; 39 | description: string; 40 | isRequired: boolean; 41 | aliases: string[]; 42 | isArray: boolean; 43 | defaultValue?: T; 44 | isArg?: boolean; 45 | validators: Validator[]; 46 | prePreprocessors: Preprocessor[]; 47 | postPreprocessors: Preprocessor[]; 48 | completer?: OptionCompleter; 49 | }; 50 | 51 | /** @hidden */ 52 | export function getOptData(opt: Option): OptData { 53 | return opt[optionDataKey]; 54 | } 55 | 56 | /** @hidden */ 57 | export function setOptData(opt: Option, data: OptData): void { 58 | opt[optionDataKey] = data; 59 | } 60 | 61 | /** @hidden */ 62 | export function cloneOption>(opt: O): O { 63 | const oldOpt = opt; 64 | const oldData = getOptData(oldOpt); 65 | opt = new Option(oldData.type) as any; 66 | setOptData(opt, oldData); 67 | return opt; 68 | } 69 | 70 | /** @hidden */ 71 | export function updateOptData>(opt: O, data: Partial>): O { 72 | return changeOptData(cloneOption(opt), data); 73 | } 74 | 75 | /** @hidden */ 76 | export function changeOptData>(opt: O, data: Partial>): O { 77 | setOptData(opt, { 78 | ...getOptData(opt), 79 | ...data 80 | }); 81 | return opt; 82 | } 83 | 84 | 85 | /** 86 | * Defines a new option 87 | * @param type option data type 88 | */ 89 | export function opt(type: T): Option> { 90 | return new Option>(type); 91 | } 92 | 93 | /** 94 | * Option - is a helper class used to configure options. 95 | * It's used for chained calls such as 96 | * ```js 97 | * ... .alias().description().default() ... 98 | * ``` 99 | */ 100 | export class Option { 101 | /** @hidden */ 102 | name = ''; 103 | /** @hidden */ 104 | [optionDataKey]: OptData = { 105 | name: '', 106 | type: 'any', 107 | labelName: 'any', 108 | description: '', 109 | isRequired: false, 110 | aliases: [], 111 | isArray: false, 112 | defaultValue: undefined, 113 | validators: [], 114 | prePreprocessors: [], 115 | postPreprocessors: [], 116 | }; 117 | 118 | _isRequired!: R; 119 | _isArray!: A; 120 | 121 | constructor(type: T) { 122 | this[optionDataKey].type = type; 123 | this[optionDataKey].labelName = type; 124 | const intrinsicValidator = intrinsicValidators[type]; 125 | if (intrinsicValidator) { 126 | changeOptData(this, { 127 | validators: [ 128 | (value): void => { 129 | if (intrinsicValidator(value)) { 130 | return; 131 | } 132 | throw new allIssues.TypeMismatchError(this[optionDataKey].labelName, typeof value); 133 | } 134 | ] 135 | }); 136 | } 137 | const intrinsicPreProcessor = intrinsicPreProcessors[type]; 138 | if (intrinsicPreProcessor) { 139 | changeOptData(this, { 140 | prePreprocessors: [intrinsicPreProcessor as Preprocessor] 141 | }) 142 | } 143 | } 144 | 145 | /** 146 | * Allows to create custom type name. 147 | * Useful for presets, allows to get output like: 148 | * `expected but recieved ` 149 | * @param name - new label for the type 150 | */ 151 | label(name: string): Option { 152 | return updateOptData(this, { 153 | labelName: name 154 | }); 155 | } 156 | 157 | /** 158 | * Adds one or more aliases to an option. 159 | * Used to create short aliases such '-a', '-b' etc. 160 | * Could be called multiple times, the alias lists will be 161 | * concatenated. 162 | * @param aliases - alias list 163 | */ 164 | alias(...aliases: string[]): Option { 165 | return updateOptData(this, { 166 | aliases: this[optionDataKey].aliases.concat(aliases) 167 | }); 168 | } 169 | 170 | /** 171 | * Sets the compliter for the option. 172 | * A completer is a function to be called when 173 | * shell completion is computated for an option. 174 | * See 'oneOf' preset source code for usage. 175 | * @param completer - completer function 176 | */ 177 | completer(completer: OptionCompleter): Option { 178 | return updateOptData(this, { 179 | completer 180 | }); 181 | } 182 | 183 | /** 184 | * Sets the description of the option that is 185 | * printed with the rest of the help when '--help' flag 186 | * is provided. 187 | * @param text - description string 188 | */ 189 | description(text: string): Option { 190 | return updateOptData(this, { 191 | description: text 192 | }); 193 | } 194 | 195 | /** 196 | * Marks the option as required. 197 | * Required options must be provided. Otherwise 198 | * the program will quit with non-zero code and print an error. 199 | * On the other hand required options always accessible so 200 | * there is no need to check if they are presented i.e. 201 | * no `options.foo && options.foo.toString()` checks. 202 | */ 203 | required(): Option { 204 | return updateOptData(this, { 205 | isRequired: true 206 | }) as any; 207 | } 208 | 209 | /** 210 | * Marks the option as multiple. 211 | * It allows to pass the same option multiple times. 212 | * So `-o 1 -o 2 -o 3` will be resolved as `[1, 2, 3]`. 213 | * *Important:* result will be an array even if only one value 214 | * was presented (or no value at all) 215 | * i.e. both `[]` and `[1]` are valid results. 216 | */ 217 | array(): Option { 218 | return updateOptData(this, { 219 | isArray: true 220 | }) as any; 221 | } 222 | 223 | /** 224 | * Sets the default value for an option. 225 | * Option will be resolved to that value if no value was present. 226 | * Also removes `nullability` from the result type like `required()` does. 227 | * @param value - default value of the option 228 | */ 229 | default(value: RT): Option { 230 | return updateOptData(this, { 231 | isRequired: false, 232 | defaultValue: value, 233 | }) as any; 234 | } 235 | 236 | /** 237 | * Adds custom validation function. 238 | * @param errorMsg - error message to be shown if the result of validate function is falsy 239 | * @param validator - a validate function that takes a value and return `true` for valid 240 | * values and `false` otherwise 241 | */ 242 | validate(errorMsg: string, validator: BooleanValidator): Option; 243 | /** 244 | * Adds custom validation function. 245 | * @param validator - a validate function that will throw an error if the provided value is invalid 246 | */ 247 | validate(validator: Validator): Option; 248 | validate(...args: any[]): Option { 249 | const validator = args.length === 2 250 | ? makeValidator(args[0], args[1]) 251 | : args[0]; 252 | return updateOptData(this, { 253 | validators: getOptData(this).validators.concat(validator), 254 | }); 255 | } 256 | 257 | 258 | /** 259 | * Adds a pre-/post- processor. A processor is a function 260 | * that takes a value and return a new one. Each processor gets 261 | * the result from the previous one and passes new value to the next one. 262 | * So it looks like this: `argv -> proc1 -> proc2 -> result data` 263 | * 264 | * Preprocessors run from raw input and before validation, while 265 | * postprocessors run after validation and can produce either __invalid__ or non-string-like 266 | * values such as objects, functions etc. 267 | * 268 | * So the full data pipeline looks like this: 269 | * `argv -> preprocessors -> validators -> postprocessors -> result data` 270 | * @param phase - determine whether it will be a **pre**processor or **post** processor 271 | * @param fn - a processor function 272 | */ 273 | process(phase: 'pre', fn: Preprocessor>): Option; 274 | process(phase: 'post', fn: Preprocessor, FR>): Option; 275 | process(phase: 'pre' | 'post', fn: Preprocessor): Option { 276 | switch (phase) { 277 | case 'pre': 278 | return updateOptData(this, { 279 | prePreprocessors: getOptData(this).prePreprocessors.concat(fn), 280 | }); 281 | case 'post': 282 | return updateOptData(this, { 283 | postPreprocessors: getOptData(this).postPreprocessors.concat(fn), 284 | }); 285 | default: 286 | throw new Error(`invalid phase <${phase}>`); 287 | } 288 | } 289 | } 290 | 291 | export type OptionSet = Record>; 292 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import yargsParser from 'yargs-parser'; 2 | 3 | import { OptionSet, getOptData, updateOptData } from './option'; 4 | import { CliDeclaration, ResolveCliDeclaration } from './type-logic'; 5 | import { handleAllOptions, handleOption } from './pipeline'; 6 | import { createKebabAlias, objMap, uniq } from './utils'; 7 | import { Report, mergeReports, isError } from './report'; 8 | import { allIssues } from './errors'; 9 | 10 | function checkAliasCollisions(opts: OptionSet): Set { 11 | const usedKeys = new Set(); 12 | 13 | const check = (str: string): void => { 14 | if (usedKeys.has(str)) { 15 | throw new Error(`alias collision for "${str}"`); 16 | } 17 | usedKeys.add(str); 18 | } 19 | 20 | for (const [name, opt] of Object.entries(opts)) { 21 | check(name); 22 | getOptData(opt).aliases.forEach(check); 23 | } 24 | 25 | return usedKeys; 26 | } 27 | 28 | export function prepareCliDeclaration(decl: CliDeclaration): {decl: Required; usedKeys: Set} { 29 | const resDecl = {...decl}; 30 | resDecl.options = {...(decl.options || {})}; 31 | for (const [name, opt] of Object.entries(resDecl.options)) { 32 | const alias = createKebabAlias(name); 33 | let resOpt = updateOptData(opt, {name}); 34 | if (alias) { 35 | resOpt = updateOptData(resOpt, { 36 | aliases: uniq([...getOptData(opt).aliases, alias]) 37 | }); 38 | } 39 | resOpt.name = name; 40 | resDecl.options[name] = resOpt; 41 | } 42 | if (decl._) { 43 | resDecl._ = updateOptData(decl._, {name: '#argument#', isArg: true}) 44 | } 45 | 46 | const usedKeys = checkAliasCollisions(resDecl.options); 47 | return {decl: resDecl as Required, usedKeys}; 48 | } 49 | 50 | function extractOptionsFromYargs(data: any): any { 51 | const copyData = {...data}; 52 | delete copyData.$0; 53 | delete copyData._; 54 | return copyData; 55 | } 56 | 57 | function optNameToEnvName(optName: string) { 58 | return optName 59 | .replace(/[A-Z]/g, l => '_' + l) 60 | .toUpperCase(); 61 | } 62 | 63 | export class Parser { 64 | private optCfg: Record; 65 | private decl: D; 66 | private usedKeys: Set; 67 | private envPrefix: string; 68 | private useEnv: boolean; 69 | 70 | constructor(decl: D) { 71 | const {decl: declPrepared, usedKeys} = prepareCliDeclaration(decl); 72 | this.usedKeys = usedKeys; 73 | this.decl = declPrepared as D; 74 | this.optCfg = objMap(declPrepared.options, item => getOptData(item)); 75 | this.envPrefix = decl.envPrefix ?? ''; 76 | this.useEnv = decl.useEnv ?? false; 77 | if (decl.useEnv && decl.envPrefix === undefined && decl.name) { 78 | this.envPrefix = optNameToEnvName(decl.name) + '_'; 79 | } 80 | } 81 | 82 | private parseOptions(parsed: any): {data: any; report: Report} { 83 | return handleAllOptions(this.optCfg, extractOptionsFromYargs(parsed), this.usedKeys); 84 | } 85 | 86 | private parseArguments(parsed: any): {data: any; report: Report} { 87 | if (this.decl._) { 88 | let parsedArgs = parsed._; 89 | 90 | if (!getOptData(this.decl._).isArray) { 91 | // if multiiple args passed to single argument program 92 | if (parsedArgs.length > 1) { 93 | return { 94 | data: undefined, 95 | report: { 96 | issue: new allIssues.IvalidSomeArguemntsError(), 97 | children: [{ 98 | issue: new allIssues.TooManyArgumentsError(), 99 | children: [] 100 | }] 101 | } 102 | } 103 | } 104 | parsedArgs = parsedArgs[0]; 105 | } 106 | 107 | const {value, report} = handleOption(getOptData(this.decl._), parsedArgs); 108 | if (isError(report.issue)) { 109 | return { 110 | data: value, 111 | report: { 112 | issue: new allIssues.IvalidSomeArguemntsError(), 113 | children: [report] 114 | } 115 | } 116 | } 117 | return {data: value, report}; 118 | } 119 | return {data: undefined, report: {issue: null, children: []}}; 120 | } 121 | 122 | private parseEnv(env: Record) { 123 | if (!this.useEnv) { 124 | return {}; 125 | } 126 | const res: Record = {}; 127 | Object.keys(this.optCfg).forEach(key => { 128 | const envKey = this.envPrefix + optNameToEnvName(key); 129 | if (env[envKey]) { 130 | switch (this.optCfg[key].type) { 131 | case 'number': 132 | case 'int': 133 | res[key] = Number(env[envKey]!); 134 | break; 135 | case 'boolean': 136 | res[key] = Boolean(env[envKey]!); 137 | break; 138 | default: 139 | res[key] = env[envKey]!; 140 | } 141 | } 142 | }); 143 | return res; 144 | } 145 | 146 | parse(argv: string[] | string, env: Record): {report: Report; data: ResolveCliDeclaration | null} { 147 | const parsed = yargsParser(argv, { 148 | alias: this.decl.options && objMap(this.decl.options, item => getOptData(item).aliases), 149 | boolean: Object.values(this.decl.options as OptionSet) 150 | .filter(opt => getOptData(opt).type === 'boolean') 151 | .map(opt => opt.name) 152 | }); 153 | const {report: optionsReport, data: optionsData} = this.parseOptions({...this.parseEnv(env), ...parsed}); 154 | const {report: argumentsReport, data: argumentsData} = this.parseArguments(parsed); 155 | const report = mergeReports(new allIssues.IvalidInputError, optionsReport, argumentsReport); 156 | if (isError(report.issue)) { 157 | return {report, data: null}; 158 | } 159 | return { 160 | report, 161 | data: { 162 | options: optionsData, 163 | _: argumentsData 164 | } 165 | }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/pipeline.ts: -------------------------------------------------------------------------------- 1 | import { Report, Issue, combineIssues, isError } from './report'; 2 | import { allIssues } from './errors'; 3 | 4 | export type Validator = (value: T) => void; 5 | 6 | export type BooleanValidator = (value: T) => boolean; 7 | 8 | export function makeValidator(errorMsg: string, fn: (value: T) => boolean): Validator { 9 | return (value: any): void => { 10 | if (fn(value)) { 11 | return; 12 | } 13 | throw new Error(errorMsg); 14 | } 15 | } 16 | 17 | export type Preprocessor = (value: I) => O; 18 | 19 | function runValidator(validator: Validator, value: any): undefined | Error { 20 | try { 21 | validator(value); 22 | } catch(e) { 23 | return e as any; 24 | } 25 | } 26 | 27 | interface ValidationCfg { 28 | isRequired: boolean; 29 | validators: Validator[]; 30 | name: string; 31 | isArg?: boolean; 32 | } 33 | 34 | function validateOption(optCfg: ValidationCfg, value: any): Report { 35 | const issues: Issue[] = []; 36 | if (value === undefined) { 37 | if (optCfg.isRequired) { 38 | return { 39 | issue: optCfg.isArg 40 | ? new allIssues.IvalidArguemntError(value) 41 | : new allIssues.IvalidOptionError(optCfg.name, value), 42 | children: [{ 43 | issue: new allIssues.EmptyRequiredOptionError(optCfg.name), 44 | children: [] 45 | }] 46 | }; 47 | } 48 | return { 49 | issue: null, 50 | children: [] 51 | }; 52 | } 53 | const validators = optCfg.validators; 54 | validators.forEach(validator => { 55 | const error = runValidator(validator, value); 56 | if (error) { 57 | issues.push(error); 58 | } 59 | }); 60 | return combineIssues( 61 | optCfg.isArg 62 | ? new allIssues.IvalidArguemntError(value) 63 | : new allIssues.IvalidOptionError(optCfg.name, value) 64 | , issues); 65 | } 66 | 67 | function runPreprocessors(processors: Preprocessor[], value: any): any { 68 | processors.forEach(fn => { 69 | value = fn(value); 70 | }); 71 | return value; 72 | } 73 | 74 | interface OptCfg extends ValidationCfg { 75 | prePreprocessors: Preprocessor[]; 76 | postPreprocessors: Preprocessor[]; 77 | isArray: boolean; 78 | defaultValue?: any; 79 | isArg?: boolean; 80 | } 81 | 82 | function handleArrayOption(optCfg: OptCfg, value: any): {value: any[] | null; report: Report} { 83 | value = ([] as any[]).concat(value); 84 | let issues: Issue[] = []; 85 | const resValue: any[] = []; 86 | value.forEach((v: any) => { 87 | const res = handleOption(optCfg, v, true); 88 | resValue.push(res.value); 89 | issues = [...issues, ...res.report.children.map(c => c.issue)]; 90 | }); 91 | const report = combineIssues(new allIssues.IvalidOptionError(optCfg.name, value), issues); 92 | return { 93 | report, 94 | value: isError(report.issue) ? null : resValue 95 | }; 96 | } 97 | 98 | export function handleOption(optCfg: OptCfg, value: any, iterating?: boolean): {value: any; report: Report} { 99 | if (optCfg.isArray && !iterating) { 100 | return handleArrayOption(optCfg, value); 101 | } 102 | value = runPreprocessors(optCfg.prePreprocessors, value); 103 | const report = validateOption(optCfg, value); 104 | if (isError(report.issue)) { 105 | return {report, value: null}; 106 | } 107 | if (!optCfg.isRequired && value === undefined) { 108 | if (optCfg.defaultValue !== undefined) { 109 | value = optCfg.defaultValue; 110 | } else { 111 | return {report, value}; 112 | } 113 | } 114 | value = runPreprocessors(optCfg.postPreprocessors, value); 115 | return { 116 | report, 117 | value 118 | }; 119 | } 120 | 121 | export function handleAllOptions(optSchema: Record, rawData: Record, usedKeys: Set): {data: any; report: Report} { 122 | const data: any = {}; 123 | const dataCopy = {...rawData}; 124 | let isValid = true; 125 | const allReports: Report[] = []; 126 | for (const key of Object.keys(optSchema).sort()) { 127 | const optCfg = optSchema[key]; 128 | const dataValue = dataCopy[key]; 129 | delete dataCopy[key]; 130 | const {value, report} = handleOption(optCfg, dataValue); 131 | if (isError(report.issue)) { 132 | isValid = false; 133 | } 134 | isError(report.issue) && allReports.push(report); 135 | data[key] = value; 136 | } 137 | const report = { 138 | issue: isValid ? null : new allIssues.SomeIvalidOptionsError(), 139 | children: allReports 140 | }; 141 | const warnings = Object.keys(dataCopy) 142 | .filter(key => !usedKeys.has(key)) 143 | .map(key => new allIssues.UnknownOptionWarning(key)) 144 | .map(warning => ({ 145 | issue: warning, 146 | children: [] 147 | })); 148 | report.children = report.children.concat(warnings); 149 | if (isError(report.issue)) { 150 | return {data: null, report}; 151 | } 152 | return {data, report}; 153 | } 154 | -------------------------------------------------------------------------------- /src/printer.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from './i18n'; 2 | import { TextDecorator } from './decorator'; 3 | import { Report, isError } from './report'; 4 | import { CliDeclaration } from './type-logic'; 5 | import { getOptData, Option } from './option'; 6 | import { alignTextMatrix, arrayPartition, tabText } from './utils'; 7 | import { BaseError, BaseWarning } from './errors'; 8 | import { prepareCliDeclaration } from './parser'; 9 | import { CommandSet, CommandHelperParams, _decl, _subCommandSet } from './command'; 10 | 11 | function findMinialAlias(opt: Option): string { 12 | return [opt.name, ...getOptData(opt).aliases].sort((a, b) => a.length - b.length)[0]; 13 | } 14 | 15 | type PrinterParams = { 16 | locale: Locale; 17 | decorator: TextDecorator; 18 | lineEnding?: string; 19 | } 20 | 21 | export class Printer { 22 | private locale: Locale; 23 | private decorator: TextDecorator; 24 | private lineEnding: string; 25 | 26 | constructor ({locale, decorator, lineEnding}: PrinterParams) { 27 | this.locale = locale; 28 | this.decorator = decorator; 29 | this.lineEnding = lineEnding || '\n'; 30 | } 31 | 32 | private generateOptionDescription(config: Required): string | undefined { 33 | const d = this.decorator; 34 | const l = this.locale; 35 | 36 | const options = config.options; 37 | 38 | const optionTextMatrix: string[][] = []; 39 | for (const [name, optCgf] of Object.entries(options)) { 40 | const lineParts: string[] = []; 41 | const optData = getOptData(optCgf); 42 | 43 | // aliases 44 | const aliases = [name, ...optData.aliases] 45 | .sort((a, b) => a.length - b.length) 46 | .map(alias => { 47 | return alias.length > 1 48 | ? '--' + alias 49 | : '-' + alias; 50 | }) 51 | .join(', '); 52 | lineParts.push(d.alias(aliases)); 53 | 54 | // type 55 | lineParts.push(d.type(`<${optData.labelName}>`)); 56 | 57 | // optionality 58 | if (optData.isArray) { 59 | lineParts.push(d.multiple(`[${l.texts.opt_multile(d)}]`)); 60 | } else if (optData.defaultValue) { 61 | lineParts.push(d.optional(`[=${optData.defaultValue}]`)); 62 | } else if (!optData.isRequired) { 63 | lineParts.push(d.optional(`[${l.texts.opt_optional(d)}]`)); 64 | } else { 65 | lineParts.push(d.required(`[${l.texts.opt_required(d)}]`)); 66 | } 67 | 68 | // description 69 | lineParts.push(d.optionDescription('- ' + optData.description)); 70 | 71 | optionTextMatrix.push(lineParts); 72 | } 73 | 74 | return alignTextMatrix(optionTextMatrix) 75 | .map(line => ' ' + line.join(' ')) 76 | .join('\n'); 77 | } 78 | 79 | private generateUsage(config: Required): string { 80 | const d = this.decorator; 81 | 82 | const options = config.options; 83 | 84 | // options: 85 | const [requiredOpts, optionalOpts] = arrayPartition(Object.values(options), (opt) => { 86 | return getOptData(opt).isRequired; 87 | }) 88 | .map(opts => { 89 | const optionStrings: string[] = []; 90 | 91 | const [boolean, rest] = arrayPartition(opts, (opt) => { 92 | return getOptData(opt).labelName === 'boolean' && findMinialAlias(opt).length === 1; 93 | }); 94 | 95 | const booleanGroup = boolean.map(opt => findMinialAlias(opt)).join(''); 96 | booleanGroup.length > 0 && optionStrings.push(d.usageOption('-' + booleanGroup)); 97 | 98 | rest.forEach(opt => { 99 | const alias = findMinialAlias(opt); 100 | const prefix = alias.length === 1 ? '-' : '--'; 101 | const value = getOptData(opt).labelName === 'boolean' 102 | ? '' 103 | : ' ' + d.type(`<${getOptData(opt).labelName}>`); 104 | optionStrings.push(d.usageOption(prefix + alias) + value); 105 | }); 106 | 107 | return optionStrings.join(' '); 108 | }); 109 | 110 | // arguments: 111 | let argText: string | undefined = undefined; 112 | if (config._) { 113 | const arg = getOptData(config._); 114 | const argType = d.type(`<${arg.labelName}>`); 115 | if (arg.isArray) { 116 | argText = `[${argType} ${argType} ...]`; 117 | } else if (!arg.isRequired) { 118 | argText = `[${argType}]`; 119 | } else { 120 | argText = argType; 121 | } 122 | } 123 | 124 | return [ 125 | config.name && d.command(config.name), 126 | requiredOpts, 127 | optionalOpts.length > 0 && ('[' + optionalOpts + ']'), 128 | argText 129 | ] 130 | .filter(Boolean) 131 | .join(' '); 132 | } 133 | 134 | private genenrateCommandList(cs: CommandSet): string[][] { 135 | const d = this.decorator; 136 | 137 | let res: string[][] = []; 138 | for (const cmd of Object.values(cs)) { 139 | const cmdParts = (cmd[_decl].name as string).split(' '); 140 | const title = d.commandPath(cmdParts.slice(0, -1).join(' ')) 141 | + ' ' 142 | + d.commandEnding(cmdParts[cmdParts.length - 1]); 143 | const desc = cmd[_decl].description as string; 144 | const descText = desc ? ('| - ' + desc) : '|'; 145 | res.push([title, d.commandDescription(descText)]); 146 | res = res.concat(this.genenrateCommandList(cmd[_subCommandSet])); 147 | } 148 | 149 | return res; 150 | } 151 | 152 | generateHelpForComands(cfg: CommandHelperParams, cs: CommandSet): string { 153 | const d = this.decorator; 154 | const l = this.locale; 155 | 156 | const textAbstracts: string[] = []; 157 | const {description, program} = cfg; 158 | 159 | description && textAbstracts.push( 160 | d.title(l.texts.title_description(d)) 161 | + '\n' + 162 | description 163 | ); 164 | 165 | textAbstracts.push( 166 | d.title(l.texts.title_commands(d)) 167 | + '\n' + 168 | alignTextMatrix(this.genenrateCommandList(cs), ['right', 'left']) 169 | .map(line => line.join(' ')) 170 | .join('\n') 171 | ); 172 | 173 | textAbstracts.push(d.usageOption(l.texts.hint_commandHint(d, {command: program}))); 174 | 175 | return textAbstracts 176 | .join('\n\n') 177 | .replace(/[ \t]+\n/g, '\n') 178 | .replace(/\n/g, this.lineEnding); 179 | 180 | } 181 | 182 | generateHelp(decl: CliDeclaration): string { 183 | decl = prepareCliDeclaration(decl).decl; 184 | 185 | const d = this.decorator; 186 | const l = this.locale; 187 | 188 | const textAbstracts: string[] = []; 189 | const {description} = decl; 190 | 191 | description && textAbstracts.push( 192 | d.title(l.texts.title_description(d)) 193 | + '\n' + 194 | description 195 | ); 196 | 197 | textAbstracts.push( 198 | d.title(l.texts.title_usage(d)) 199 | + '\n ' + 200 | this.generateUsage(decl as Required) 201 | ); 202 | 203 | const optDecription = this.generateOptionDescription(decl as Required); 204 | optDecription && textAbstracts.push( 205 | d.title(l.texts.title_options(d)) 206 | + '\n' + 207 | optDecription 208 | ); 209 | 210 | return textAbstracts 211 | .join('\n\n') 212 | .replace(/[ \t]+\n/g, '\n') 213 | .replace(/\n/g, this.lineEnding); 214 | } 215 | 216 | private printReportLayer(report: Report, level: number): string { 217 | const d = this.decorator; 218 | const l = this.locale; 219 | 220 | let text = ''; 221 | 222 | if (report.issue instanceof BaseError || report.issue instanceof BaseWarning) { 223 | text += l.issues[report.issue.className](report.issue as any, d); 224 | } else { 225 | if ((report.issue as any).stringify) { 226 | text += (report.issue as any).stringify(l.code, d); 227 | } else { 228 | text += (report.issue as Error).message; 229 | } 230 | } 231 | 232 | if (level === 0) { 233 | text = isError(report.issue) 234 | ? d.errorLine(text) 235 | : d.warningLine(text); 236 | } 237 | 238 | const childText: string = report.children 239 | .map(childReport => this.printReportLayer(childReport, level + 1)) 240 | .join('\n'); 241 | 242 | return childText.trim() !== '' 243 | ? [text, tabText(childText, ' - ')].join('\n') 244 | : text; 245 | } 246 | 247 | stringifyReport(report: Report): string { 248 | return report.children 249 | .map(child => this.printReportLayer(child, 0)) 250 | .join('\n') 251 | .replace(/\n/g, this.lineEnding); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/report.ts: -------------------------------------------------------------------------------- 1 | import { BaseWarning, BaseError, allIssues } from './errors'; 2 | 3 | export type Issue = (Error | BaseWarning | null); 4 | 5 | export type Report = { 6 | issue: Issue; 7 | children: Report[]; 8 | }; 9 | 10 | export function combineIssues(conclusion: Issue, issues: Issue[]): Report { 11 | let isValid = true; 12 | const children = issues.map(i => { 13 | isValid = isValid && !isError(i); 14 | return { 15 | issue: i, 16 | children: [] 17 | }; 18 | }); 19 | return { 20 | issue: isValid ? null : conclusion, 21 | children 22 | } 23 | } 24 | 25 | export function isError(issue?: Issue): boolean { 26 | return !(issue instanceof BaseWarning) 27 | && 28 | (Boolean(issue) || issue instanceof BaseError || issue instanceof Error); 29 | } 30 | 31 | export function mergeReports(conclusion: Issue, ...reports: Report[]): Report { 32 | const res: Report = { 33 | issue: null, 34 | children: [] 35 | }; 36 | let isValid = true; 37 | for (const r of reports) { 38 | isValid = isValid && !isError(r.issue) 39 | res.children = [...res.children, ...r.children]; 40 | } 41 | res.issue = isValid ? null : conclusion; 42 | return res; 43 | } 44 | 45 | export function errorToReport(err: Error): Report { 46 | return { 47 | issue: new allIssues.IvalidInputError(), 48 | children: [{ 49 | issue: err, 50 | children: [] 51 | }] 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/type-logic.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | import { Option, OptionSet, Types } from './option'; 3 | 4 | export type GetPropertiyNames, P> = { 5 | [K in keyof T]: T[K] extends P ? K : never; 6 | }[keyof T]; 7 | 8 | export type GetProperties, P> = Pick>; 9 | 10 | type PickRequiredOpts = GetProperties | Option>; 11 | type PickNonRequiredOpts = GetProperties>; 12 | 13 | type ResolveOptionType> = O extends Option 14 | ? R 15 | : never; 16 | 17 | type ResolveOption> = O extends Option 18 | ? Array> 19 | : ResolveOptionType; 20 | 21 | type ResolveOptionSet = { 22 | [key in keyof PickRequiredOpts]: ResolveOption[key]>; 23 | } & { 24 | [key in keyof PickNonRequiredOpts]?: ResolveOption[key]>; 25 | } 26 | 27 | export type CliDeclaration = { 28 | name?: string; 29 | options?: OptionSet; 30 | description?: string; 31 | useEnv?: boolean; 32 | envPrefix?: string; 33 | _?: Option; 34 | } 35 | 36 | export type ResolveCliDeclaration = { 37 | options: D['options'] extends OptionSet ? ResolveOptionSet : {}; 38 | _: D['_'] extends Option 39 | ? R extends true 40 | ? ResolveOption 41 | : ResolveOption | undefined 42 | : undefined; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function createKebabAlias(str: string): string | undefined { 2 | if (!/[a-z][A-Z]/.test(str)) { 3 | return; 4 | } 5 | return str.replace(/[a-z][A-Z]/g, subStr => subStr.split('').join('-')).toLowerCase(); 6 | } 7 | 8 | export function objMap(obj: Record, fn: (item: T, key: string) => R): Record { 9 | const res: any = {}; 10 | for (const [name, value] of Object.entries(obj)) { 11 | res[name] = fn(value, name); 12 | } 13 | return res; 14 | } 15 | 16 | export function alignTextMatrix(textMatrix: string[][], alignment?: ('left' | 'right')[]): string[][] { 17 | const colSizes: number[] = []; 18 | textMatrix.forEach(line => { 19 | line.forEach((text, index) => colSizes[index] = Math.max(colSizes[index] || 0, text.length)); 20 | }); 21 | return textMatrix.map(line => { 22 | return line.map((text, index) => { 23 | const align = alignment && alignment[index] || 'left'; 24 | return align === 'left' 25 | ? text.padEnd(colSizes[index], ' ') 26 | : text.padStart(colSizes[index], ' '); 27 | }); 28 | }); 29 | } 30 | 31 | export function arrayPartition(array: T[], fn: (item: T, index: number, array: T[]) => boolean): [T[], T[]] { 32 | return [ 33 | array.filter((item, index, array) => fn(item, index, array)), 34 | array.filter((item, index, array) => !fn(item, index, array)) 35 | ]; 36 | } 37 | 38 | export function tabText(text: string, prefix: string): string { 39 | return text.split('\n') 40 | .map(line => prefix + line) 41 | .join('\n'); 42 | } 43 | 44 | export function findKeyCollision(keys: string[]): string | null { 45 | const usedKeys = new Set(); 46 | for (const key of keys) { 47 | if (usedKeys.has(key)) { 48 | return key; 49 | } 50 | usedKeys.add(key); 51 | } 52 | return null; 53 | } 54 | 55 | export function uniq(array: T[]): T[] { 56 | return array.filter((value, index, self) => { 57 | return self.indexOf(value) === index; 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /tests/e2e/basics/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | RUN npm i -g typescript 3 | COPY ./app/* /app/ 4 | WORKDIR /app 5 | ARG CACHEBUST=1 6 | RUN npm i 7 | RUN tsc 8 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/.gitignore: -------------------------------------------------------------------------------- 1 | !*.js 2 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import {cli, option} from 'typed-cli'; 3 | 4 | const data = cli({ 5 | name: 'calc-area', 6 | description: 'calculate area', 7 | options: { 8 | width: option.number.alias('w').required().description('width of a rectangle'), 9 | height: option.number.alias('h').required().description('height of a rectangle'), 10 | } 11 | }); 12 | 13 | const {width, height} = data.options; 14 | 15 | function calculateArea(width: number, height: number): number { 16 | return width * height; 17 | } 18 | 19 | process.stdout.write(String(calculateArea(width, height))); 20 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/install-completions.js: -------------------------------------------------------------------------------- 1 | const tabtab = require('tabtab'); 2 | 3 | tabtab 4 | .install({ 5 | name: 'calc-area', 6 | completer: 'calc-area', 7 | completeCmd: 'typed-cli--complete-input' 8 | }) 9 | .catch(err => { 10 | console.error('INSTALL ERROR', err); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "tabtab": "git+https://github.com/int0h/tabtab.git#ba938481a264fcc60a21a3288b32485f28e7fcca", 14 | "typed-cli": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true 7 | }, 8 | "include": [ 9 | "./index.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/e2e/basics/app/uninstall-completions.js: -------------------------------------------------------------------------------- 1 | const tabtab = require('tabtab'); 2 | 3 | tabtab 4 | .uninstall({ 5 | name: 'calc-area', 6 | }) 7 | .catch(err => { 8 | console.error('UNINSTALL ERROR', err); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/e2e/basics/build: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | docker build -t typed-cli-basics . 4 | -------------------------------------------------------------------------------- /tests/e2e/basics/test.ts: -------------------------------------------------------------------------------- 1 | import {spawn, IPty} from 'node-pty'; 2 | import test from 'tape'; 3 | import chalk from 'chalk'; 4 | import {stripAnsi} from '../../unit/strip-ansi'; 5 | 6 | const sleep = (time: number): Promise => new Promise((resolve): any => setTimeout(resolve, time)); 7 | const waitFor = (isReadyFn: () => boolean, interval = 25): Promise => { 8 | return new Promise((resolve): void => { 9 | function check(): void { 10 | if (isReadyFn()) { 11 | resolve(); 12 | } else { 13 | setTimeout(check, interval); 14 | } 15 | } 16 | 17 | check(); 18 | }); 19 | } 20 | 21 | function removePrompts(str: string): string { 22 | return str 23 | .split('\r\n') 24 | .slice(1) // removes input 25 | .filter(line => !/root@.+:\/.+#/.test(line)) 26 | .join('\r\n'); 27 | } 28 | 29 | function trimBorderLines(str: string): string { 30 | return str 31 | .split('\r\n') 32 | .slice(1, -1) 33 | .join('\r\n'); 34 | } 35 | 36 | type ReadyFn = (buf: string) => boolean; 37 | 38 | const defaultReadyFn: ReadyFn = buf => /root@.+:\/.+#/.test(buf.replace(/^.*\n/, '')); 39 | 40 | class Dialog { 41 | private proc: IPty; 42 | private isReady = false; 43 | buf = ''; 44 | 45 | constructor(cmd: string[]) { 46 | this.proc = spawn(cmd[0], cmd.slice(1), { 47 | name: 'xterm-color', 48 | cols: 80, 49 | rows: 30, 50 | cwd: __dirname 51 | }); 52 | 53 | this.proc.onData(str => { 54 | process.stdout.write(str); 55 | this.isReady = this.isReady || /root@.+:\/.+#/.test(str.replace(/^.*\n/, '')); 56 | this.buf += str; 57 | }); 58 | } 59 | 60 | write(str: string): void { 61 | this.proc.write(str); 62 | } 63 | 64 | wait(readyFn: ReadyFn = defaultReadyFn): Promise { 65 | return waitFor(() => readyFn(this.buf)); 66 | } 67 | 68 | async talk(str: string): Promise { 69 | await this.wait(); 70 | this.isReady = false; 71 | this.buf = ''; 72 | this.proc.write(str + '\n'); 73 | await this.wait(); 74 | return removePrompts(this.buf); 75 | } 76 | 77 | } 78 | 79 | const dialog = new Dialog('docker run -it typed-cli-basics /bin/bash'.split(' ')); 80 | // const sh = spawn('docker', `run -i typed-cli-basics /bin/bash`.split(' ')); 81 | // console.log(execSync('docker run -it typed-cli-basics /bin/bash'), {stdio: 'inherit'}) 82 | // const sh = spawn('docker', `run -it typed-cli-basics /bin/bash`.split(' '), { 83 | // name: 'xterm-color', 84 | // cols: 80, 85 | // rows: 30, 86 | // cwd: __dirname 87 | // }); 88 | // global.sh = sh; 89 | // sh.stdout.pipe(process.stdout); 90 | // sh.on('error', err => { 91 | // console.error(err); 92 | // }); 93 | // let buf = ''; 94 | // sh.on('data', str => { 95 | // process.stdout.write(str); 96 | // buf += str; 97 | // }); 98 | // sh.stdout.on('data', str => { 99 | // buf += str; 100 | // }); 101 | // sh.stderr.on('data', str => { 102 | // buf += str; 103 | // }); 104 | // async function talk(str: string, pause = 100): Promise { 105 | // buf = ''; 106 | // sh.write(str + '\n'); 107 | // await sleep(pause); 108 | // return buf; 109 | // } 110 | 111 | test('basics', async t => { 112 | t.is(await dialog.talk('node index.js && echo fail'), [ 113 | `❌ option <${chalk.redBright('height')}> is invalid`, 114 | ` - it's required`, 115 | `❌ option <${chalk.redBright('width')}> is invalid`, 116 | ` - it's required`, 117 | ].join('\r\n'), 'no option fails'); 118 | 119 | t.is(await dialog.talk('node index.js -w asv && echo fail'), [ 120 | `❌ option <${chalk.redBright('height')}> is invalid`, 121 | ` - it's required`, 122 | `❌ option <${chalk.redBright('width')}> is invalid`, 123 | ` - expected , but received <${chalk.redBright('string')}>`, 124 | ].join('\r\n'), 'invalid option fails'); 125 | 126 | t.is(await dialog.talk('node index.js -w 12 -h 23 && echo success'), [ 127 | `276success`, 128 | ].join('\r\n'), 'valid data works'); 129 | 130 | t.is(stripAnsi(await dialog.talk('node index.js --help')), [ 131 | `Description`, 132 | `calculate area`, 133 | ``, 134 | `Usage`, 135 | ` calc-area -w -h `, 136 | ``, 137 | `Options`, 138 | ` -w, --width [required] - width of a rectangle`, 139 | ` -h, --height [required] - height of a rectangle`, 140 | ].join('\r\n'), 'help is printed'); 141 | 142 | // make 'calc-area' globally accessable 143 | await dialog.talk(`export PATH=/app:$PATH`); 144 | await dialog.talk(`ln -s /app/index.js ./calc-area`); 145 | await dialog.talk(`chmod +x ./calc-area`); 146 | 147 | // install completions 148 | dialog.talk(`node ./install-completions.js`); 149 | await dialog.wait(s => stripAnsi(s).includes('Which Shell do you use')); 150 | dialog.write('\n'); 151 | await dialog.wait(s => stripAnsi(s).includes('We will install completion')); 152 | dialog.write('Y\n'); 153 | await dialog.talk(`source ~/.bashrc`); 154 | 155 | dialog.write('calc-area \t'); 156 | await sleep(1000); 157 | t.is(dialog.buf.slice(-1), '-'); 158 | 159 | dialog.buf = ''; 160 | dialog.write('\t\t'); 161 | await dialog.wait(); 162 | t.deepEqual(trimBorderLines(dialog.buf).split(/\s+/g).filter(Boolean).sort(), [ 163 | `-w`, `-h`, `--width`, `--height` 164 | ].sort(), 'completions work'); 165 | 166 | await dialog.talk('\u0003'); 167 | await dialog.talk('exit'); 168 | 169 | t.end(); 170 | }); 171 | -------------------------------------------------------------------------------- /tests/unit/cli-helper.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { createCliHelper } from '../../src/cli-helper'; 4 | import { en_US } from '../../src/i18n'; 5 | import { plain } from '../../src/decorator'; 6 | import { Printer } from '../../src/printer'; 7 | import { option } from '../../'; 8 | 9 | test('createCliHelper', t => { 10 | let exitCode = -1; 11 | const log = { 12 | error: '', 13 | log: '' 14 | }; 15 | let argv = ''; 16 | const flush = (): void => { 17 | exitCode = -1; 18 | log.error = ''; 19 | log.log = ''; 20 | } 21 | let env = {}; 22 | 23 | const printer = new Printer({locale: en_US, decorator: plain}); 24 | 25 | const cli = createCliHelper({ 26 | argvProvider: () => argv.split(' '), 27 | envProvider: () => env, 28 | exiter: hasErrors => exitCode = (hasErrors ? 1 : 0), 29 | helpGeneration: true, 30 | writer: (text, logType) => {log[logType] += '\n' + text}, 31 | printer 32 | }); 33 | 34 | test('help generation', t => { 35 | const helpTextRef = [ 36 | '', 37 | 'Description', 38 | 'description', 39 | '', 40 | 'Usage', 41 | ' test-cmd [--foo ]', 42 | '', 43 | 'Options', 44 | ' --foo [optional] - ', 45 | ].join('\n'); 46 | 47 | try { 48 | argv = '--help'; 49 | flush(); 50 | cli({ 51 | name: 'test-cmd', 52 | description: 'description', 53 | options: { 54 | foo: option.int 55 | } 56 | }); 57 | } catch(e) {} 58 | 59 | t.equal(exitCode, 0); 60 | t.equal(log.log, helpTextRef); 61 | 62 | t.end(); 63 | }); 64 | 65 | test('return result', t => { 66 | argv = '--foo 1' 67 | flush(); 68 | const data = cli({ 69 | name: 'test-cmd', 70 | description: 'description', 71 | options: { 72 | foo: option.int 73 | } 74 | }); 75 | 76 | t.equal(exitCode, -1); 77 | t.deepEqual(data, {options: {foo: 1}, _: undefined}); 78 | 79 | t.end(); 80 | }); 81 | 82 | test('handle problems', t => { 83 | const helpTextRef = [ 84 | '', 85 | 'option is invalid', 86 | ' - expected , but received ', 87 | ].join('\n'); 88 | 89 | try { 90 | argv = '--foo wrong'; 91 | flush(); 92 | cli({ 93 | name: 'test-cmd', 94 | description: 'description', 95 | options: { 96 | foo: option.int 97 | } 98 | }); 99 | } catch(e) {} 100 | 101 | t.equal(exitCode, 1); 102 | t.equal(log.error, helpTextRef); 103 | 104 | t.end(); 105 | }); 106 | 107 | t.end(); 108 | }); 109 | 110 | test('createCliHelper:noHelpGeneration', t => { 111 | let exitCode = -1; 112 | const log = { 113 | error: '', 114 | log: '' 115 | }; 116 | let argv = ''; 117 | const flush = (): void => { 118 | exitCode = -1; 119 | log.error = ''; 120 | log.log = ''; 121 | } 122 | let env = {}; 123 | 124 | const printer = new Printer({locale: en_US, decorator: plain}); 125 | 126 | const cli = createCliHelper({ 127 | argvProvider: () => argv.split(' '), 128 | envProvider: () => env, 129 | exiter: hasErrors => exitCode = (hasErrors ? 1 : 0), 130 | writer: (text, logType) => {log[logType] += '\n' + text}, 131 | printer 132 | }); 133 | 134 | const helpTextRef = [ 135 | '', 136 | 'option is not supported', 137 | ].join('\n'); 138 | 139 | try { 140 | argv = '--help'; 141 | flush(); 142 | cli({ 143 | name: 'test-cmd', 144 | description: 'description', 145 | options: { 146 | foo: option.int 147 | } 148 | }); 149 | } catch(e) {} 150 | 151 | t.equal(exitCode, -1); 152 | t.equal(log.error, helpTextRef); 153 | 154 | t.end(); 155 | }); 156 | -------------------------------------------------------------------------------- /tests/unit/command.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import {command, _aliases, _decl, _fn, _subCommandSet, createCommandHelper, defaultCommand} from '../../src/command'; 4 | import { Printer } from '../../src/printer'; 5 | import { locales } from '../../src/i18n'; 6 | import { decorators } from '../../src/decorator'; 7 | import { option } from '../../'; 8 | 9 | test('command helper result', t => { 10 | const handleChild = (): void => {}; 11 | const handleParent = (): void => {}; 12 | const cmd = command({ 13 | name: 'foo', 14 | description: 'boo' 15 | }).alias('a', 'b').subCommands({ 16 | sub: command({}).handle(handleChild) 17 | }).handle(handleParent); 18 | 19 | t.is(cmd[_decl].name, 'foo'); 20 | t.is(cmd[_decl].description, 'boo'); 21 | t.is(cmd[_fn], handleParent); 22 | t.is(cmd[_subCommandSet].sub[_fn], handleChild); 23 | t.deepEqual(cmd[_aliases], ['a', 'b']); 24 | t.end(); 25 | }); 26 | 27 | test('command parsing', t => { 28 | let argv: string[] = []; 29 | let env = {}; 30 | let exitCode = 0; 31 | let out = ''; 32 | let handled = false; 33 | 34 | const cleanup = (): void => { 35 | argv = []; 36 | exitCode = 0; 37 | out = ''; 38 | handled = false; 39 | }; 40 | 41 | const commandHelper = createCommandHelper({ 42 | argvProvider: () => argv, 43 | envProvider: () => env, 44 | exiter: (hasErrors) => exitCode = (hasErrors ? 1 : 0), 45 | helpGeneration: true, 46 | printer: new Printer({locale: locales.en_US, decorator: decorators.plain}), 47 | writer: (text) => out = text 48 | }); 49 | 50 | test('basics', t => { 51 | cleanup(); 52 | argv = ['cmd']; 53 | commandHelper({}, { 54 | cmd: command({}).handle(() => handled = true) 55 | }); 56 | t.is(handled, true); 57 | t.is(exitCode, 0); 58 | t.is(out, ''); 59 | t.end(); 60 | }); 61 | 62 | test('no command', t => { 63 | cleanup(); 64 | argv = []; 65 | t.throws(() => { 66 | commandHelper({}, { 67 | cmd: command({}).handle(() => handled = true) 68 | }); 69 | }); 70 | t.is(handled, false); 71 | t.is(exitCode, 1); 72 | t.is(out, 'no command was provided and no default command was set'); 73 | t.end(); 74 | }); 75 | 76 | test('no command', t => { 77 | cleanup(); 78 | argv = ['ads']; 79 | t.throws(() => { 80 | commandHelper({}, { 81 | cmd: command({}).handle(() => handled = true) 82 | }); 83 | }); 84 | t.is(handled, false); 85 | t.is(exitCode, 1); 86 | t.is(out, 'command is not supported'); 87 | t.end(); 88 | }); 89 | 90 | test('default cmd', t => { 91 | cleanup(); 92 | argv = ['']; 93 | commandHelper({}, { 94 | [defaultCommand]: command({}).handle(() => handled = true) 95 | }); 96 | t.is(handled, true); 97 | t.is(exitCode, 0); 98 | t.is(out, ''); 99 | t.end(); 100 | }); 101 | 102 | test('default cmd', t => { 103 | cleanup(); 104 | argv = ['--help']; 105 | try { 106 | commandHelper({ 107 | program: 'prog' 108 | }, { 109 | abc: command({ 110 | description: 'abc-description' 111 | }).handle(() => handled = true), 112 | def: command({ 113 | description: 'def-description' 114 | }).handle(() => handled = true) 115 | }); 116 | t.fail(); 117 | } catch(e) { 118 | t.is((e as any).message, 'exiter has failed'); 119 | } 120 | t.is(handled, false); 121 | t.is(exitCode, 0); 122 | t.is(out, [ 123 | 'Commands', 124 | 'prog abc | - abc-description', 125 | 'prog def | - def-description', 126 | '', 127 | 'Type prog --help for detailed documentation' 128 | ].join('\n')); 129 | t.end(); 130 | }); 131 | 132 | test('sub', t => { 133 | cleanup(); 134 | argv = ['cmd', 'subA']; 135 | commandHelper({}, { 136 | cmd: command({}).handle(() => {}).subCommands({ 137 | subA: command({}).handle(() => handled = true) 138 | }) 139 | }); 140 | t.is(handled, true); 141 | t.is(exitCode, 0); 142 | t.is(out, ''); 143 | t.end(); 144 | }); 145 | 146 | test('sub help', t => { 147 | cleanup(); 148 | argv = ['cmd', 'subA', '--help']; 149 | try { 150 | commandHelper({ 151 | program: 'prog' 152 | }, { 153 | cmd: command({}).handle(() => {}).subCommands({ 154 | subA: command({ 155 | options: { 156 | i: option.int.description('opt-desc') 157 | } 158 | }).handle(() => handled = true) 159 | }) 160 | }); 161 | } catch(e) { 162 | t.is((e as any).message, 'exiter has failed'); 163 | } 164 | t.is(handled, false); 165 | t.is(exitCode, 0); 166 | t.is(out, [ 167 | 'Usage', 168 | ' prog cmd subA [-i ]', 169 | '', 170 | 'Options', 171 | ' -i [optional] - opt-desc' 172 | ].join('\n')); 173 | t.end(); 174 | }); 175 | 176 | test('alias collision', t => { 177 | cleanup(); 178 | argv = ['cmd', 'subA']; 179 | t.throws(() => { 180 | commandHelper({}, { 181 | cmd: command({}).handle(() => {}).subCommands({ 182 | subA: command({}).handle(() => handled = true) 183 | }), 184 | cmd2: command({}).handle(() => {}).alias('cmd') 185 | }); 186 | }); 187 | t.is(handled, false); 188 | t.end(); 189 | }); 190 | 191 | test('no command handler', t => { 192 | cleanup(); 193 | argv = ['cmd', 'subA']; 194 | t.throws(() => { 195 | commandHelper({}, { 196 | cmd: command({}) 197 | }); 198 | }); 199 | t.is(handled, false); 200 | t.end(); 201 | }); 202 | 203 | test('no default command handler', t => { 204 | cleanup(); 205 | argv = ['cmd', 'subA']; 206 | t.throws(() => { 207 | commandHelper({}, { 208 | [defaultCommand]: command({}) 209 | }); 210 | }); 211 | t.is(handled, false); 212 | t.end(); 213 | }); 214 | 215 | test('sub invalid', t => { 216 | cleanup(); 217 | argv = ['cmd', 'subA', '-i', 'asd']; 218 | try { 219 | commandHelper({}, { 220 | cmd: command({}).handle(() => {}).subCommands({ 221 | subA: command({ 222 | options: { 223 | i: option.int 224 | } 225 | }).handle(() => handled = true) 226 | }) 227 | }); 228 | } catch(e) { 229 | t.is((e as any).message, 'exiter has failed'); 230 | } 231 | t.is(handled, false); 232 | t.is(exitCode, 1); 233 | t.is(out, [ 234 | 'option is invalid', 235 | ' - expected , but received ' 236 | ].join('\n')); 237 | t.end(); 238 | }); 239 | 240 | test('sub warning', t => { 241 | cleanup(); 242 | argv = ['cmd', 'subA', '-r', 'asd']; 243 | try { 244 | commandHelper({}, { 245 | cmd: command({}).handle(() => {}).subCommands({ 246 | subA: command({ 247 | options: { 248 | i: option.int 249 | } 250 | }).handle(() => handled = true) 251 | }) 252 | }); 253 | } catch(e) { 254 | t.is((e as any).message, 'exiter has failed'); 255 | } 256 | t.is(handled, true); 257 | t.is(exitCode, 0); 258 | t.is(out, [ 259 | 'option is not supported', 260 | ].join('\n')); 261 | t.end(); 262 | }); 263 | 264 | t.end(); 265 | }); 266 | -------------------------------------------------------------------------------- /tests/unit/completer.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import {normalizeCompleterOptions, completeForCliDecl, completeForCommandSet} from '../../src/completer'; 4 | import { CliDeclaration } from '../../src/type-logic'; 5 | import { option } from '../../'; 6 | import { prepareCliDeclaration } from '../../src/parser'; 7 | import { oneOf } from '../../presets'; 8 | import { CommandSet, command, prepareCommandSet } from '../../src/command'; 9 | 10 | test('normalizeCompleterOptions', t => { 11 | t.deepEqual(normalizeCompleterOptions({ 12 | completeCmd: 'a', 13 | installCmd: 'b' 14 | }), { 15 | completeCmd: 'a', 16 | installCmd: 'b', 17 | uninstallCmd: 'typed-cli--install-shell-completions', 18 | }); 19 | 20 | t.end(); 21 | }); 22 | 23 | test('completeForCliDecl', t => { 24 | const cli: CliDeclaration = { 25 | name: 'prog', 26 | description: 'desc', 27 | options: { 28 | n: option.int.description('foo').array(), 29 | someProp: option.string.alias('some-opt-alias').description('bar'), 30 | en: oneOf(['a', 'b']).description('enum'), 31 | bool: option.boolean.description('bool') 32 | } 33 | }; 34 | const pCli = prepareCliDeclaration(cli).decl; 35 | 36 | test('basics', t => { 37 | const res = completeForCliDecl(pCli, [''], ''); 38 | t.deepEqual(res, [ 39 | {completion: '-n', description: 'foo'}, 40 | {completion: '--someProp', description: 'bar'}, 41 | {completion: '--some-opt-alias', description: 'bar'}, 42 | {completion: '--some-prop', description: 'bar'}, 43 | {completion: '--en', description: 'enum'}, 44 | {completion: '--bool', description: 'bool'}, 45 | ]); 46 | t.end(); 47 | }); 48 | 49 | test('complete after bool', t => { 50 | const res = completeForCliDecl(pCli, ['--bool'], ''); 51 | t.deepEqual(res, [ 52 | {completion: '-n', description: 'foo'}, 53 | {completion: '--someProp', description: 'bar'}, 54 | {completion: '--some-opt-alias', description: 'bar'}, 55 | {completion: '--some-prop', description: 'bar'}, 56 | {completion: '--en', description: 'enum'}, 57 | ]); 58 | t.end(); 59 | }); 60 | 61 | test('--', t => { 62 | const res = completeForCliDecl(pCli, [''], '--'); 63 | t.deepEqual(res, [ 64 | {completion: '--someProp', description: 'bar'}, 65 | {completion: '--some-opt-alias', description: 'bar'}, 66 | {completion: '--some-prop', description: 'bar'}, 67 | {completion: '--en', description: 'enum'}, 68 | {completion: '--bool', description: 'bool'}, 69 | ]); 70 | t.end(); 71 | }); 72 | 73 | test('value completions', t => { 74 | const res = completeForCliDecl(pCli, ['--en'], ''); 75 | t.deepEqual(res, [ 76 | {completion: 'a', description: ''}, 77 | {completion: 'b', description: ''}, 78 | ]); 79 | t.end(); 80 | }); 81 | 82 | test('value completions without opt completer', t => { 83 | const res = completeForCliDecl(pCli, ['-n'], ''); 84 | t.deepEqual(res, [ 85 | ]); 86 | t.end(); 87 | }); 88 | 89 | test('invalid option value completions', t => { 90 | const res = completeForCliDecl(pCli, ['--asd'], ''); 91 | t.deepEqual(res, [ 92 | ]); 93 | t.end(); 94 | }); 95 | 96 | test('array option', t => { 97 | const res = completeForCliDecl(pCli, ['-n', '1'], ''); 98 | t.deepEqual(res, [ 99 | {completion: '-n', description: 'foo'}, 100 | {completion: '--someProp', description: 'bar'}, 101 | {completion: '--some-opt-alias', description: 'bar'}, 102 | {completion: '--some-prop', description: 'bar'}, 103 | {completion: '--en', description: 'enum'}, 104 | {completion: '--bool', description: 'bool'}, 105 | ]); 106 | t.end(); 107 | }); 108 | 109 | test('non array skipping', t => { 110 | const res = completeForCliDecl(pCli, ['--someProp', 'sad'], ''); 111 | t.deepEqual(res, [ 112 | {completion: '-n', description: 'foo'}, 113 | {completion: '--en', description: 'enum'}, 114 | {completion: '--bool', description: 'bool'}, 115 | ]); 116 | t.end(); 117 | }); 118 | 119 | test('arg completions do not work so far, but do not break anything either', t => { 120 | const res = completeForCliDecl(pCli, [], 'a'); 121 | t.deepEqual(res, [ 122 | ]); 123 | t.end(); 124 | }); 125 | 126 | t.end(); 127 | }); 128 | 129 | test('completeForCommandSet', t => { 130 | const cs: CommandSet = prepareCommandSet({ 131 | load: command({ 132 | description: 'load-desc', 133 | }).alias('l').handle(() => {}), 134 | save: command({ 135 | description: 'save-desc' 136 | }).handle(() => {}).subCommands({ 137 | all: command({description: 'save-all-desc'}).handle(() => {}) 138 | }) 139 | }); 140 | 141 | test('basics', t => { 142 | const res = completeForCommandSet(cs, [], ''); 143 | t.deepEqual(res, [ 144 | {completion: 'load', description: 'load-desc'}, 145 | {completion: 'l', description: 'load-desc'}, 146 | {completion: 'save', description: 'save-desc'}, 147 | ]); 148 | t.end(); 149 | }); 150 | 151 | test('with partial', t => { 152 | const res = completeForCommandSet(cs, [], 'l'); 153 | t.deepEqual(res, [ 154 | {completion: 'load', description: 'load-desc'}, 155 | {completion: 'l', description: 'load-desc'}, 156 | ]); 157 | t.end(); 158 | }); 159 | 160 | test('subs', t => { 161 | const res = completeForCommandSet(cs, ['save'], ''); 162 | t.deepEqual(res, [ 163 | {completion: 'all', description: 'save-all-desc'}, 164 | ]); 165 | t.end(); 166 | }); 167 | 168 | t.end(); 169 | }); 170 | -------------------------------------------------------------------------------- /tests/unit/decorator.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import {stripAnsi} from './strip-ansi'; 3 | 4 | import {decorators} from '../../src/decorator'; 5 | 6 | test('decorated text looks like original', t => { 7 | const results: boolean[] = []; 8 | for (const decorator of Object.values(decorators)) { 9 | for (const decoratorMethod of Object.values(decorator)) { 10 | const isValid = stripAnsi(decoratorMethod('some text')).toLowerCase().includes('some text'); 11 | results.push(isValid) 12 | } 13 | } 14 | t.true(results.every(Boolean)); 15 | t.end(); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/unit/i18n.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import {locales, declareLocale} from '../../src/i18n'; 4 | import {decorators} from '../../src/decorator'; 5 | 6 | test('declareLocale', t => { 7 | const l = declareLocale({ 8 | code: 'test', 9 | issues: { 10 | IvalidOptionError: (): string => 'IvalidOptionError', 11 | EmptyRequiredOptionError: (): string => 'EmptyRequiredOptionError', 12 | IvalidInputError: (): string => 'IvalidInputError', 13 | SomeIvalidOptionsError: (): string => 'SomeIvalidOptionsError', 14 | UnknownOptionWarning: (): string => 'UnknownOptionWarning', 15 | TypeMismatchError: (): string => 'TypeMismatchError', 16 | IvalidSomeArguemntsError: (): string => 'IvalidSomeArguemntsError', 17 | IvalidArguemntError: (): string => 'IvalidArguemntError', 18 | TooManyArgumentsError: (): string => 'TooManyArgumentsError', 19 | InvalidCommand: (): string => 'InvalidCommand', 20 | NoCommand: (): string => 'NoCommand', 21 | }, 22 | texts: { 23 | title_description: (): string => 'title_description', 24 | title_usage: (): string => 'title_usage', 25 | title_options: (): string => 'title_options', 26 | title_commands: (): string => 'title_commands', 27 | hint_commandHint: (): string => 'hint_commandHint', 28 | opt_required: (): string => 'opt_required', 29 | opt_optional: (): string => 'opt_optional', 30 | opt_multile: (): string => 'opt_multile', 31 | } 32 | }); 33 | 34 | const results: boolean[] = []; 35 | for (const group of [l.issues, l.texts]) { 36 | for (const [key, fn] of Object.entries(group)) { 37 | const isValid = fn() === key; 38 | results.push(isValid) 39 | } 40 | } 41 | 42 | t.true(results.every(Boolean)); 43 | t.end(); 44 | }) 45 | 46 | test('decorated text looks like original', t => { 47 | // this test is roughly checks that no locale throws any errors 48 | for (const locale of Object.values(locales)) { 49 | for (const fn of Object.values(locale.issues)) { 50 | fn({} as any, decorators.plain); 51 | } 52 | for (const fn of Object.values(locale.texts)) { 53 | fn(decorators.plain); 54 | } 55 | } 56 | t.pass(); 57 | t.end(); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/unit/index.ts: -------------------------------------------------------------------------------- 1 | import './utils'; 2 | import './option'; 3 | import './pipeline'; 4 | import './parser'; 5 | import './printer'; 6 | import './cli-helper'; 7 | import './command'; 8 | import './completer'; 9 | import './decorator'; 10 | import './i18n'; 11 | -------------------------------------------------------------------------------- /tests/unit/option.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { getOptData } from '../../src/option'; 4 | import { option } from '../../'; 5 | 6 | test('option basic', t => { 7 | t.deepEqual(getOptData(option.any), { 8 | name: '', 9 | type: 'any', 10 | labelName: 'any', 11 | description: '', 12 | isRequired: false, 13 | aliases: [], 14 | isArray: false, 15 | defaultValue: undefined, 16 | validators: [], 17 | prePreprocessors: [], 18 | postPreprocessors: [] 19 | }); 20 | t.end(); 21 | }); 22 | 23 | test('option basic', t => { 24 | const preFn = (): void => {}; 25 | const postFn = (): void => {}; 26 | const valFn = (): void => {}; 27 | 28 | const opt = option.any 29 | .alias('alias1', 'alias2') 30 | .array() 31 | .default(123) 32 | .description('description') 33 | .label('label') 34 | .process('pre', preFn as any) 35 | .process('post', postFn as any) 36 | .required() 37 | .validate(valFn); 38 | 39 | const data = getOptData(opt); 40 | //@ts-ignore 41 | delete data.postPreprocessors; 42 | t.deepEqual(data, { 43 | name: '', 44 | type: 'any', 45 | labelName: 'label', 46 | description: 'description', 47 | isRequired: true, 48 | aliases: ['alias1', 'alias2'], 49 | isArray: true, 50 | defaultValue: 123, 51 | validators: [valFn], 52 | prePreprocessors: [preFn], 53 | }); 54 | t.end(); 55 | }); 56 | 57 | test('declaration validation', t => { 58 | t.throws(() => { 59 | option.any.process('blah' as any, () => {}); 60 | }); 61 | t.end(); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/unit/parser.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { Parser, option } from '../..'; 4 | import { validateReport } from './pipeline'; 5 | import { allIssues } from '../../src/errors'; 6 | import { isError } from '../../src/report'; 7 | 8 | test('every option type', t => { 9 | const parser = new Parser({ 10 | options: { 11 | boolean: option.boolean, 12 | int: option.int, 13 | number: option.number, 14 | string: option.string, 15 | } 16 | }); 17 | 18 | const {data, report} = parser.parse('--int 12.23 --number qwe --string', {}); 19 | 20 | t.equal(data, null); 21 | 22 | validateReport(report, { 23 | issue: [allIssues.IvalidInputError, {}], 24 | children: [ 25 | {issue: [allIssues.IvalidOptionError, {optionName: 'int', value: 12.23}], children: [ 26 | {issue: [allIssues.TypeMismatchError, {}], children: []} 27 | ]}, 28 | {issue: [allIssues.IvalidOptionError, {optionName: 'number', value: 'qwe'}], children: [ 29 | {issue: [allIssues.TypeMismatchError, {}], children: []} 30 | ]}, 31 | {issue: [allIssues.IvalidOptionError, {optionName: 'string', value: true}], children: [ 32 | {issue: [allIssues.TypeMismatchError, {}], children: []} 33 | ]}, 34 | ] 35 | }); 36 | 37 | t.end(); 38 | }); 39 | 40 | test('parsing valid data', t => { 41 | const parser = new Parser({ 42 | options: { 43 | boolean: option.boolean, 44 | int: option.int, 45 | number: option.number, 46 | string: option.string, 47 | } 48 | }); 49 | 50 | const {data, report} = parser.parse('--int 12 --boolean --number 123 --string asd', {}); 51 | 52 | t.equal(report.children.length, 0); 53 | t.deepEqual(data!.options, { 54 | boolean: true, 55 | int: 12, 56 | number: 123, 57 | string: 'asd' 58 | }); 59 | 60 | t.end(); 61 | }); 62 | 63 | test('parsing ivalid arguments', t => { 64 | const parser = new Parser({ 65 | _: option.int 66 | }); 67 | 68 | const {data, report} = parser.parse('asd', {}); 69 | 70 | t.equal(data, null); 71 | 72 | validateReport(report, { 73 | issue: [allIssues.IvalidInputError, {}], 74 | children: [ 75 | {issue: [allIssues.IvalidArguemntError, {value: 'asd'}], children: [ 76 | {issue: [allIssues.TypeMismatchError, {}], children: []} 77 | ]} 78 | ] 79 | }); 80 | 81 | t.end(); 82 | }); 83 | 84 | test('empty required argument', t => { 85 | const parser = new Parser({ 86 | _: option.int.required() 87 | }); 88 | 89 | const {data, report} = parser.parse('', {}); 90 | 91 | t.equal(data, null); 92 | 93 | validateReport(report, { 94 | issue: [allIssues.IvalidInputError, {}], 95 | children: [ 96 | {issue: [allIssues.IvalidArguemntError, {value: undefined}], children: [ 97 | {issue: [allIssues.EmptyRequiredOptionError, {}], children: []} 98 | ]} 99 | ] 100 | }); 101 | 102 | t.end(); 103 | }); 104 | 105 | test('alias collision detection', t => { 106 | t.throws(() => { 107 | new Parser({ 108 | options: { 109 | a: option.any, 110 | b: option.any.alias('a') 111 | } 112 | }); 113 | }); 114 | 115 | t.throws(() => { 116 | new Parser({ 117 | options: { 118 | someVar: option.any, 119 | 'some-var': option.any 120 | } 121 | }); 122 | }, 'kebab alias collision check'); 123 | 124 | t.end(); 125 | }); 126 | 127 | test('passing array for non-array option', t => { 128 | const parser = new Parser({ 129 | _: option.int.required() 130 | }); 131 | 132 | const {data, report} = parser.parse('1 2 3', {}); 133 | 134 | t.equal(data, null); 135 | 136 | validateReport(report, { 137 | issue: [allIssues.IvalidInputError, {}], 138 | children: [ 139 | {issue: [allIssues.TooManyArgumentsError, {}], children: []} 140 | ] 141 | }); 142 | 143 | t.end(); 144 | }); 145 | 146 | test('parsing valid array of arguments', t => { 147 | const parser = new Parser({ 148 | _: option.int.array() 149 | }); 150 | 151 | const {data, report} = parser.parse('1 2 3', {}); 152 | 153 | t.deepEqual(data!._, [1, 2, 3]); 154 | 155 | t.false(isError(report.issue)); 156 | 157 | t.end(); 158 | }); 159 | 160 | test('parsing one valid arguments', t => { 161 | const parser = new Parser({ 162 | _: option.int 163 | }); 164 | 165 | const {data, report} = parser.parse('1', {}); 166 | 167 | t.deepEqual(data!._, 1); 168 | 169 | t.false(isError(report.issue)); 170 | 171 | t.end(); 172 | }); 173 | 174 | test('parsing one argument when multiple supported', t => { 175 | const parser = new Parser({ 176 | _: option.int.array() 177 | }); 178 | 179 | const {data, report} = parser.parse('1', {}); 180 | 181 | t.deepEqual(data!._ as number[], [1]); 182 | 183 | t.false(isError(report.issue)); 184 | 185 | t.end(); 186 | }); 187 | 188 | test('parsing from ENV', t => { 189 | const parser = new Parser({ 190 | useEnv: true, 191 | options: { 192 | foo: option.int.array() 193 | } 194 | }); 195 | 196 | const {data, report} = parser.parse('', {FOO: '1'}); 197 | 198 | t.deepEqual(data?.options.foo as number[], [1]); 199 | 200 | t.false(isError(report.issue)); 201 | 202 | t.end(); 203 | }); 204 | 205 | test('parsing from ENV: mutli-word', t => { 206 | const parser = new Parser({ 207 | useEnv: true, 208 | options: { 209 | envOpt: option.int.array() 210 | } 211 | }); 212 | 213 | const {data, report} = parser.parse('', {ENV_OPT: '1'}); 214 | 215 | t.deepEqual(data?.options.envOpt as number[], [1]); 216 | 217 | t.false(isError(report.issue)); 218 | 219 | t.end(); 220 | }); 221 | 222 | test('parsing from ENV: name as prefix by default', t => { 223 | const parser = new Parser({ 224 | useEnv: true, 225 | name: 'program', 226 | options: { 227 | envOpt: option.int.array() 228 | } 229 | }); 230 | 231 | const {data, report} = parser.parse('', {PROGRAM_ENV_OPT: '1'}); 232 | 233 | t.deepEqual(data?.options.envOpt as number[], [1]); 234 | 235 | t.false(isError(report.issue)); 236 | 237 | t.end(); 238 | }); 239 | -------------------------------------------------------------------------------- /tests/unit/pipeline.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { option } from '../../'; 4 | import { getOptData } from '../../src/option'; 5 | import { handleAllOptions, handleOption } from '../../src/pipeline'; 6 | import { isError, Report } from '../../src/report'; 7 | import { IssueType, allIssues } from '../../src/errors'; 8 | 9 | test('handleOption', t => { 10 | const opt = option.int 11 | .process('pre', i => Math.abs(i)) 12 | .validate('option is bad', i => i < 256) 13 | .process('post', i => i / 256) 14 | .process('post', i => i * 2 - 1); 15 | 16 | const {value, report} = handleOption(getOptData(opt), 128); 17 | t.equal(value, 0); 18 | t.deepEqual(report.children, []); 19 | t.end(); 20 | }); 21 | 22 | test('handleAllOptions', t => { 23 | const opt1 = option.int 24 | .process('pre', i => Math.abs(i)) 25 | .validate('option is bad', i => i < 256) 26 | .process('post', i => i / 256) 27 | .process('post', i => i * 2 - 1); 28 | 29 | const opt2 = option.string 30 | .default('abc'); 31 | 32 | const {data} = handleAllOptions({ 33 | opt1: getOptData(opt1), 34 | opt2: getOptData(opt2) 35 | }, {opt1: 128}, new Set() as any); 36 | 37 | t.deepEqual(data, {opt1: 0, opt2: 'abc'}); 38 | t.end(); 39 | }); 40 | 41 | test('handleArrayOption', t => { 42 | const opt = option.int 43 | .process('pre', i => Math.abs(i)) 44 | .validate('option is bad', i => i < 256) 45 | .process('post', i => i / 255) 46 | .process('post', i => i * 2 - 1) 47 | .process('post', i => Math.round(i * 100) / 100) 48 | .array(); 49 | 50 | const {value, report} = handleOption(getOptData(opt), [0, 128, 255]); 51 | t.deepEqual(value, [-1, 0, 1]); 52 | t.deepEqual(report.children, []); 53 | t.end(); 54 | }); 55 | 56 | type ReportReference = { 57 | issue: [new (...args: any[]) => IssueType, Record]; 58 | children: ReportReference[]; 59 | }; 60 | 61 | function cheapDeepEqual(v1: any, v2: any): boolean { 62 | const res = JSON.stringify(v1) === JSON.stringify(v2); 63 | if (!res) { 64 | console.error('not equal:', v1, v2); 65 | } 66 | return res; 67 | } 68 | 69 | export function validateReport(r: Report, ref: ReportReference): void { 70 | const [IssueClass, shape] = ref.issue; 71 | if (!(r.issue instanceof (IssueClass as any))) { 72 | throw new Error('report issue is wrong class'); 73 | } 74 | for (const key of Object.keys(shape)) { 75 | if (!cheapDeepEqual((r.issue as any)[key], shape[key])) { 76 | throw new Error(`report Error.${key} is incorrect`); 77 | } 78 | } 79 | ref.children.forEach((ref, i) => validateReport(r.children[i], ref)); 80 | } 81 | 82 | test('invalid options', t => { 83 | const opt1 = option.int 84 | .validate('option is bad', i => i < 256) 85 | .process('post', i => i / 256) 86 | .process('post', i => i * 2 - 1); 87 | 88 | const opt2 = option.string 89 | .default('abc'); 90 | 91 | const {data, report} = handleAllOptions({ 92 | opt1: getOptData(opt1), 93 | opt2: getOptData(opt2) 94 | }, {opt1: false, opt2: false, opt3: false}, new Set() as any); 95 | 96 | validateReport(report, { 97 | issue: [allIssues.SomeIvalidOptionsError, {}], 98 | children: [ 99 | { 100 | issue: [allIssues.IvalidOptionError, {value: false}], 101 | children: [{ 102 | issue: [allIssues.TypeMismatchError, {expected: 'int', received: 'boolean'}], 103 | children: [] 104 | }] 105 | }, 106 | { 107 | issue: [allIssues.IvalidOptionError, {value: false}], 108 | children: [{ 109 | issue: [allIssues.TypeMismatchError, {expected: 'string', received: 'boolean'}], 110 | children: [] 111 | }] 112 | }, 113 | { 114 | issue: [allIssues.UnknownOptionWarning, {}], 115 | children: [] 116 | } 117 | ] 118 | }) 119 | 120 | t.true(isError(report.issue)); 121 | t.equal(data, null); 122 | t.end(); 123 | }); 124 | 125 | test('handle strings containing digits only', t => { 126 | const opt = option.string; 127 | 128 | const res1 = handleOption(getOptData(opt), '123'); 129 | t.deepEqual(res1.value, '123'); 130 | t.deepEqual(res1.report.children, []); 131 | 132 | const res2 = handleOption(getOptData(opt), 123); 133 | t.deepEqual(res2.value, '123'); 134 | t.deepEqual(res2.report.children, []); 135 | t.end(); 136 | }); 137 | 138 | test('custom validator', t => { 139 | const opt = option.string 140 | .validate('invalid', () => false); 141 | 142 | const res = handleOption(getOptData(opt), '123'); 143 | validateReport(res.report, { 144 | issue: [allIssues.IvalidOptionError, {}], children: [{ 145 | issue: [Error as any, {message: 'invalid'}], children: [] 146 | }] 147 | }); 148 | 149 | t.end(); 150 | }); 151 | 152 | test('empty required option', t => { 153 | const opt = option.string 154 | .required(); 155 | 156 | const res = handleOption(getOptData(opt), undefined); 157 | validateReport(res.report, { 158 | issue: [allIssues.IvalidOptionError, {}], children: [{ 159 | issue: [allIssues.EmptyRequiredOptionError, {}], children: [] 160 | }] 161 | }); 162 | 163 | t.end(); 164 | }); 165 | 166 | test('all empty & all optional', t => { 167 | const opt = option.string; 168 | 169 | const res = handleOption(getOptData(opt), undefined); 170 | t.false(isError(res.report.issue)); 171 | 172 | t.equal(res.value, undefined); 173 | 174 | t.end(); 175 | }); 176 | 177 | test('invalid array', t => { 178 | const opt = option.string.array(); 179 | 180 | const res = handleOption(getOptData(opt), ['asd', true, false]); 181 | 182 | validateReport(res.report, { 183 | issue: [allIssues.IvalidOptionError, {}], children: [ 184 | {issue: [allIssues.TypeMismatchError, {}], children: []}, 185 | {issue: [allIssues.TypeMismatchError, {}], children: []} 186 | ] 187 | }); 188 | 189 | t.end(); 190 | }); 191 | -------------------------------------------------------------------------------- /tests/unit/printer.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { Printer } from '../../src/printer'; 4 | import { en_US } from '../../src/i18n'; 5 | import { plain } from '../../src/decorator'; 6 | import { option } from '../../'; 7 | import { Parser } from '../../src/parser'; 8 | import { command } from '../../src/command'; 9 | 10 | const helpTextRef = 11 | `Description 12 | description 13 | 14 | Usage 15 | test-cmd -w --required [-bz --bigBoolean -i -n -s --array --default --desc ] [] 16 | 17 | Options 18 | -b, --boolean [optional] - 19 | --bigBoolean, --big-boolean [optional] - 20 | -z [optional] - 21 | -w [required] - 22 | -i, --int [optional] - 23 | -n, --number [optional] - 24 | -s, --string [optional] - 25 | --array [multiple] - 26 | --required [required] - 27 | --default [=123] - 28 | --desc [optional] - option desc`; 29 | 30 | test('printer:genHelp', t => { 31 | const printer = new Printer({locale: en_US, decorator: plain}); 32 | const helpText = printer.generateHelp({ 33 | name: 'test-cmd', 34 | description: 'description', 35 | options: { 36 | boolean: option.boolean.alias('b'), 37 | bigBoolean: option.boolean, 38 | z: option.boolean, 39 | w: option.boolean.required(), 40 | int: option.int.alias('i'), 41 | number: option.number.alias('n'), 42 | string: option.string.alias('s'), 43 | 44 | array: option.int.array(), 45 | required: option.int.required(), 46 | default: option.any.default(123), 47 | 48 | desc: option.any.description('option desc') 49 | }, 50 | _: option.number 51 | }); 52 | 53 | t.equal(helpText, helpTextRef); 54 | 55 | t.end(); 56 | }); 57 | 58 | const cmdTextRef = [ 59 | `Description`, 60 | `prog description`, 61 | ``, 62 | `Commands`, 63 | ` test-cmd | - description`, 64 | ` test-cmd2 |`, 65 | ``, 66 | `Type prog --help for detailed documentation` 67 | ].join('\n'); 68 | 69 | test('printer:command genHelp', t => { 70 | const printer = new Printer({locale: en_US, decorator: plain}); 71 | const helpText = printer.generateHelpForComands({ 72 | program: 'prog', 73 | description: 'prog description' 74 | }, { 75 | cmd1: command({ 76 | name: 'test-cmd', 77 | description: 'description', 78 | options: {}, 79 | _: option.number 80 | }), 81 | cmd2: command({ 82 | name: 'test-cmd2', 83 | options: {}, 84 | }) 85 | }) 86 | 87 | t.equal(helpText, cmdTextRef); 88 | 89 | t.end(); 90 | }); 91 | 92 | test('printer:noOpts', t => { 93 | const helpTextRef = [ 94 | 'Description', 95 | 'description', 96 | '', 97 | 'Usage', 98 | ' test-cmd' 99 | ].join('\n'); 100 | const printer = new Printer({locale: en_US, decorator: plain}); 101 | const helpText = printer.generateHelp({ 102 | name: 'test-cmd', 103 | description: 'description', 104 | }); 105 | 106 | t.equal(helpText, helpTextRef); 107 | 108 | t.end(); 109 | }); 110 | 111 | test('printer:args:multiple', t => { 112 | const helpTextRef = [ 113 | 'Description', 114 | 'description', 115 | '', 116 | 'Usage', 117 | ' test-cmd [ ...]' 118 | ].join('\n'); 119 | const printer = new Printer({locale: en_US, decorator: plain}); 120 | const helpText = printer.generateHelp({ 121 | name: 'test-cmd', 122 | description: 'description', 123 | _: option.int.array() 124 | }); 125 | 126 | t.equal(helpText, helpTextRef); 127 | 128 | t.end(); 129 | }); 130 | 131 | test('printer:args:optional', t => { 132 | const helpTextRef = [ 133 | 'Description', 134 | 'description', 135 | '', 136 | 'Usage', 137 | ' test-cmd []' 138 | ].join('\n'); 139 | const printer = new Printer({locale: en_US, decorator: plain}); 140 | const helpText = printer.generateHelp({ 141 | name: 'test-cmd', 142 | description: 'description', 143 | _: option.int 144 | }); 145 | 146 | t.equal(helpText, helpTextRef); 147 | 148 | t.end(); 149 | }); 150 | 151 | test('printer:args:required', t => { 152 | const helpTextRef = [ 153 | 'Description', 154 | 'description', 155 | '', 156 | 'Usage', 157 | ' test-cmd ' 158 | ].join('\n'); 159 | const printer = new Printer({locale: en_US, decorator: plain}); 160 | const helpText = printer.generateHelp({ 161 | name: 'test-cmd', 162 | description: 'description', 163 | _: option.int.required() 164 | }); 165 | 166 | t.equal(helpText, helpTextRef); 167 | 168 | t.end(); 169 | }); 170 | 171 | const reportTextRef = 172 | `option is invalid 173 | - custom:error:stringify 174 | option is invalid 175 | - custom:error 176 | option is invalid 177 | - expected , but received 178 | option is invalid 179 | - expected , but received 180 | option is invalid 181 | - expected , but received 182 | option is not supported`; 183 | 184 | test('printer:stringifyReport:basic types', t => { 185 | const printer = new Printer({locale: en_US, decorator: plain}); 186 | 187 | const parser = new Parser({ 188 | options: { 189 | booleanOpt: option.boolean, 190 | intOpt: option.int, 191 | numberOpt: option.number, 192 | stringOpt: option.string, 193 | foo: option.string.validate('custom:error', () => false), 194 | bar: option.string.validate(() => { 195 | class CustomError extends Error { 196 | stringify(): string { 197 | return 'custom:error:stringify' 198 | } 199 | } 200 | throw new CustomError(); 201 | }), 202 | } 203 | }); 204 | 205 | const {data, report} = parser.parse([ 206 | '--intOpt', '12.23', 207 | '--numberOpt', 'qwe', 208 | '--stringOpt', 209 | '--foo', '0', 210 | '--bar', '1', 211 | '--invalid' 212 | ], {}); 213 | 214 | t.equal(data, null); 215 | 216 | const reportAsText = printer.stringifyReport(report); 217 | 218 | t.equals(reportAsText, reportTextRef); 219 | 220 | t.end(); 221 | }); 222 | 223 | test('printer:stringifyReport:valid report', t => { 224 | const printer = new Printer({locale: en_US, decorator: plain}); 225 | 226 | const parser = new Parser({ 227 | options: { 228 | booleanOpt: option.boolean, 229 | } 230 | }); 231 | 232 | const {report} = parser.parse([ 233 | '--booleanOpt' 234 | ], {}); 235 | 236 | t.equals(printer.stringifyReport(report), ''); 237 | 238 | t.end(); 239 | }); 240 | -------------------------------------------------------------------------------- /tests/unit/strip-ansi.ts: -------------------------------------------------------------------------------- 1 | export const stripAnsi = (str: string) => { 2 | return str.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g, ''); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/unit/utils.ts: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import { alignTextMatrix, createKebabAlias, objMap, arrayPartition, tabText, findKeyCollision } from '../../src/utils'; 4 | 5 | test('alignTextMatrix', t => { 6 | t.deepEqual(alignTextMatrix([ 7 | ['1', '123', '12'], 8 | ['', '12345', '1'], 9 | ['12345', '12345', ''] 10 | ]), [ 11 | ['1 ', '123 ', '12'], 12 | [' ', '12345', '1 '], 13 | ['12345', '12345', ' '] 14 | ]); 15 | 16 | t.deepEqual(alignTextMatrix([ 17 | ['a', 'aaa'], 18 | ['bbb', 'b'] 19 | ], ['left', 'right']), [ 20 | ['a ', 'aaa'], 21 | ['bbb', ' b'] 22 | ]); 23 | t.end(); 24 | }); 25 | 26 | test('createKebabAlias', t => { 27 | t.equal(createKebabAlias('asd'), undefined); 28 | t.equal(createKebabAlias('abcAbc'), 'abc-abc'); 29 | t.equal(createKebabAlias('AbcAbc'), 'abc-abc'); 30 | t.end(); 31 | }); 32 | 33 | test('objMap', t => { 34 | t.deepEqual(objMap({a: 2, b: 3}, i => i ** 2), {a: 4, b: 9}); 35 | t.end(); 36 | }); 37 | 38 | test('arrayPartition', t => { 39 | t.deepEqual(arrayPartition([1, 2, 3, 4, 5], i => i % 2 === 0), [[2, 4], [1, 3, 5]]); 40 | t.end(); 41 | }); 42 | 43 | test('tabText', t => { 44 | t.equal(tabText('abc\ndef', '! '), '! abc\n! def'); 45 | t.end(); 46 | }); 47 | 48 | test('findKeyCollision', t => { 49 | t.is(findKeyCollision(['a', 'b', 'a']), 'a'); 50 | t.is(findKeyCollision(['a', 'b']), null); 51 | t.end(); 52 | }); 53 | -------------------------------------------------------------------------------- /todo: -------------------------------------------------------------------------------- 1 | + postProcess typing 2 | + tests 3 | + fix string validation for number-like strings 4 | + define custom types 5 | + refactoring 6 | + locale 7 | + linter 8 | + cli helper 9 | + commands 10 | + bash complition 11 | + enumType 12 | - remove ? it should be a string 13 | - maximize test coverage 14 | - multiline help cells & max cell width 15 | ? markdown in descriptions? 16 | ? descriptions as functions 17 | ? position arguments 18 | ? option deprication? 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "inlineSourceMap": true, 8 | "declaration": true 9 | }, 10 | "exclude": [ 11 | "./tests/e2e/**/app/**/*.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | out: '../typed-cli-docs/', 3 | theme: 'minimal', 4 | 5 | readme: 'readme.md', 6 | includes: './index.ts', 7 | exclude: [ 8 | './presets/index.ts', 9 | 'src/type-logic.ts', 10 | 'src/completer.ts', 11 | 'src/decorator.ts', 12 | 'src/default-cli.ts', 13 | 'src/errors.ts', 14 | 'src/i18n.ts', 15 | 'src/pipeline.ts', 16 | 'src/printer.ts', 17 | 'src/report.ts', 18 | 'src/utils.ts', 19 | 'tests/**/*', 20 | 'pg/**/*', 21 | 'presets/**/*', 22 | 'option-helper/**/*' 23 | ], 24 | excludeNotExported: true, 25 | excludePrivate: true 26 | }; 27 | --------------------------------------------------------------------------------