├── .nvmrc ├── .github ├── logo.png ├── logo.webp ├── typed-flags.png ├── responsive-narrow.png ├── responsive-normal.png ├── command-type-narrowing.png ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE_REQUEST.yml │ └── BUG_REPORT.yml └── workflows │ ├── package-size-report.yml │ ├── test.yml │ └── release.yml ├── .vscode └── settings.json ├── examples ├── tsconfig.json ├── npm │ ├── commands │ │ ├── run-script.ts │ │ └── install.ts │ └── index.ts ├── greet │ └── index.ts ├── snap-tweet │ └── index.ts ├── pkg-size │ └── index.ts ├── esbuild │ └── index.ts └── tsc │ └── index.ts ├── src ├── render-help │ ├── index.ts │ ├── render-flags.ts │ ├── renderers.ts │ └── generate-help.ts ├── utils │ ├── script-name.ts │ └── convert-case.ts ├── index.ts ├── command.ts ├── types.ts └── cli.ts ├── tsconfig.json ├── eslint.config.ts ├── .gitignore ├── tests ├── index.ts ├── utils │ └── mock-env-functions.ts └── specs │ ├── integration.ts │ ├── cli.ts │ ├── arguments.ts │ ├── edge-cases.ts │ ├── command.ts │ ├── types.ts │ ├── flags.ts │ └── help.ts ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.11.0 2 | -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/logo.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | } 4 | -------------------------------------------------------------------------------- /.github/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/logo.webp -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "..", 3 | "include": [ 4 | ".", 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /.github/typed-flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/typed-flags.png -------------------------------------------------------------------------------- /.github/responsive-narrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/responsive-narrow.png -------------------------------------------------------------------------------- /.github/responsive-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/responsive-normal.png -------------------------------------------------------------------------------- /.github/command-type-narrowing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/cleye/HEAD/.github/command-type-narrowing.png -------------------------------------------------------------------------------- /src/render-help/index.ts: -------------------------------------------------------------------------------- 1 | export { generateHelp } from './generate-help'; 2 | export { Renderers } from './renderers'; 3 | -------------------------------------------------------------------------------- /src/utils/script-name.ts: -------------------------------------------------------------------------------- 1 | export const isValidScriptName = ( 2 | name: string, 3 | ) => name.length > 0 && !name.includes(' '); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "strict": true, 5 | "noEmit": true, 6 | "esModuleInterop": true, 7 | "isolatedModules": true, 8 | "skipLibCheck": true, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Help / Questions / Discussions 4 | url: https://github.com/privatenumber/cleye/discussions 5 | about: Use GitHub Discussions for anything else 6 | -------------------------------------------------------------------------------- /src/utils/convert-case.ts: -------------------------------------------------------------------------------- 1 | export const camelCase = (word: string) => word.replaceAll(/[\W_]([a-z\d])?/gi, (_, c) => (c ? c.toUpperCase() : '')); 2 | 3 | export const kebabCase = (word: string) => word.replaceAll(/\B([A-Z])/g, '-$1').toLowerCase(); 4 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, pvtnbr } from 'lintroll'; 2 | 3 | export default defineConfig([ 4 | ...pvtnbr(), 5 | { 6 | rules: { 7 | 'pvtnbr/prefer-arrow-functions': 'off', 8 | '@typescript-eslint/no-explicit-any': 'off', 9 | }, 10 | }, 11 | ]); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS X 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Dependency directories 10 | node_modules/ 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | # Optional REPL history 16 | .node_repl_history 17 | 18 | # Output of 'npm pack' 19 | *.tgz 20 | 21 | # Jest coverage data 22 | coverage 23 | 24 | # Distribution files 25 | dist 26 | 27 | # Cache 28 | .eslintcache 29 | -------------------------------------------------------------------------------- /examples/npm/commands/run-script.ts: -------------------------------------------------------------------------------- 1 | import { command } from '../../../src'; 2 | 3 | export const runScript = command({ 4 | name: 'run-script', 5 | 6 | alias: ['run', 'rum', 'urn'], 7 | 8 | parameters: ['', '--', '[args...]'], 9 | 10 | help: { 11 | description: 'Run a script', 12 | }, 13 | }, (argv) => { 14 | console.log('run', { 15 | command: argv._.command, 16 | args: argv._.args, 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'manten'; 2 | 3 | process.stdout.columns = Number.POSITIVE_INFINITY; 4 | 5 | describe('cleye', ({ runTestSuite }) => { 6 | runTestSuite(import('./specs/cli.js')); 7 | runTestSuite(import('./specs/flags.js')); 8 | runTestSuite(import('./specs/arguments.js')); 9 | runTestSuite(import('./specs/command.js')); 10 | runTestSuite(import('./specs/help.js')); 11 | runTestSuite(import('./specs/types.js')); 12 | runTestSuite(import('./specs/integration.js')); 13 | runTestSuite(import('./specs/edge-cases.js')); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/utils/mock-env-functions.ts: -------------------------------------------------------------------------------- 1 | import { spyOn } from 'nanospy'; 2 | 3 | const noop = () => {}; 4 | 5 | export const mockEnvFunctions = () => { 6 | const consoleLog = spyOn(console, 'log', noop); 7 | const consoleError = spyOn(console, 'error', noop); 8 | // @ts-expect-error noop 9 | const processExit = spyOn(process, 'exit', noop); 10 | 11 | return { 12 | consoleLog, 13 | consoleError, 14 | processExit, 15 | restore: () => { 16 | consoleLog.restore(); 17 | consoleError.restore(); 18 | processExit.restore(); 19 | }, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/package-size-report.yml: -------------------------------------------------------------------------------- 1 | name: Package size report 2 | 3 | on: 4 | pull_request: 5 | branches: [master, develop] 6 | 7 | jobs: 8 | pkg-size-report: 9 | name: Package size report 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Package size report 17 | id: pkg-size-report 18 | uses: privatenumber/pkg-size-action@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | display-size: uncompressed, gzip 23 | -------------------------------------------------------------------------------- /examples/npm/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Demo showing how `npm i --help` can be re-implemented with cleye 3 | * 4 | * Usage: 5 | * npx esno examples/npm i --help 6 | */ 7 | 8 | import { cli } from '../../src'; 9 | import { install } from './commands/install'; 10 | import { runScript } from './commands/run-script'; 11 | 12 | const argv = cli({ 13 | name: 'npm', 14 | 15 | commands: [ 16 | install, 17 | runScript, 18 | ], 19 | }); 20 | 21 | // Type narrowing by command name 22 | if (argv.command === 'install') { 23 | console.log(argv.flags); 24 | } else { 25 | console.log(argv.flags); 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { cli } from './cli'; 2 | export { command, type Command } from './command'; 3 | export type { Renderers } from './render-help'; 4 | export type { Flags } from './types'; 5 | 6 | /** 7 | * Re-export type-flag types that appear in cleye's public API. 8 | * 9 | * Without this, TypeScript cannot generate portable declaration files 10 | * for code like `export const cmd = command({...})` because the inferred 11 | * return type includes TypeFlag, which TS would reference via cleye's 12 | * internal node_modules path (not portable across package managers). 13 | * 14 | * Issue: https://github.com/privatenumber/cleye/issues/31 15 | */ 16 | export type { TypeFlag } from 'type-flag'; 17 | -------------------------------------------------------------------------------- /examples/greet/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Demo from README.md 3 | * 4 | * Usage: 5 | * npx esno examples/greet --help 6 | */ 7 | 8 | import { cli } from '../../src'; 9 | 10 | // Parse argv 11 | const argv = cli({ 12 | name: 'greet.js', 13 | 14 | // Define parameters 15 | // Becomes available in ._.filePath 16 | parameters: [ 17 | '', // First name is required 18 | '[last name]', // Last name is optional 19 | ], 20 | 21 | // Define flags/options 22 | // Becomes available in .flags 23 | flags: { 24 | // Parses `--time` as a string 25 | time: { 26 | type: String, 27 | description: 'Time of day to greet (morning or evening)', 28 | default: 'morning', 29 | }, 30 | }, 31 | }); 32 | 33 | const name = [argv._.firstName, argv._.lastName].filter(Boolean).join(' '); 34 | 35 | if (argv.flags.time === 'morning') { 36 | console.log(`Good morning ${name}!`); 37 | } else { 38 | console.log(`Good evening ${name}!`); 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [master, develop] 5 | pull_request: 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version-file: .nvmrc 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | run_install: true 24 | 25 | - name: Build 26 | run: pnpm build 27 | 28 | - name: Test 29 | run: | 30 | # Node.js v16 detects GITHUB_ACTIONS as a color supported environment 31 | # https://github.com/nodejs/node/blob/v16.18.0/lib/internal/tty.js#L167 32 | pnpm test 33 | 34 | pnpm --use-node-version=20.19.4 test 35 | 36 | - name: Lint 37 | run: pnpm lint 38 | 39 | - name: Type check 40 | run: pnpm type-check 41 | -------------------------------------------------------------------------------- /examples/npm/commands/install.ts: -------------------------------------------------------------------------------- 1 | import { command } from '../../../src'; 2 | 3 | export const install = command({ 4 | name: 'install', 5 | 6 | alias: ['i', 'isntall', 'add'], 7 | 8 | flags: { 9 | global: { 10 | type: Boolean, 11 | alias: 'g', 12 | }, 13 | saveProd: String, 14 | saveDev: { 15 | type: Boolean, 16 | alias: 'D', 17 | }, 18 | saveOptional: Boolean, 19 | saveExact: Boolean, 20 | noSave: Boolean, 21 | }, 22 | 23 | help: { 24 | description: 'Install a package', 25 | 26 | examples: [ 27 | 'npm install (with no args, in package dir)', 28 | 'npm install [<@scope>/]', 29 | 'npm install [<@scope>/]@', 30 | 'npm install [<@scope>/]@', 31 | 'npm install [<@scope>/]@', 32 | 'npm install @npm:', 33 | 'npm install ', 34 | 'npm install ', 35 | 'npm install ', 36 | 'npm install ', 37 | 'npm install /', 38 | ], 39 | }, 40 | }, (argv) => { 41 | console.log('install!', argv); 42 | }); 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hiroki Osame 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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: [feature request, pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for taking the time to file this feature request. 9 | - type: textarea 10 | attributes: 11 | label: Feature request 12 | description: A description of the feature you would like. 13 | validations: 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Why? 18 | description: | 19 | Describe the problem you’re tackling with this feature request. 20 | placeholder: | 21 | 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Alternatives 31 | description: | 32 | Have you considered alternative solutions? Is there a workaround? 33 | placeholder: | 34 | 39 | - type: textarea 40 | attributes: 41 | label: Additional context 42 | description: | 43 | Anything else to share? Screenshots? Links? 44 | -------------------------------------------------------------------------------- /tests/specs/integration.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { spy } from 'nanospy'; 3 | import { cli, command } from '#cleye'; 4 | 5 | export default testSuite(({ describe }) => { 6 | describe('integration', ({ test }) => { 7 | test('full CLI with all features', () => { 8 | const buildCallback = spy(); 9 | const buildCommand = command( 10 | { 11 | name: 'build', 12 | parameters: ['', '[output]'], 13 | flags: { 14 | minify: Boolean, 15 | watch: { 16 | type: Boolean, 17 | alias: 'w', 18 | }, 19 | }, 20 | }, 21 | (parsed) => { 22 | expect(parsed._.input).toBe('src/'); 23 | expect(parsed.flags.minify).toBe(true); 24 | buildCallback(); 25 | }, 26 | ); 27 | 28 | const parsed = cli( 29 | { 30 | name: 'my-cli', 31 | version: '1.0.0', 32 | parameters: ['[global-arg]'], 33 | flags: { 34 | verbose: Boolean, 35 | }, 36 | commands: [buildCommand], 37 | }, 38 | undefined, 39 | ['build', 'src/', '--minify', '--verbose'], 40 | ); 41 | 42 | if (parsed.command === 'build') { 43 | expect(parsed._.input).toBe('src/'); 44 | expect(parsed.flags.minify).toBe(true); 45 | } 46 | 47 | expect(buildCallback.called).toBe(true); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /examples/snap-tweet/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reimplementation of snap-tweet CLI: https://github.com/privatenumber/snap-tweet 3 | * 4 | * Usage: 5 | * npx esno examples/snap-tweet --help 6 | */ 7 | 8 | import { cli } from '../../src'; 9 | 10 | const argv = cli({ 11 | name: 'snap-tweet', 12 | 13 | version: '1.0.0', 14 | 15 | parameters: [''], 16 | 17 | flags: { 18 | outputDir: { 19 | type: String, 20 | alias: 'o', 21 | description: 'Tweet screenshot output directory', 22 | placeholder: '', 23 | }, 24 | width: { 25 | type: Number, 26 | alias: 'w', 27 | description: 'Width of tweet', 28 | default: 550, 29 | placeholder: '', 30 | }, 31 | showTweet: { 32 | type: Boolean, 33 | alias: 't', 34 | description: 'Show tweet thread', 35 | }, 36 | darkMode: { 37 | type: Boolean, 38 | alias: 'd', 39 | description: 'Show tweet in dark mode', 40 | }, 41 | locale: { 42 | type: String, 43 | description: 'Locale', 44 | default: 'en', 45 | placeholder: '', 46 | }, 47 | }, 48 | 49 | help: { 50 | examples: [ 51 | '# Snapshot a tweet', 52 | 'snap-tweet https://twitter.com/jack/status/20', 53 | '', 54 | '# Snapshot a tweet with Japanese locale', 55 | 'snap-tweet https://twitter.com/TwitterJP/status/578707432 --locale ja', 56 | '', 57 | '# Snapshot a tweet with dark mode and 900px width', 58 | 'snap-tweet https://twitter.com/Interior/status/463440424141459456 --width 900 --dark-mode', 59 | ], 60 | }, 61 | }); 62 | 63 | console.log(argv); 64 | -------------------------------------------------------------------------------- /examples/pkg-size/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reimplmentation of pkg-size CLI: https://github.com/pkg-size/pkg-size 3 | * 4 | * Usage: 5 | * npx esno examples/pkg-size --help 6 | */ 7 | 8 | import { cli } from '../../src'; 9 | 10 | const argv = cli({ 11 | name: 'pkg-size', 12 | 13 | version: '1.0.0', 14 | 15 | parameters: [''], 16 | 17 | flags: { 18 | sizes: { 19 | type: [String], 20 | alias: 'S', 21 | default: () => ['size', 'gzip', 'brotli'], 22 | description: 'Comma separated list of sizes to show (size, gzip, brotli)', 23 | placeholder: '', 24 | }, 25 | sortBy: { 26 | type: String, 27 | alias: 's', 28 | default: 'brotli', 29 | description: 'Sort list by (name, size, gzip, brotli)', 30 | placeholder: '', 31 | }, 32 | unit: { 33 | type: String, 34 | alias: 'u', 35 | default: 'metric', 36 | description: 'Display units (metric, iec, metric_octet, iec_octet)', 37 | placeholder: '', 38 | }, 39 | ignoreFiles: { 40 | type: String, 41 | alias: 'i', 42 | description: 'Glob to ignores files from list. Total size will still include them.', 43 | placeholder: '', 44 | }, 45 | json: { 46 | type: Boolean, 47 | description: 'JSON output', 48 | }, 49 | }, 50 | 51 | help: { 52 | examples: [ 53 | 'pkg-size', 54 | 'pkg-size ./package/path', 55 | 56 | '', 57 | 58 | '# Display formats', 59 | 'pkg-size --sizes=size,gzip,brotli', 60 | 'pkg-size -S brotli', 61 | 62 | '', 63 | 64 | '# Sorting', 65 | 'pkg-size --sort-by=name', 66 | 'pkg-size -s size', 67 | 'pkg-size --unit=iec', 68 | 69 | '', 70 | 71 | '# Formatting', 72 | 'pkg-size -u metric_octet', 73 | ], 74 | }, 75 | }); 76 | 77 | console.log(argv); 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cleye", 3 | "version": "0.0.0-semantic-release", 4 | "description": "The intuitive CLI development tool", 5 | "keywords": [ 6 | "cli", 7 | "command line", 8 | "argv", 9 | "parameters", 10 | "flags", 11 | "node", 12 | "typescript" 13 | ], 14 | "license": "MIT", 15 | "repository": "privatenumber/cleye", 16 | "funding": "https://github.com/privatenumber/cleye?sponsor=1", 17 | "author": { 18 | "name": "Hiroki Osame", 19 | "email": "hiroki.osame@gmail.com" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "main": "./dist/index.cjs", 25 | "module": "./dist/index.mjs", 26 | "types": "./dist/index.d.cts", 27 | "exports": { 28 | "require": { 29 | "types": "./dist/index.d.cts", 30 | "default": "./dist/index.cjs" 31 | }, 32 | "import": { 33 | "types": "./dist/index.d.mts", 34 | "default": "./dist/index.mjs" 35 | } 36 | }, 37 | "imports": { 38 | "#cleye": { 39 | "types": "./src/index.ts", 40 | "development": "./src/index.ts", 41 | "default": "./dist/index.mjs" 42 | } 43 | }, 44 | "packageManager": "pnpm@10.13.1", 45 | "scripts": { 46 | "build": "pkgroll --minify", 47 | "lint": "lintroll --cache --git", 48 | "type-check": "tsc", 49 | "test": "tsx tests", 50 | "dev": "tsx watch --conditions=development tests", 51 | "prepack": "pnpm build && clean-pkg-json" 52 | }, 53 | "dependencies": { 54 | "terminal-columns": "^2.0.0", 55 | "type-flag": "^4.0.3" 56 | }, 57 | "devDependencies": { 58 | "@types/node": "^24.9.2", 59 | "clean-pkg-json": "^1.3.0", 60 | "expect-type": "^1.2.2", 61 | "fastest-levenshtein": "^1.0.16", 62 | "kolorist": "^1.8.0", 63 | "lintroll": "^1.24.2", 64 | "manten": "^1.5.0", 65 | "nanospy": "^1.0.0", 66 | "pkgroll": "^2.20.1", 67 | "tsx": "^4.20.6", 68 | "typescript": "^5.9.3" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master, develop] 6 | 7 | permissions: 8 | contents: write 9 | issues: write 10 | pull-requests: write 11 | id-token: write 12 | 13 | jobs: 14 | release: 15 | name: Release 16 | if: ( 17 | github.repository_owner == 'pvtnbr' && github.ref_name =='develop' 18 | ) || ( 19 | github.repository_owner == 'privatenumber' && github.ref_name =='master' 20 | ) 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | token: ${{ secrets.GH_TOKEN }} 29 | 30 | - name: Use Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version-file: .nvmrc 34 | 35 | - name: Setup pnpm 36 | uses: pnpm/action-setup@v4 37 | with: 38 | run_install: true 39 | 40 | - name: Lint 41 | run: pnpm lint 42 | 43 | - name: Prerelease to GitHub 44 | if: github.repository_owner == 'pvtnbr' 45 | run: | 46 | git remote add public https://github.com/$(echo $GITHUB_REPOSITORY | sed "s/^pvtnbr/privatenumber/") 47 | git fetch public master 'refs/tags/*:refs/tags/*' 48 | git push --force --tags origin refs/remotes/public/master:refs/heads/master 49 | 50 | jq ' 51 | .publishConfig.registry = "https://npm.pkg.github.com" 52 | | .name = ("@" + env.GITHUB_REPOSITORY_OWNER + "/" + .name) 53 | | .repository = env.GITHUB_REPOSITORY 54 | | .release.branches = [ 55 | "master", 56 | { name: "develop", prerelease: "rc", channel: "latest" } 57 | ] 58 | ' package.json > _package.json 59 | mv _package.json package.json 60 | 61 | - name: Release 62 | env: 63 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 64 | run: pnpm dlx semantic-release 65 | -------------------------------------------------------------------------------- /src/render-help/render-flags.ts: -------------------------------------------------------------------------------- 1 | import type { Flags } from '../types'; 2 | import { kebabCase } from '../utils/convert-case'; 3 | 4 | const tableBreakpoints = { 5 | '> 80': [ 6 | { 7 | width: 'content-width', 8 | paddingLeft: 2, 9 | paddingRight: 8, 10 | }, 11 | 12 | // Flag alias to fill remaining line 13 | { 14 | width: 'auto', 15 | }, 16 | ], 17 | 18 | '> 40': [ 19 | { 20 | width: 'auto', 21 | paddingLeft: 2, 22 | paddingRight: 8, 23 | 24 | // Remove alias padding on smaller screens 25 | preprocess: (text: string) => text.trim(), 26 | }, 27 | 28 | // Flag description to be on own line 29 | { 30 | width: '100%', 31 | paddingLeft: 2, 32 | paddingBottom: 1, 33 | }, 34 | ], 35 | 36 | '> 0': { 37 | // Remove responsiveness 38 | stdoutColumns: 1000, 39 | 40 | columns: [ 41 | { 42 | width: 'content-width', 43 | paddingLeft: 2, 44 | paddingRight: 8, 45 | }, 46 | { 47 | width: 'content-width', 48 | }, 49 | ], 50 | }, 51 | }; 52 | 53 | export type FlagData = { 54 | name: string; 55 | flag: Flags[string]; 56 | flagFormatted: string; 57 | aliasesEnabled: boolean; 58 | aliasFormatted: string | undefined; 59 | }; 60 | 61 | export function renderFlags(flags: Flags) { 62 | let aliasesEnabled = false; 63 | const flagsData = Object.keys(flags) 64 | .sort((a, b) => a.localeCompare(b)) 65 | .map((name): FlagData => { 66 | const flag = flags[name]; 67 | const hasAlias = ('alias' in flag); 68 | 69 | if (hasAlias) { 70 | aliasesEnabled = true; 71 | } 72 | 73 | return { 74 | name, 75 | flag, 76 | flagFormatted: `--${kebabCase(name)}`, 77 | aliasesEnabled, 78 | aliasFormatted: hasAlias ? `-${flag.alias}` : undefined, 79 | }; 80 | }); 81 | 82 | const tableData = flagsData.map((flagData) => { 83 | flagData.aliasesEnabled = aliasesEnabled; 84 | 85 | return [ 86 | { 87 | type: 'flagName', 88 | data: flagData, 89 | }, 90 | { 91 | type: 'flagDescription', 92 | data: flagData, 93 | }, 94 | ]; 95 | }); 96 | 97 | return { 98 | type: 'table', 99 | data: { 100 | tableData, 101 | tableBreakpoints, 102 | }, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import type { IgnoreFunction } from 'type-flag'; 2 | import type { 3 | CallbackFunction, 4 | Flags, 5 | HelpOptions, 6 | ParseArgv, 7 | parsedType, 8 | } from './types'; 9 | import { isValidScriptName } from './utils/script-name'; 10 | 11 | export type CommandOptions = { 12 | 13 | /** 14 | Name of the command used to invoke it. Also displayed in `--help` output. 15 | */ 16 | name: string; 17 | 18 | /** 19 | Aliases for the command used to invoke it. Also displayed in `--help` output. 20 | */ 21 | alias?: string | string[]; 22 | 23 | /** 24 | Parameters accepted by the command. Parameters must be in the following formats: 25 | 26 | - Required parameter: `` 27 | - Optional parameter: `[parameter name]` 28 | - Required spread parameter: `` 29 | - Optional spread parameter: `[parameter name...]` 30 | */ 31 | parameters?: Parameters; 32 | 33 | /** 34 | Flags accepted by the command 35 | */ 36 | flags?: Flags; 37 | 38 | /** 39 | Options to configure the help documentation. Pass in `false` to disable handling `--help, -h`. 40 | */ 41 | help?: false | HelpOptions; 42 | 43 | /** 44 | * Which argv elements to ignore from parsing 45 | */ 46 | ignoreArgv?: IgnoreFunction; 47 | 48 | /** 49 | * When enabled, prints an error and exits if unknown flags are passed. 50 | * Suggests the closest matching flag name when possible. 51 | * Inherits from parent CLI if not specified. 52 | */ 53 | strictFlags?: boolean; 54 | }; 55 | 56 | export function command< 57 | Options extends CommandOptions<[...Parameters]>, 58 | Parameters extends string[], 59 | >( 60 | options: Readonly & CommandOptions<[...Parameters]>, 61 | callback?: CallbackFunction>, 62 | ): Command> { 63 | if (!options) { 64 | throw new Error('Command options are required'); 65 | } 66 | 67 | const { name } = options; 68 | if (name === undefined) { 69 | throw new Error('Command name is required'); 70 | } 71 | 72 | if (!isValidScriptName(name)) { 73 | throw new Error(`Invalid command name ${JSON.stringify(name)}. Command names must be one word.`); 74 | } 75 | 76 | // @ts-expect-error Missing parsedType, a type only property 77 | return { 78 | options, 79 | callback, 80 | }; 81 | } 82 | 83 | export type Command< 84 | Options extends CommandOptions = CommandOptions, 85 | ParsedType = any, 86 | > = { 87 | readonly options: Options; 88 | readonly callback?: CallbackFunction; 89 | [parsedType]: ParsedType; 90 | }; 91 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report 3 | labels: [bug, pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for taking the time to file this bug report. 9 | - type: textarea 10 | attributes: 11 | label: Bug description 12 | description: A clear and concise description of the bug. 13 | placeholder: | 14 | 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Reproduction 28 | description: | 29 | How can we reproduce the issue? When an issue is immediately reproducible, others can start debugging instead of following-up with questions. 30 | placeholder: | 31 | 39 | validations: 40 | required: true 41 | - type: dropdown 42 | attributes: 43 | label: Node.js package manager 44 | description: Which package manager are you using? [npm](https://docs.npmjs.com/cli/v7/commands/npm), [yarn](https://yarnpkg.com/), or [pnpm](https://pnpm.io/) 45 | options: 46 | - npm 47 | - yarn 48 | - pnpm 49 | validations: 50 | required: true 51 | - type: textarea 52 | attributes: 53 | label: Environment 54 | description: | 55 | Describe the environment the issue is happening in. This information is used to for reproduction and debugging. 56 | placeholder: | 57 | 63 | 64 | System: 65 | OS: 66 | CPU: 67 | Shell: 68 | Binaries: 69 | Node: 70 | npm: 71 | npmPackages: 72 | cleye: 73 | render: shell 74 | validations: 75 | required: true 76 | - type: checkboxes 77 | attributes: 78 | label: Can you contribute a fix? 79 | description: We would love it if you can open a pull request to fix this bug! 80 | options: 81 | - label: I’m interested in opening a pull request for this issue. 82 | -------------------------------------------------------------------------------- /tests/specs/cli.ts: -------------------------------------------------------------------------------- 1 | import { setImmediate } from 'node:timers/promises'; 2 | import { testSuite, expect } from 'manten'; 3 | import { cli } from '#cleye'; 4 | 5 | export default testSuite(({ describe }) => { 6 | describe('cli', ({ describe, test }) => { 7 | describe('error-handling', ({ test }) => { 8 | test('must pass in options', () => { 9 | expect(() => { 10 | // @ts-expect-error no options 11 | cli(); 12 | }).toThrow('Options is required'); 13 | }); 14 | 15 | test('missing name', () => { 16 | expect(() => { 17 | cli({ 18 | name: '', 19 | }); 20 | }).toThrow('Invalid script name: ""'); 21 | }); 22 | 23 | test('invalid name format', () => { 24 | expect(() => { 25 | cli({ 26 | name: 'a b', 27 | }); 28 | }).toThrow('Invalid script name: "a b"'); 29 | }); 30 | 31 | test('allowed name format', () => { 32 | cli({ 33 | name: 'a.b_', 34 | }); 35 | }); 36 | }); 37 | 38 | test('async callbacks', async () => { 39 | let asyncCompleted = false; 40 | await cli({}, async () => { 41 | await setImmediate(); 42 | asyncCompleted = true; 43 | }); 44 | expect(asyncCompleted).toBe(true); 45 | }); 46 | 47 | describe('callback error handling', ({ test }) => { 48 | test('callback throws synchronous error', () => { 49 | expect(() => { 50 | cli({}, () => { 51 | throw new Error('Callback error'); 52 | }); 53 | }).toThrow('Callback error'); 54 | }); 55 | 56 | test('callback returns rejected Promise', async () => { 57 | const result = cli({}, async () => { 58 | throw new Error('Async error'); 59 | }); 60 | await expect(result).rejects.toThrow('Async error'); 61 | }); 62 | 63 | test('callback with fake promise thenable', () => { 64 | const fakePromise = { 65 | then: (resolve: any) => resolve('fake'), // eslint-disable-line unicorn/no-thenable 66 | }; 67 | const result = cli({}, () => fakePromise as any); 68 | expect(result).toHaveProperty('then'); 69 | }); 70 | }); 71 | 72 | describe('Promise edge cases', ({ test }) => { 73 | test('result properties accessible on Promise return', async () => { 74 | const result = cli({ 75 | parameters: [''], 76 | }, async () => { 77 | await setImmediate(); 78 | }, ['test']); 79 | 80 | // Properties should be accessible even though callback returns Promise 81 | expect(result._.value).toBe('test'); 82 | 83 | // And it's awaitable 84 | await result; 85 | }); 86 | 87 | test('cli Promise waits for callback to complete', async () => { 88 | let callbackCompleted = false; 89 | 90 | const result = cli({}, async () => { 91 | await setImmediate(); 92 | callbackCompleted = true; 93 | }); 94 | 95 | // Callback shouldn't have completed yet 96 | expect(callbackCompleted).toBe(false); 97 | 98 | // After awaiting cli, callback should be complete 99 | await result; 100 | expect(callbackCompleted).toBe(true); 101 | }); 102 | 103 | test('cli Promise never resolves if callback never resolves', async () => { 104 | let cliResolved = false; 105 | 106 | const result = cli({}, async () => { 107 | // Never resolve - hang forever 108 | await new Promise(() => {}); 109 | }); 110 | 111 | // Race the cli promise against a timeout 112 | await Promise.race([ 113 | result.then(() => { 114 | cliResolved = true; 115 | }), 116 | setImmediate(50), 117 | ]); 118 | 119 | // cli should not have resolved 120 | expect(cliResolved).toBe(false); 121 | }); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/render-help/renderers.ts: -------------------------------------------------------------------------------- 1 | import tty from 'tty'; 2 | import { 3 | terminalColumns, 4 | breakpoints, 5 | type Options as TerminalColumnsOptions, 6 | } from 'terminal-columns'; 7 | import type { HelpDocumentNode } from '../types'; 8 | import type { FlagData } from './render-flags'; 9 | 10 | type TypeFunction = (value: any) => any; 11 | 12 | /** 13 | * process.stdout.hasColors() may not be available if stdout is not a TTY, 14 | * but whether the viewer can render colors is an environment concern: 15 | * https://github.com/nodejs/node/blob/v18.0.0/lib/internal/tty.js#L106 16 | * 17 | * In the future, they may deprecate the prototype method in favor of a 18 | * standalone function: 19 | * https://github.com/nodejs/node/pull/40240 20 | */ 21 | const stdoutHasColors = tty.WriteStream.prototype.hasColors(); 22 | 23 | type HelpDocumentNodeOrString = string | HelpDocumentNode; 24 | export class Renderers { 25 | // Useful for associating an id with data: 26 | // { id: 'title', type: 'string' } 27 | text(text: string) { 28 | return text; 29 | } 30 | 31 | bold(text: string) { 32 | return stdoutHasColors 33 | ? `\u001B[1m${text}\u001B[22m` 34 | : text.toLocaleUpperCase(); 35 | } 36 | 37 | indentText({ text, spaces }: { text: string; 38 | spaces: number; }) { 39 | return text.replaceAll(/^/gm, ' '.repeat(spaces)); 40 | } 41 | 42 | heading(text: string) { 43 | return this.bold(text); 44 | } 45 | 46 | section({ 47 | title, 48 | body, 49 | indentBody = 2, 50 | }: { 51 | title?: string; 52 | body?: string; 53 | indentBody?: number; 54 | }) { 55 | return ( 56 | `${ 57 | ( 58 | title 59 | ? `${this.heading(title)}\n` 60 | : '' 61 | ) 62 | + ( 63 | body 64 | ? this.indentText({ 65 | text: this.render(body), 66 | spaces: indentBody, 67 | }) 68 | : '' 69 | ) 70 | }\n` 71 | ); 72 | } 73 | 74 | table({ 75 | tableData, 76 | tableOptions, 77 | tableBreakpoints, 78 | }: { 79 | tableData: string[][]; 80 | tableOptions?: TerminalColumnsOptions; 81 | tableBreakpoints?: Record; 82 | }) { 83 | return terminalColumns( 84 | tableData.map(row => row.map(cell => this.render(cell))), 85 | tableBreakpoints ? breakpoints(tableBreakpoints) : tableOptions, 86 | ); 87 | } 88 | 89 | flagParameter( 90 | typeFunction: TypeFunction | readonly [TypeFunction], 91 | ): string { 92 | if (typeFunction === Boolean) { 93 | return ''; 94 | } 95 | 96 | if (typeFunction === String) { 97 | return ''; 98 | } 99 | 100 | if (typeFunction === Number) { 101 | return ''; 102 | } 103 | 104 | if (Array.isArray(typeFunction)) { 105 | return this.flagParameter(typeFunction[0]); 106 | } 107 | 108 | return ''; 109 | } 110 | 111 | flagOperator(_: FlagData) { 112 | return ' '; 113 | } 114 | 115 | flagName(flagData: FlagData) { 116 | const { 117 | flag, 118 | flagFormatted, 119 | aliasesEnabled, 120 | aliasFormatted, 121 | } = flagData; 122 | let flagText = ''; 123 | 124 | if (aliasFormatted) { 125 | flagText += `${aliasFormatted}, `; 126 | } else if (aliasesEnabled) { 127 | flagText += ' '; 128 | } 129 | 130 | flagText += flagFormatted; 131 | 132 | if ('placeholder' in flag && typeof flag.placeholder === 'string') { 133 | flagText += `${this.flagOperator(flagData)}${flag.placeholder}`; 134 | } else { 135 | // Test: default flag for String type short-hand 136 | const flagPlaceholder = this.flagParameter('type' in flag ? flag.type : flag); 137 | if (flagPlaceholder) { 138 | flagText += `${this.flagOperator(flagData)}${flagPlaceholder}`; 139 | } 140 | } 141 | 142 | return flagText; 143 | } 144 | 145 | flagDefault(value: any) { 146 | return JSON.stringify(value); 147 | } 148 | 149 | flagDescription({ flag }: FlagData) { 150 | let descriptionText = 'description' in flag ? (flag.description ?? '') : ''; 151 | 152 | if ('default' in flag) { 153 | let { default: flagDefault } = flag; 154 | 155 | if (typeof flagDefault === 'function') { 156 | flagDefault = flagDefault(); 157 | } 158 | 159 | if (flagDefault) { 160 | descriptionText += ` (default: ${this.flagDefault(flagDefault)})`; 161 | } 162 | } 163 | 164 | return descriptionText; 165 | } 166 | 167 | render( 168 | nodes: ( 169 | HelpDocumentNodeOrString 170 | | HelpDocumentNodeOrString[] 171 | ), 172 | ): string { 173 | if (typeof nodes === 'string') { 174 | return nodes; 175 | } 176 | 177 | if (Array.isArray(nodes)) { 178 | return nodes.map(node => this.render(node)).join('\n'); 179 | } 180 | 181 | if ('type' in nodes && this[nodes.type]) { 182 | const renderer = this[nodes.type]; 183 | if (typeof renderer === 'function') { 184 | return renderer.call(this, nodes.data); 185 | } 186 | } 187 | 188 | throw new Error(`Invalid node type: ${JSON.stringify(nodes)}`); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/render-help/generate-help.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CliOptionsInternal, 3 | HelpDocumentNode, 4 | } from '../types'; 5 | import type { CommandOptions } from '../command'; 6 | import { renderFlags } from './render-flags'; 7 | 8 | type Options = CliOptionsInternal | CommandOptions; 9 | 10 | const getVersion = (options?: CliOptionsInternal) => ( 11 | !options || ( 12 | options.version ?? ( 13 | options.help 14 | ? options.help.version 15 | : undefined 16 | ) 17 | ) 18 | ); 19 | 20 | const getName = (options: Options) => { 21 | const parentName = ('parent' in options) && options.parent?.name; 22 | return (parentName ? `${parentName} ` : '') + options.name; 23 | }; 24 | 25 | function getNameAndVersion(options: Options) { 26 | const name = []; 27 | 28 | if (options.name) { 29 | name.push(getName(options)); 30 | } 31 | 32 | const version = getVersion(options) ?? (('parent' in options) && getVersion(options.parent)); 33 | if (version) { 34 | name.push(`v${version}`); 35 | } 36 | 37 | if (name.length === 0) { 38 | return; 39 | } 40 | 41 | return { 42 | id: 'name', 43 | type: 'text', 44 | data: `${name.join(' ')}\n`, 45 | } as const; 46 | } 47 | 48 | function getDescription(options: Options) { 49 | const { help } = options; 50 | if ( 51 | !help 52 | || !help.description 53 | ) { 54 | return; 55 | } 56 | 57 | return { 58 | id: 'description', 59 | type: 'text', 60 | data: `${help.description}\n`, 61 | } as const; 62 | } 63 | 64 | function getUsage(options: Options) { 65 | const help = options.help || {}; 66 | 67 | if ('usage' in help) { 68 | if (!help.usage) { 69 | return; 70 | } 71 | 72 | return { 73 | id: 'usage', 74 | type: 'section', 75 | data: { 76 | title: 'Usage:', 77 | body: ( 78 | Array.isArray(help.usage) 79 | ? help.usage.join('\n') 80 | : help.usage 81 | ), 82 | }, 83 | } as const; 84 | } if (options.name) { 85 | const usages: string[] = []; 86 | 87 | const usage = [getName(options)]; 88 | 89 | if ( 90 | options.flags 91 | && Object.keys(options.flags).length > 0 92 | ) { 93 | usage.push('[flags...]'); 94 | } 95 | 96 | if ( 97 | options.parameters 98 | && options.parameters.length > 0 99 | ) { 100 | const { parameters } = options; 101 | const hasEof = parameters.indexOf('--'); 102 | const hasRequiredParametersAfterEof = hasEof !== -1 && parameters.slice(hasEof + 1).some(parameter => parameter.startsWith('<')); 103 | usage.push( 104 | parameters 105 | .map((parameter) => { 106 | if (parameter !== '--') { 107 | return parameter; 108 | } 109 | return hasRequiredParametersAfterEof ? '--' : '[--]'; 110 | }) 111 | .join(' '), 112 | ); 113 | } 114 | 115 | if (usage.length > 1) { 116 | usages.push(usage.join(' ')); 117 | } 118 | 119 | if ( 120 | 'commands' in options 121 | && options.commands?.length 122 | ) { 123 | usages.push(`${options.name} `); 124 | } 125 | 126 | if (usages.length > 0) { 127 | return { 128 | id: 'usage', 129 | type: 'section', 130 | data: { 131 | title: 'Usage:', 132 | body: usages.join('\n'), 133 | }, 134 | } as const; 135 | } 136 | } 137 | } 138 | 139 | function getCommands(options: Options) { 140 | if ( 141 | !('commands' in options) 142 | || !options.commands?.length 143 | ) { 144 | return; 145 | } 146 | 147 | const commands = options.commands.map( 148 | (command) => { 149 | const { help } = command.options; 150 | return [command.options.name, (typeof help === 'object' && help.description) || '']; 151 | }, 152 | ); 153 | 154 | const commandsTable = { 155 | type: 'table', 156 | data: { 157 | tableData: commands, 158 | tableOptions: [ 159 | { 160 | width: 'content-width', 161 | paddingLeft: 2, 162 | paddingRight: 8, 163 | }, 164 | ], 165 | }, 166 | }; 167 | 168 | return { 169 | id: 'commands', 170 | type: 'section', 171 | data: { 172 | title: 'Commands:', 173 | body: commandsTable, 174 | indentBody: 0, 175 | }, 176 | } as const; 177 | } 178 | 179 | function getFlags(options: Options) { 180 | if ( 181 | !options.flags 182 | || Object.keys(options.flags).length === 0 183 | ) { 184 | return; 185 | } 186 | 187 | return { 188 | id: 'flags', 189 | type: 'section', 190 | data: { 191 | title: 'Flags:', 192 | body: renderFlags(options.flags), 193 | indentBody: 0, 194 | }, 195 | } as const; 196 | } 197 | 198 | function getExamples(options: Options) { 199 | const { help } = options; 200 | if ( 201 | !help 202 | || !help.examples 203 | || help.examples.length === 0 204 | ) { 205 | return; 206 | } 207 | 208 | let { examples } = help; 209 | 210 | if (Array.isArray(examples)) { 211 | examples = examples.join('\n'); 212 | } 213 | 214 | if (examples) { 215 | return { 216 | id: 'examples', 217 | type: 'section', 218 | data: { 219 | title: 'Examples:', 220 | body: examples, 221 | }, 222 | } as const; 223 | } 224 | } 225 | 226 | function getAliases(options: Options) { 227 | if ( 228 | !('alias' in options) 229 | || !options.alias 230 | ) { 231 | return; 232 | } 233 | 234 | const { alias } = options; 235 | const aliases = Array.isArray(alias) ? alias.join(', ') : alias; 236 | 237 | return { 238 | id: 'aliases', 239 | type: 'section', 240 | data: { 241 | title: 'Aliases:', 242 | body: aliases, 243 | }, 244 | } as const; 245 | } 246 | 247 | type Truthy = (value?: T) => value is T; 248 | 249 | export const generateHelp = (options: Options): HelpDocumentNode[] => ( 250 | [ 251 | getNameAndVersion, 252 | getDescription, 253 | getUsage, 254 | getCommands, 255 | getFlags, 256 | getExamples, 257 | getAliases, 258 | ].map( 259 | helpSectionGenerator => helpSectionGenerator(options), 260 | ).filter( 261 | Boolean as any as Truthy, 262 | ) 263 | ); 264 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TypeFlag, 3 | Flags as BaseFlags, 4 | IgnoreFunction, 5 | } from 'type-flag'; 6 | import type { Command } from './command'; 7 | import type { Renderers } from './render-help/renderers'; 8 | 9 | export declare const parsedType: unique symbol; 10 | 11 | export type Flags = BaseFlags<{ 12 | 13 | /** 14 | Description to be used in help output 15 | 16 | @example 17 | ``` 18 | description: 'Unit of output (metric, imperial)', 19 | ``` 20 | */ 21 | description?: string; 22 | 23 | /** 24 | Placeholder label to be used in help output 25 | 26 | @example Required value 27 | ``` 28 | placeholder: '' 29 | ``` 30 | */ 31 | placeholder?: string; 32 | }>; 33 | 34 | export type CallbackFunction = (parsed: { 35 | // This exposes the content of "TypeFlag" in type hints 36 | [Key in keyof Parsed]: Parsed[Key]; 37 | }) => void | Promise; 38 | 39 | type HasVersion = ( 40 | Options extends { version: string } 41 | ? Options['flags'] & { version: BooleanConstructor } 42 | : Options['flags'] 43 | ); 44 | 45 | type HasHelp = ( 46 | Options extends { help: false } 47 | ? Options['flags'] 48 | : Options['flags'] & { help: BooleanConstructor } 49 | ); 50 | 51 | type HasHelpOrVersion = ( 52 | HasVersion & HasHelp 53 | ); 54 | 55 | export type HelpDocumentNode = { 56 | id?: string; 57 | type: Types; 58 | data: any; 59 | }; 60 | 61 | export type HelpOptions = { 62 | 63 | /** 64 | Version of the script displayed in `--help` output. Use to avoid enabling `--version` flag. 65 | */ 66 | version?: string; 67 | 68 | /** 69 | Description of the script or command to display in `--help` output. 70 | */ 71 | description?: string; 72 | 73 | /** 74 | Usage code examples to display in `--help` output. 75 | */ 76 | usage?: false | string | string[]; 77 | 78 | /** 79 | Example code snippets to display in `--help` output. 80 | */ 81 | examples?: string | string[]; 82 | 83 | /** 84 | Function to customize the help document before it is logged. 85 | */ 86 | render?: ( 87 | nodes: HelpDocumentNode[], 88 | renderers: Renderers, 89 | ) => string; 90 | }; 91 | 92 | export type CliOptions< 93 | Commands = Command[], 94 | Parameters extends string[] = string[], 95 | > = { 96 | 97 | /** 98 | Name of the script displayed in `--help` output. 99 | */ 100 | name?: string; 101 | 102 | /** 103 | Version of the script displayed in `--version` and `--help` outputs. 104 | */ 105 | version?: string; 106 | 107 | /** 108 | Parameters accepted by the script. Parameters must be in the following formats: 109 | 110 | - Required parameter: `` 111 | - Optional parameter: `[parameter name]` 112 | - Required spread parameter: `` 113 | - Optional spread parameter: `[parameter name...]` 114 | */ 115 | parameters?: Parameters; 116 | 117 | /** 118 | Commands to register to the script. 119 | */ 120 | commands?: Commands; 121 | 122 | /** 123 | Flags accepted by the script 124 | */ 125 | flags?: Flags; 126 | 127 | /** 128 | Options to configure the help documentation. Pass in `false` to disable handling `--help, -h`. 129 | */ 130 | help?: false | HelpOptions; 131 | 132 | /** 133 | * Which argv elements to ignore from parsing 134 | */ 135 | ignoreArgv?: IgnoreFunction; 136 | 137 | /** 138 | * When enabled, prints an error and exits if unknown flags are passed. 139 | * Suggests the closest matching flag name when possible. 140 | */ 141 | strictFlags?: boolean; 142 | }; 143 | 144 | export type CliOptionsInternal< 145 | Commands = Command[], 146 | > = CliOptions & { 147 | parent?: CliOptions; 148 | }; 149 | 150 | type AlphabetLowercase = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'; 151 | type Numeric = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; 152 | type AlphaNumeric = AlphabetLowercase | Uppercase | Numeric; 153 | 154 | type CamelCase = ( 155 | Word extends `${infer FirstCharacter}${infer Rest}` 156 | ? ( 157 | FirstCharacter extends AlphaNumeric 158 | ? `${FirstCharacter}${CamelCase}` 159 | : Capitalize> 160 | ) 161 | : Word 162 | ); 163 | 164 | type StripBrackets = ( 165 | Parameter extends `<${infer ParameterName}>` | `[${infer ParameterName}]` 166 | ? ( 167 | ParameterName extends `${infer SpreadName}...` 168 | ? SpreadName 169 | : ParameterName 170 | ) 171 | : never 172 | ); 173 | 174 | type ParameterType = ( 175 | Parameter extends `<${infer _ParameterName}...>` | `[${infer _ParameterName}...]` 176 | ? string[] 177 | : Parameter extends `<${infer _ParameterName}>` 178 | ? string 179 | : Parameter extends `[${infer _ParameterName}]` 180 | ? string | undefined 181 | : never 182 | ); 183 | 184 | type WithCommand< 185 | Options extends TypeFlag, 186 | CommandName extends string | undefined = undefined, 187 | > = { 188 | command: CommandName; 189 | } & Options; 190 | 191 | type TypeFlagWrapper< 192 | Options extends { flags?: Flags }, 193 | Parameters extends string[], 194 | > = TypeFlag> & { 195 | _: { 196 | [ 197 | Parameter in Parameters[number] 198 | as CamelCase> 199 | ]: ParameterType; 200 | }; 201 | showHelp: (options?: HelpOptions) => void; 202 | showVersion: () => void; 203 | }; 204 | 205 | export type ParseArgv< 206 | Options extends { flags?: Flags }, 207 | Parameters extends string[], 208 | CommandName extends string | undefined = '', 209 | > = ( 210 | CommandName extends '' 211 | ? TypeFlagWrapper 212 | : WithCommand, CommandName> 213 | ); 214 | 215 | /** 216 | * Helper type to reject unknown properties in cli() options. 217 | * Maps any key not in CliOptions to `never`, causing a type error 218 | * when excess properties are passed. 219 | */ 220 | export type StrictOptions = T & Record, never>; 221 | 222 | /** 223 | * Helper type to conditionally add Promise to return type 224 | * only when callback returns a Promise. 225 | */ 226 | export type MaybePromise< 227 | Result, 228 | CallbackReturn, 229 | > = CallbackReturn extends Promise 230 | ? Result & Promise 231 | : Result; 232 | -------------------------------------------------------------------------------- /examples/esbuild/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reimplementation of esbuild CLI: https://github.com/evanw/esbuild 3 | * 4 | * Usage: 5 | * npx esno examples/esbuild --help 6 | */ 7 | 8 | import { underline } from 'kolorist'; 9 | import { cli } from '../../src'; 10 | 11 | const simpleFlags = { 12 | bundle: { 13 | type: Boolean, 14 | description: 'Bundle all dependencies into the output files', 15 | }, 16 | define: { 17 | type: String, 18 | description: 'Substitute K with V while parsing', 19 | }, 20 | external: { 21 | type: String, 22 | description: 'Exclude module M from the bundle (can use * wildcards)', 23 | }, 24 | format: { 25 | type: String, 26 | description: 'Output format (iife | cjs | esm, no default when not bundling, otherwise default is iife when platform is browser and cjs when platform is node)', 27 | }, 28 | loader: { 29 | type: String, 30 | description: 'Use loader L to load file extension X, where L is one of: js | jsx | ts | tsx | json | text | base64 | file | dataurl | binary', 31 | }, 32 | minify: { 33 | type: Boolean, 34 | description: 'Minify the output (sets all --minify-* flags)', 35 | }, 36 | outdir: { 37 | type: String, 38 | description: 'The output directory (for multiple entry points)', 39 | }, 40 | outfile: { 41 | type: String, 42 | description: 'The output file (for one entry point)', 43 | }, 44 | platform: { 45 | type: String, 46 | description: 'Platform target (browser | node | neutral, default browser)', 47 | }, 48 | serve: { 49 | type: String, 50 | description: 'Start a local HTTP server on this host:port for outputs', 51 | }, 52 | sourcemap: { 53 | type: Boolean, 54 | description: 'Enable a source map', 55 | }, 56 | splitting: { 57 | type: Boolean, 58 | description: 'Enable code splitting (currently only for esm)', 59 | }, 60 | target: { 61 | type: String, 62 | description: 'Environment target (e.g. es2017, chrome58, firefox57, safari11, edge16, node10, default esnext)', 63 | }, 64 | watch: { 65 | type: Boolean, 66 | description: 'Watch mode: rebuild on file system changes', 67 | }, 68 | }; 69 | 70 | const advancedFlags = { 71 | allowOverwrite: { 72 | type: Boolean, 73 | description: 'Allow output files to overwrite input files', 74 | }, 75 | assetNames: { 76 | type: Boolean, 77 | description: 'Path template to use for "file" loader files (default "[name]-[hash]")', 78 | }, 79 | charset: { 80 | type: Boolean, 81 | description: 'Do not escape UTF-8 code points', 82 | }, 83 | chunkNames: { 84 | type: Boolean, 85 | description: 'Path template to use for code splitting chunks (default "[name]-[hash]")', 86 | }, 87 | color: { 88 | type: Boolean, 89 | description: 'Force use of color terminal escapes (true | false)', 90 | }, 91 | entryNames: { 92 | type: Boolean, 93 | description: 'Path template to use for entry point output paths (default "[dir]/[name]", can also use "[hash]")', 94 | }, 95 | globalName: { 96 | type: Boolean, 97 | description: 'The name of the global for the IIFE format', 98 | }, 99 | jsxFactory: { 100 | type: Boolean, 101 | description: 'What to use for JSX instead of React.createElement', 102 | }, 103 | jsxFragment: { 104 | type: Boolean, 105 | description: 'What to use for JSX instead of React.Fragment', 106 | }, 107 | jsx: { 108 | type: Boolean, 109 | description: 'Set to "preserve" to disable transforming JSX to JS', 110 | }, 111 | keepNames: { 112 | type: Boolean, 113 | description: 'Preserve "name" on functions and classes', 114 | }, 115 | legalComments: { 116 | type: Boolean, 117 | description: 'Where to place license comments (none | inline | eof | linked | external, default eof when bundling and inline otherwise)', 118 | }, 119 | logLevel: { 120 | type: Boolean, 121 | description: 'Disable logging (verbose | debug | info | warning | error | silent, default info)', 122 | }, 123 | logLimit: { 124 | type: Boolean, 125 | description: 'Maximum message count or 0 to disable (default 10)', 126 | }, 127 | mainFields: { 128 | type: Boolean, 129 | description: 'Override the main file order in package.json (default "browser,module,main" when platform is browser and "main,module" when platform is node)', 130 | }, 131 | metafile: { 132 | type: Boolean, 133 | description: 'Write metadata about the build to a JSON file', 134 | }, 135 | minifyWhitespace: { 136 | type: Boolean, 137 | description: 'Remove whitespace in output files', 138 | }, 139 | minifyIdentifiers: { 140 | type: Boolean, 141 | description: 'Shorten identifiers in output files', 142 | }, 143 | minifySyntax: { 144 | type: Boolean, 145 | description: 'Use equivalent but shorter syntax in output files', 146 | }, 147 | outbase: { 148 | type: Boolean, 149 | description: 'The base path used to determine entry point output paths (for multiple entry points)', 150 | }, 151 | preserveSymlinks: { 152 | type: Boolean, 153 | description: 'Disable symlink resolution for module lookup', 154 | }, 155 | publicPath: { 156 | type: Boolean, 157 | description: 'Set the base URL for the "file" loader', 158 | }, 159 | resolveExtensions: { 160 | type: Boolean, 161 | description: 'A comma-separated list of implicit extensions (default ".tsx,.ts,.jsx,.js,.css,.json")', 162 | }, 163 | servedir: { 164 | type: Boolean, 165 | description: 'What to serve in addition to generated output files', 166 | }, 167 | sourceRoot: { 168 | type: Boolean, 169 | description: 'Sets the "sourceRoot" field in generated source maps', 170 | }, 171 | sourcefile: { 172 | type: Boolean, 173 | description: 'Set the source file for the source map (for stdin)', 174 | }, 175 | sourcesContent: { 176 | type: Boolean, 177 | description: 'Omit "sourcesContent" in generated source maps', 178 | }, 179 | treeShaking: { 180 | type: Boolean, 181 | description: 'Set to "ignore-annotations" to work with packages that have incorrect tree-shaking annotations', 182 | }, 183 | tsconfig: { 184 | type: Boolean, 185 | description: 'Use this tsconfig.json file instead of other ones', 186 | }, 187 | version: { 188 | type: Boolean, 189 | description: 'Print the current version (0.12.14) and exit', 190 | }, 191 | }; 192 | 193 | const app = cli({ 194 | 195 | name: 'esbuild', 196 | 197 | version: '1.0.0', 198 | 199 | parameters: ['[entry points]'], 200 | 201 | flags: { 202 | ...simpleFlags, 203 | ...advancedFlags, 204 | help: Boolean, 205 | }, 206 | 207 | help: { 208 | examples: [ 209 | '# Produces dist/entry_point.js and dist/entry_point.js.map', 210 | 'esbuild --bundle entry_point.js --outdir=dist --minify --sourcemap', 211 | '', 212 | '# Allow JSX syntax in .js files', 213 | 'esbuild --bundle entry_point.js --outfile=out.js --loader:.js=jsx', 214 | '', 215 | '# Substitute the identifier RELEASE for the literal true', 216 | 'esbuild example.js --outfile=out.js --define:RELEASE=true', 217 | '', 218 | '# Provide input via stdin, get output via stdout', 219 | 'esbuild --minify --loader=ts < input.ts > output.js', 220 | '', 221 | '# Automatically rebuild when input files are changed', 222 | 'esbuild app.ts --bundle --watch', 223 | '', 224 | '# Start a local HTTP server for everything in "www"', 225 | 'esbuild app.ts --bundle --servedir=www --outdir=www/js', 226 | ], 227 | 228 | render(nodes, renderers) { 229 | const [, usage, flags, examples] = nodes; 230 | 231 | // Replace "flags" with "options" 232 | usage.data.body = usage.data.body.replace('flags', 'options'); 233 | 234 | // Update renderer so flags that accept a value shows `=...` 235 | renderers.flagOperator = () => '='; 236 | renderers.flagParameter = flagType => (flagType === Boolean ? '' : '...'); 237 | 238 | const { tableData: flagsTableData } = flags.data.body.data; 239 | 240 | return renderers.render([ 241 | usage, 242 | 243 | // Add Documentation & Repository links 244 | { 245 | type: 'section', 246 | data: { 247 | title: 'Documentation:', 248 | body: underline('https://esbuild.github.io/'), 249 | }, 250 | }, 251 | { 252 | type: 'section', 253 | data: { 254 | title: 'Repository:', 255 | body: underline('https://github.com/evanw/esbuild'), 256 | }, 257 | }, 258 | 259 | // Split Flags into "Simple options" and "Advanced options" 260 | { 261 | type: 'section', 262 | data: { 263 | title: 'Simple options:', 264 | body: { 265 | type: 'table', 266 | data: { 267 | ...flags, 268 | tableData: flagsTableData.filter( 269 | ([flagName]: [{ data: { name: string } }]) => flagName.data.name in simpleFlags, 270 | ), 271 | }, 272 | }, 273 | indentBody: 0, 274 | }, 275 | }, 276 | { 277 | type: 'section', 278 | data: { 279 | title: 'Advanced options:', 280 | body: { 281 | type: 'table', 282 | data: { 283 | ...flags, 284 | tableData: flagsTableData.filter( 285 | ([flagName]: [{ data: { name: string } }]) => flagName.data.name in advancedFlags, 286 | ), 287 | }, 288 | }, 289 | indentBody: 0, 290 | }, 291 | }, 292 | flags, 293 | examples, 294 | ]); 295 | }, 296 | }, 297 | }); 298 | 299 | console.log(app); 300 | -------------------------------------------------------------------------------- /examples/tsc/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Demo showing how `tsc --help` can be re-implemented with cleye 3 | * 4 | * Usage: 5 | * npx esno examples/tsc --help 6 | */ 7 | 8 | import assert from 'assert'; 9 | import { cli } from '../../src'; 10 | 11 | // https://github.com/microsoft/TypeScript/blob/7a12909ae3f03b1feed19df2082aa84e5c7a5081/src/executeCommandLine/executeCommandLine.ts#L111 12 | const blue = (string_: string) => `\u001B[94m${string_}\u001B[39m`; 13 | 14 | const targetType = ['es3', 'es5', 'es6', 'es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'esnext']; 15 | 16 | const moduleTypes = ['none', 'commonjs', 'amd', 'system', 'umd', 'es6', 'es2015', 'es2020', 'esnext'] as const; 17 | 18 | const libraryTypes = ['es5', 'es6', 'es2015', 'es7', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'esnext', 'dom', 'dom.iterable', 'webworker', 'webworker.importscripts', 'webworker.iterable', 'scripthost', 'es2015.core', 'es2015.coll ection', 'es2015.generator', 'es2015.iterable', 'es2015.promise', 'es2015.proxy', 'es2015.reflect', 'es2015 .symbol', 'es2015.symbol.wellknown', 'es2016.array.include', 'es2017.object', 'es2017.sharedmemory', 'es2 017.string', 'es2017.intl', 'es2017.typedarrays', 'es2018.asyncgenerator', 'es2018.asynciterable', 'es201 8.intl', 'es2018.promise', 'es2018.regexp', 'es2019.array', 'es2019.object', 'es2019.string', 'es2019.symbo l', 'es2020.bigint', 'es2020.promise', 'es2020.sharedmemory', 'es2020.string', 'es2020.symbol.wellknown', 'es2020.intl', 'es2021.promise', 'es2021.string', 'es2021.weakref', 'esnext.array', 'esnext.symbol', 'esnext .asynciterable', 'esnext.intl', 'esnext.bigint', 'esnext.string', 'esnext.promise', 'esnext.weakref'] as const; 19 | 20 | const jsxTypes = [undefined, 'preserve', 'react-native', 'react', 'react-jsx', 'react-jsxdev'] as const; 21 | 22 | const commandLineFlags = { 23 | help: { 24 | type: Boolean, 25 | alias: 'h', 26 | description: 'Print this message.', 27 | }, 28 | watch: { 29 | type: Boolean, 30 | alias: 'w', 31 | description: 'Watch input files.', 32 | }, 33 | all: { 34 | type: Boolean, 35 | description: 'Show all compiler options.', 36 | }, 37 | version: { 38 | type: Boolean, 39 | alias: 'v', 40 | description: 'Print the compiler\'s version.', 41 | }, 42 | init: { 43 | type: Boolean, 44 | description: 'Initializes a TypeScript project and creates a tsconfig.json file.', 45 | }, 46 | project: { 47 | type: Boolean, 48 | alias: 'p', 49 | description: 'Compile the project given the path to its configuration file, or to a folder with a \'tsconfig.json\'.', 50 | }, 51 | build: { 52 | type: Boolean, 53 | alias: 'b', 54 | description: 'Build one or more projects and their dependencies, if out of date', 55 | }, 56 | showConfig: { 57 | type: Boolean, 58 | description: 'Print the final configuration instead of building.', 59 | }, 60 | }; 61 | 62 | const commonCompilerOptions = { 63 | pretty: { 64 | type: Boolean, 65 | description: 'Enable color and formatting in TypeScript\'s output to make compiler errors easier to read', 66 | default: true, 67 | }, 68 | 69 | target: { 70 | type: (value: typeof targetType[number]) => { 71 | assert.ok(targetType.includes(value), 'Invalid target type'); 72 | return value; 73 | }, 74 | alias: 't', 75 | description: `Set the JavaScript language version for emitted JavaScript and include compatible library declarations.\none of: ${targetType.join(', ')}`, 76 | default: 'ES3', 77 | }, 78 | 79 | module: { 80 | type: (value: typeof moduleTypes[number]) => { 81 | assert.ok(moduleTypes.includes(value), 'Invalid module type'); 82 | return value; 83 | }, 84 | alias: 'm', 85 | description: `Specify what module code is generated.\none of: ${moduleTypes.join(', ')}`, 86 | }, 87 | 88 | lib: { 89 | type: [ 90 | (value: typeof libraryTypes[number]) => { 91 | assert.ok(libraryTypes.includes(value), 'Invalid library type'); 92 | return value; 93 | }, 94 | ] as const, 95 | description: `Specify a set of bundled library declaration files that describe the target runtime environment.\none or more: ${libraryTypes.join(', ')}`, 96 | }, 97 | 98 | allowJs: { 99 | type: Boolean, 100 | description: 'Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files.', 101 | 102 | }, 103 | 104 | checkJs: { 105 | type: Boolean, 106 | description: 'Enable error reporting in type-checked JavaScript files.', 107 | }, 108 | 109 | jsx: { 110 | type: (value: typeof jsxTypes[number]) => { 111 | assert.ok(jsxTypes.includes(value), 'Invalid jsx type'); 112 | return value; 113 | }, 114 | description: `Specify what JSX code is generated.\none of: ${jsxTypes.join(', ')}`, 115 | }, 116 | 117 | declaration: { 118 | type: Boolean, 119 | alias: 'd', 120 | description: 'Generate .d.ts files from TypeScript and JavaScript files in your project.', 121 | }, 122 | 123 | declarationMap: { 124 | type: Boolean, 125 | description: 'Create sourcemaps for d.ts files.', 126 | }, 127 | 128 | emitDeclarationOnly: { 129 | type: Boolean, 130 | description: 'Only output d.ts files and not JavaScript files.', 131 | }, 132 | 133 | sourceMap: { 134 | type: Boolean, 135 | description: 'Create source map files for emitted JavaScript files.', 136 | }, 137 | 138 | outFile: { 139 | type: String, 140 | description: 'Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output.', 141 | }, 142 | 143 | outDir: { 144 | type: String, 145 | description: 'Specify an output folder for all emitted files.', 146 | }, 147 | 148 | removeComments: { 149 | type: Boolean, 150 | description: 'Disable emitting comments.', 151 | }, 152 | 153 | noEmit: { 154 | type: Boolean, 155 | description: 'Disable emitting files from a compilation.', 156 | }, 157 | 158 | strict: { 159 | type: Boolean, 160 | description: 'Enable all strict type-checking options.', 161 | }, 162 | 163 | types: { 164 | type: String, 165 | description: 'Specify type package names to be included without being referenced in a source file.', 166 | }, 167 | 168 | esModuleInterop: { 169 | type: Boolean, 170 | description: 'Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility.', 171 | }, 172 | }; 173 | 174 | const tableBreakpoints = { 175 | '> 80': [ 176 | { 177 | align: 'right', 178 | width: 'content-width', 179 | paddingLeft: 2, 180 | paddingRight: 2, 181 | paddingBottom: 1, 182 | }, 183 | { 184 | paddingBottom: 1, 185 | }, 186 | ], 187 | '> 0': [ 188 | { 189 | width: '100%', 190 | paddingLeft: 2, 191 | paddingRight: 2, 192 | }, 193 | { 194 | width: '100%', 195 | paddingLeft: 2, 196 | paddingRight: 2, 197 | paddingBottom: 1, 198 | }, 199 | ], 200 | }; 201 | 202 | const argv = cli({ 203 | flags: { 204 | ...commandLineFlags, 205 | ...commonCompilerOptions, 206 | }, 207 | 208 | help: { 209 | usage: false, 210 | examples: [ 211 | blue('tsc'), 212 | 'Compiles the current project (tsconfig.json in the working directory.)', 213 | 214 | '', 215 | 216 | blue('tsc app.ts util.ts'), 217 | 'Ignoring tsconfig.json, compiles the specified files with default compiler options.', 218 | 219 | '', 220 | 221 | blue('tsc -b'), 222 | 'Build a composite project in the working directory.', 223 | 224 | '', 225 | 226 | blue('tsc --init'), 227 | 'Creates a tsconfig.json with the recommended settings in the working directory.', 228 | 229 | '', 230 | 231 | blue('tsc -p ./path/to/tsconfig.json'), 232 | 'Compiles the TypeScript project located at the specified path.', 233 | 234 | '', 235 | 236 | blue('tsc --help --all'), 237 | 'An expanded version of this information, showing all possible compiler options', 238 | 239 | '', 240 | 241 | blue('tsc --noEmit'), 242 | blue('tsc --target esnext'), 243 | 'Compiles the current project, with additional settings.', 244 | ], 245 | 246 | render(nodes, renderers) { 247 | const [flags, commonCommands] = nodes; 248 | 249 | commonCommands.data.title = 'COMMON COMMANDS'; 250 | commonCommands.data.body = `\n${commonCommands.data.body}`; 251 | nodes.splice(1, 0, commonCommands); 252 | 253 | const { tableData: flagsTableData } = flags.data.body.data; 254 | 255 | renderers.flagName = function (flag) { 256 | const flagName = `--${flag.name}`; 257 | return blue(flagName + (flag.aliasFormatted ? `, ${flag.aliasFormatted}` : '')); 258 | }; 259 | 260 | return renderers.render([ 261 | 'tsc: The TypeScript Compiler - Version 0.0.0\n', 262 | commonCommands, 263 | { 264 | type: 'section', 265 | data: { 266 | title: 'COMMAND LINE FLAGS\n', 267 | body: { 268 | type: 'table', 269 | data: { 270 | tableBreakpoints, 271 | tableData: flagsTableData.filter( 272 | ( 273 | [flagName]: [{ data: { name: string } }], 274 | ) => flagName.data.name in commandLineFlags, 275 | ), 276 | }, 277 | }, 278 | indentBody: 0, 279 | }, 280 | }, 281 | { 282 | type: 'section', 283 | data: { 284 | title: 'COMMON COMPILER OPTIONS\n', 285 | body: { 286 | type: 'table', 287 | data: { 288 | tableBreakpoints, 289 | tableData: flagsTableData.filter( 290 | ( 291 | [flagName]: [{ data: { name: string } }], 292 | ) => flagName.data.name in commonCompilerOptions, 293 | ), 294 | }, 295 | }, 296 | indentBody: 0, 297 | }, 298 | }, 299 | 'You can learn about all of the compiler options at https://aka.ms/tsconfig-reference', 300 | ]); 301 | }, 302 | }, 303 | }); 304 | 305 | console.log(argv); 306 | -------------------------------------------------------------------------------- /tests/specs/arguments.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { spy } from 'nanospy'; 3 | import { mockEnvFunctions } from '../utils/mock-env-functions'; 4 | import { cli, command } from '#cleye'; 5 | 6 | export default testSuite(({ describe }) => { 7 | describe('arguments', ({ describe }) => { 8 | describe('error handling', ({ describe }) => { 9 | describe('parameters', ({ test }) => { 10 | test('invalid parameter format', () => { 11 | expect(() => { 12 | cli({ 13 | parameters: ['value-a'], 14 | }); 15 | }).toThrow('Invalid parameter: "value-a". Must be wrapped in <> (required parameter) or [] (optional parameter)'); 16 | }); 17 | 18 | test('invalid parameter character', () => { 19 | expect(() => { 20 | cli({ 21 | parameters: ['[value.a]'], 22 | }); 23 | }).toThrow('Invalid parameter: "[value.a]". Invalid character found "."'); 24 | }); 25 | 26 | test('invalid parameter - all special characters', () => { 27 | // Pattern from cli.ts: /[|\\{}()[\]^$+*?.]/ 28 | const specialChars = [ 29 | '|', 30 | '\\', 31 | '{', 32 | '}', 33 | '(', 34 | ')', 35 | // '[' and ']' are bracket chars used for optional params 36 | // so we test them inside the name portion 37 | '^', 38 | '$', 39 | '+', 40 | '*', 41 | '?', 42 | '.', 43 | ]; 44 | 45 | for (const char of specialChars) { 46 | expect(() => { 47 | cli({ 48 | parameters: [``], 49 | }); 50 | }).toThrow('Invalid character found'); 51 | } 52 | }); 53 | 54 | test('duplicate parameters', () => { 55 | expect(() => { 56 | cli({ 57 | parameters: ['[value-a]', '[value-a]', '[value-a]'], 58 | }); 59 | }).toThrow('Invalid parameter: "value-a" is used more than once'); 60 | }); 61 | 62 | test('duplicate parameters across --', () => { 63 | expect(() => { 64 | cli({ 65 | parameters: ['[value-a]', '--', '[value-a]'], 66 | }); 67 | }).toThrow('Invalid parameter: "value-a" is used more than once'); 68 | }); 69 | 70 | test('multiple --', () => { 71 | expect(() => { 72 | cli({ 73 | parameters: ['[value-a]', '--', '[value-b]', '--', '[value-c]'], 74 | }); 75 | }).toThrow('Invalid parameter: "--". Must be wrapped in <> (required parameter) or [] (optional parameter)'); 76 | }); 77 | 78 | test('optional parameter before required parameter', () => { 79 | expect(() => { 80 | cli({ 81 | parameters: ['[value-a]', ''], 82 | }); 83 | }).toThrow('Invalid parameter: Required parameter "" cannot come after optional parameter "[value-a]"'); 84 | }); 85 | 86 | test('multiple spread not last', () => { 87 | expect(() => { 88 | cli({ 89 | parameters: ['[value-a...]', ''], 90 | }); 91 | }).toThrow('Invalid parameter: Spread parameter "[value-a...]" must be last'); 92 | }); 93 | 94 | test('multiple spread parameters', () => { 95 | expect(() => { 96 | cli({ 97 | parameters: ['[value-a...]', ''], 98 | }); 99 | }).toThrow('Invalid parameter: Spread parameter "[value-a...]" must be last'); 100 | }); 101 | }); 102 | 103 | describe('arguments', ({ test }) => { 104 | test('missing parameter', () => { 105 | const mocked = mockEnvFunctions(); 106 | cli( 107 | { 108 | parameters: [''], 109 | }, 110 | undefined, 111 | [], 112 | ); 113 | mocked.restore(); 114 | 115 | expect(mocked.consoleLog.called).toBe(true); 116 | expect(mocked.consoleError.calls).toStrictEqual([['Error: Missing required parameter "value-a"\n']]); 117 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 118 | }); 119 | 120 | test('missing spread parameter', () => { 121 | const mocked = mockEnvFunctions(); 122 | cli( 123 | { 124 | parameters: [''], 125 | }, 126 | undefined, 127 | [], 128 | ); 129 | mocked.restore(); 130 | 131 | expect(mocked.consoleLog.called).toBe(true); 132 | expect(mocked.consoleError.calls).toStrictEqual([['Error: Missing required parameter "value-a"\n']]); 133 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 134 | }); 135 | 136 | test('missing -- parameter', () => { 137 | const mocked = mockEnvFunctions(); 138 | cli( 139 | { 140 | parameters: ['--', ''], 141 | }, 142 | undefined, 143 | [], 144 | ); 145 | mocked.restore(); 146 | 147 | expect(mocked.consoleLog.called).toBe(true); 148 | expect(mocked.consoleError.calls).toStrictEqual([['Error: Missing required parameter "value-a"\n']]); 149 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('parses arguments', ({ test }) => { 155 | test('simple parsing', () => { 156 | const callback = spy(); 157 | const parsed = cli( 158 | { 159 | parameters: ['', '[value-B]', '[value c]', '[value_d]', '[value=e]', '[value/f]'], 160 | }, 161 | (callbackParsed) => { 162 | expect(callbackParsed._.valueA).toBe('valueA'); 163 | expect(callbackParsed._.valueB).toBe('valueB'); 164 | expect(callbackParsed._.valueC).toBe('valueC'); 165 | expect(callbackParsed._.valueD).toBe('valueD'); 166 | expect(callbackParsed._.valueE).toBe('valueE'); 167 | expect(callbackParsed._.valueF).toBe('valueF'); 168 | callback(); 169 | }, 170 | ['valueA', 'valueB', 'valueC', 'valueD', 'valueE', 'valueF'], 171 | ); 172 | 173 | expect(parsed._.valueA).toBe('valueA'); 174 | expect(parsed._.valueB).toBe('valueB'); 175 | expect(parsed._.valueC).toBe('valueC'); 176 | expect(callback.called).toBe(true); 177 | }); 178 | 179 | test('simple parsing across --', () => { 180 | const callback = spy(); 181 | const parsed = cli( 182 | { 183 | parameters: ['', '[value-b]', '[value c]', '--', '', '[value-e]', '[value f]'], 184 | }, 185 | (callbackParsed) => { 186 | expect(callbackParsed._.valueA).toBe('valueA'); 187 | expect(callbackParsed._.valueB).toBe('valueB'); 188 | expect(callbackParsed._.valueD).toBe('valueD'); 189 | callback(); 190 | }, 191 | ['valueA', 'valueB', '--', 'valueD'], 192 | ); 193 | 194 | expect(parsed._.valueA).toBe('valueA'); 195 | expect(parsed._.valueB).toBe('valueB'); 196 | expect(parsed._.valueD).toBe('valueD'); 197 | expect(callback.called).toBe(true); 198 | }); 199 | 200 | test('simple parsing with empty --', () => { 201 | const callback = spy(); 202 | const parsed = cli( 203 | { 204 | parameters: ['', '[value-b]', '[value c]', '--', '[value-d]'], 205 | }, 206 | (callbackParsed) => { 207 | expect(callbackParsed._.valueA).toBe('valueA'); 208 | expect(callbackParsed._.valueB).toBe('valueB'); 209 | callback(); 210 | }, 211 | ['valueA', 'valueB'], 212 | ); 213 | 214 | expect(parsed._.valueA).toBe('valueA'); 215 | expect(parsed._.valueB).toBe('valueB'); 216 | expect(callback.called).toBe(true); 217 | }); 218 | 219 | test('spread', () => { 220 | const callback = spy(); 221 | const parsed = cli( 222 | { 223 | parameters: [''], 224 | }, 225 | (callbackParsed) => { 226 | expect(callbackParsed._.valueA).toStrictEqual(['valueA', 'valueB']); 227 | callback(); 228 | }, 229 | ['valueA', 'valueB'], 230 | ); 231 | 232 | expect(parsed._.valueA).toStrictEqual(['valueA', 'valueB']); 233 | expect(callback.called).toBe(true); 234 | }); 235 | 236 | test('spread with --', () => { 237 | const callback = spy(); 238 | const parsed = cli( 239 | { 240 | parameters: ['', '--', ''], 241 | }, 242 | (callbackParsed) => { 243 | expect(callbackParsed._.valueA).toStrictEqual(['valueA', 'valueB']); 244 | expect(callbackParsed._.valueB).toStrictEqual(['valueC', 'valueD']); 245 | callback(); 246 | }, 247 | ['valueA', 'valueB', '--', 'valueC', 'valueD'], 248 | ); 249 | 250 | expect(parsed._.valueA).toStrictEqual(['valueA', 'valueB']); 251 | expect(parsed._.valueB).toStrictEqual(['valueC', 'valueD']); 252 | expect(callback.called).toBe(true); 253 | }); 254 | 255 | test('command', () => { 256 | const callback = spy(); 257 | 258 | const testCommand = command({ 259 | name: 'test', 260 | parameters: [''], 261 | }, (callbackParsed) => { 262 | expect(callbackParsed._.argA).toStrictEqual(['valueA', 'valueB']); 263 | callback(); 264 | }); 265 | 266 | const parsed = cli( 267 | { 268 | parameters: [''], 269 | 270 | commands: [ 271 | testCommand, 272 | ], 273 | }, 274 | undefined, 275 | ['test', 'valueA', 'valueB'], 276 | ); 277 | 278 | if (parsed.command === 'test') { 279 | expect(parsed._.argA).toStrictEqual(['valueA', 'valueB']); 280 | } 281 | expect(callback.called).toBe(true); 282 | }); 283 | }); 284 | 285 | describe('EOF edge cases', ({ test }) => { 286 | test('EOF at beginning of parameters', () => { 287 | const parsed = cli( 288 | { 289 | parameters: ['--', ''], 290 | }, 291 | undefined, 292 | ['--', 'test'], 293 | ); 294 | 295 | expect(parsed._.value).toBe('test'); 296 | }); 297 | 298 | test('empty EOF section', () => { 299 | const parsed = cli( 300 | { 301 | parameters: ['', '--', '[optional]'], 302 | }, 303 | undefined, 304 | ['value', '--'], 305 | ); 306 | 307 | expect(parsed._.arg).toBe('value'); 308 | expect(parsed._.optional).toBeUndefined(); 309 | }); 310 | 311 | test('EOF parameters are always set as properties', () => { 312 | const parsed = cli( 313 | { 314 | parameters: ['', '--', '[optional]'], 315 | }, 316 | undefined, 317 | ['value', '--'], 318 | ); 319 | 320 | // EOF parameters should always be set on the object, 321 | // even when no EOF arguments are provided 322 | expect('optional' in parsed._).toBe(true); 323 | expect(Object.keys(parsed._)).toContain('optional'); 324 | }); 325 | }); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /tests/specs/edge-cases.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { camelCase, kebabCase } from '../../src/utils/convert-case.js'; 3 | import { isValidScriptName } from '../../src/utils/script-name.js'; 4 | import { cli, command } from '#cleye'; 5 | 6 | export default testSuite(({ describe }) => { 7 | describe('edge cases', ({ describe }) => { 8 | describe('camelCase conversion', ({ test }) => { 9 | test('already camelCase input', () => { 10 | const parsed = cli( 11 | { 12 | parameters: [''], 13 | }, 14 | undefined, 15 | ['test'], 16 | ); 17 | 18 | expect(parsed._.myValue).toBe('test'); 19 | }); 20 | 21 | test('multiple consecutive separators', () => { 22 | const parsed = cli( 23 | { 24 | parameters: [''], 25 | }, 26 | undefined, 27 | ['test'], 28 | ); 29 | 30 | expect(parsed._.valueName).toBe('test'); 31 | }); 32 | 33 | test('mixed separators', () => { 34 | const parsed = cli( 35 | { 36 | parameters: [''], 37 | }, 38 | undefined, 39 | ['test'], 40 | ); 41 | 42 | expect(parsed._.valueNameHere).toBe('test'); 43 | }); 44 | 45 | test('leading separator', () => { 46 | const parsed = cli( 47 | { 48 | parameters: ['<-value>'], 49 | }, 50 | undefined, 51 | ['test'], 52 | ); 53 | 54 | // Leading separator causes first letter to be capitalized 55 | expect(parsed._.Value).toBe('test'); 56 | }); 57 | 58 | test('trailing separator', () => { 59 | const parsed = cli( 60 | { 61 | parameters: [''], 62 | }, 63 | undefined, 64 | ['test'], 65 | ); 66 | 67 | expect(parsed._.value).toBe('test'); 68 | }); 69 | 70 | test('numbers in parameter name', () => { 71 | const parsed = cli( 72 | { 73 | parameters: [''], 74 | }, 75 | undefined, 76 | ['test'], 77 | ); 78 | 79 | expect(parsed._.value1).toBe('test'); 80 | }); 81 | 82 | test('leading number in parameter name', () => { 83 | const parsed = cli( 84 | { 85 | parameters: ['<1value>'], 86 | }, 87 | undefined, 88 | ['test'], 89 | ); 90 | 91 | expect(parsed._['1value']).toBe('test'); 92 | }); 93 | 94 | test('camelCase utility directly', () => { 95 | expect(camelCase('hello world')).toBe('helloWorld'); 96 | expect(camelCase('hello-world')).toBe('helloWorld'); 97 | expect(camelCase('hello_world')).toBe('helloWorld'); 98 | expect(camelCase('hello--world')).toBe('helloWorld'); 99 | expect(camelCase('hello__world')).toBe('helloWorld'); 100 | // Leading separator causes first letter to be capitalized 101 | expect(camelCase('-hello')).toBe('Hello'); 102 | expect(camelCase('hello-')).toBe('hello'); 103 | expect(camelCase('myValue')).toBe('myValue'); 104 | expect(camelCase('value1')).toBe('value1'); 105 | expect(camelCase('1value')).toBe('1value'); 106 | }); 107 | }); 108 | 109 | describe('kebabCase conversion', ({ test }) => { 110 | test('basic camelCase to kebab-case', () => { 111 | expect(kebabCase('helloWorld')).toBe('hello-world'); 112 | expect(kebabCase('myValue')).toBe('my-value'); 113 | expect(kebabCase('getValue')).toBe('get-value'); 114 | }); 115 | 116 | test('multiple uppercase letters', () => { 117 | expect(kebabCase('getHTTPResponse')).toBe('get-h-t-t-p-response'); 118 | expect(kebabCase('XMLParser')).toBe('x-m-l-parser'); 119 | }); 120 | 121 | test('already kebab-case input', () => { 122 | expect(kebabCase('hello-world')).toBe('hello-world'); 123 | expect(kebabCase('my-value')).toBe('my-value'); 124 | }); 125 | 126 | test('single character', () => { 127 | expect(kebabCase('a')).toBe('a'); 128 | expect(kebabCase('A')).toBe('a'); 129 | }); 130 | 131 | test('all lowercase', () => { 132 | expect(kebabCase('helloworld')).toBe('helloworld'); 133 | }); 134 | 135 | test('all uppercase', () => { 136 | expect(kebabCase('ABC')).toBe('a-b-c'); 137 | }); 138 | 139 | test('empty string', () => { 140 | expect(kebabCase('')).toBe(''); 141 | }); 142 | 143 | test('leading uppercase', () => { 144 | expect(kebabCase('HelloWorld')).toBe('hello-world'); 145 | }); 146 | 147 | test('numbers in name', () => { 148 | expect(kebabCase('value1Name')).toBe('value1-name'); 149 | expect(kebabCase('get2ndValue')).toBe('get2nd-value'); 150 | }); 151 | }); 152 | 153 | describe('parameter validation edge cases', ({ test }) => { 154 | // Skipping: test('parameter with only brackets' - causes test suite to fail 155 | // The empty parameter name causes the parser to throw but also print help 156 | 157 | test('parameter with special characters', () => { 158 | const parsed = cli( 159 | { 160 | parameters: [''], 161 | }, 162 | undefined, 163 | ['test.txt'], 164 | ); 165 | 166 | expect(parsed._.filePath).toBe('test.txt'); 167 | }); 168 | 169 | test('very long parameter name', () => { 170 | const longName = ''; 171 | const parsed = cli( 172 | { 173 | parameters: [longName], 174 | }, 175 | undefined, 176 | ['value'], 177 | ); 178 | 179 | expect(parsed._.thisIsAVeryLongParameterNameWithManyWords).toBe('value'); 180 | }); 181 | 182 | test('parameter with uppercase letters', () => { 183 | const parsed = cli( 184 | { 185 | parameters: [''], 186 | }, 187 | undefined, 188 | ['test.txt'], 189 | ); 190 | 191 | // camelCase doesn't change case without separators 192 | expect(parsed._.FileNAME).toBe('test.txt'); 193 | }); 194 | 195 | // Skipped: empty string parameter value triggers validation error and help output 196 | // which cannot be easily tested with expect().toThrow() 197 | 198 | test('whitespace-only parameter value', () => { 199 | const parsed = cli( 200 | { 201 | parameters: [''], 202 | }, 203 | undefined, 204 | [' '], 205 | ); 206 | 207 | expect(parsed._.value).toBe(' '); 208 | }); 209 | }); 210 | 211 | describe('flag value edge cases', ({ test }) => { 212 | test('flag with empty string value', () => { 213 | const parsed = cli( 214 | { 215 | flags: { 216 | value: String, 217 | }, 218 | }, 219 | undefined, 220 | ['--value='], 221 | ); 222 | 223 | expect(parsed.flags.value).toBe(''); 224 | }); 225 | 226 | test('flag with whitespace value', () => { 227 | const parsed = cli( 228 | { 229 | flags: { 230 | value: String, 231 | }, 232 | }, 233 | undefined, 234 | ['--value', ' '], 235 | ); 236 | 237 | expect(parsed.flags.value).toBe(' '); 238 | }); 239 | 240 | test('number flag with zero', () => { 241 | const parsed = cli( 242 | { 243 | flags: { 244 | value: Number, 245 | }, 246 | }, 247 | undefined, 248 | ['--value', '0'], 249 | ); 250 | 251 | expect(parsed.flags.value).toBe(0); 252 | }); 253 | 254 | test('number flag with negative', () => { 255 | const parsed = cli( 256 | { 257 | flags: { 258 | value: Number, 259 | }, 260 | }, 261 | undefined, 262 | ['--value=-42'], 263 | ); 264 | 265 | expect(parsed.flags.value).toBe(-42); 266 | }); 267 | 268 | test('number flag with decimal', () => { 269 | const parsed = cli( 270 | { 271 | flags: { 272 | value: Number, 273 | }, 274 | }, 275 | undefined, 276 | ['--value', '3.14'], 277 | ); 278 | 279 | expect(parsed.flags.value).toBe(3.14); 280 | }); 281 | }); 282 | 283 | describe('command name edge cases', ({ test }) => { 284 | test('command name with numbers', () => { 285 | const cmd1 = command({ 286 | name: 'cmd1', 287 | }); 288 | 289 | expect(() => { 290 | cli( 291 | { 292 | commands: [cmd1], 293 | }, 294 | undefined, 295 | ['cmd1'], 296 | ); 297 | }).not.toThrow(); 298 | }); 299 | 300 | test('command name with dash', () => { 301 | const myCommand = command({ 302 | name: 'my-command', 303 | }); 304 | 305 | expect(() => { 306 | cli( 307 | { 308 | commands: [myCommand], 309 | }, 310 | undefined, 311 | ['my-command'], 312 | ); 313 | }).not.toThrow(); 314 | }); 315 | 316 | test('command name with underscore', () => { 317 | const myCommand = command({ 318 | name: 'my_command', 319 | }); 320 | 321 | expect(() => { 322 | cli( 323 | { 324 | commands: [myCommand], 325 | }, 326 | undefined, 327 | ['my_command'], 328 | ); 329 | }).not.toThrow(); 330 | }); 331 | }); 332 | 333 | describe('isValidScriptName edge cases', ({ test }) => { 334 | test('empty string is invalid', () => { 335 | expect(isValidScriptName('')).toBe(false); 336 | }); 337 | 338 | test('single space is invalid', () => { 339 | expect(isValidScriptName(' ')).toBe(false); 340 | }); 341 | 342 | test('multiple spaces is invalid', () => { 343 | expect(isValidScriptName(' ')).toBe(false); 344 | }); 345 | 346 | test('name with space is invalid', () => { 347 | expect(isValidScriptName('my command')).toBe(false); 348 | }); 349 | 350 | test('tab character is valid (not a space)', () => { 351 | // The current implementation only checks for space character 352 | expect(isValidScriptName('my\tcommand')).toBe(true); 353 | }); 354 | 355 | test('newline character is valid (not a space)', () => { 356 | // The current implementation only checks for space character 357 | expect(isValidScriptName('my\ncommand')).toBe(true); 358 | }); 359 | 360 | test('leading space is invalid', () => { 361 | expect(isValidScriptName(' command')).toBe(false); 362 | }); 363 | 364 | test('trailing space is invalid', () => { 365 | expect(isValidScriptName('command ')).toBe(false); 366 | }); 367 | 368 | test('valid names', () => { 369 | expect(isValidScriptName('command')).toBe(true); 370 | expect(isValidScriptName('my-command')).toBe(true); 371 | expect(isValidScriptName('my_command')).toBe(true); 372 | expect(isValidScriptName('cmd123')).toBe(true); 373 | expect(isValidScriptName('a')).toBe(true); 374 | }); 375 | 376 | test('unicode characters are valid', () => { 377 | expect(isValidScriptName('命令')).toBe(true); 378 | expect(isValidScriptName('café')).toBe(true); 379 | }); 380 | 381 | test('special characters are valid', () => { 382 | expect(isValidScriptName('@scope/package')).toBe(true); 383 | expect(isValidScriptName('name.ext')).toBe(true); 384 | }); 385 | }); 386 | }); 387 | }); 388 | -------------------------------------------------------------------------------- /tests/specs/command.ts: -------------------------------------------------------------------------------- 1 | import { setImmediate } from 'node:timers/promises'; 2 | import { testSuite, expect } from 'manten'; 3 | import { spy } from 'nanospy'; 4 | import { mockEnvFunctions } from '../utils/mock-env-functions'; 5 | import { cli, command } from '#cleye'; 6 | 7 | export default testSuite(({ describe }) => { 8 | describe('command', ({ describe }) => { 9 | describe('error handling', ({ test }) => { 10 | test('missing options', () => { 11 | expect(() => { 12 | // @ts-expect-error no options 13 | command(); 14 | }).toThrow('Command options are required'); 15 | }); 16 | 17 | test('missing command name', () => { 18 | expect(() => { 19 | // @ts-expect-error no name 20 | command({}); 21 | }).toThrow('Command name is required'); 22 | }); 23 | 24 | test('empty command name', () => { 25 | expect(() => { 26 | command({ 27 | name: '', 28 | }); 29 | }).toThrow('Invalid command name ""'); 30 | }); 31 | 32 | test('invalid command name', () => { 33 | expect(() => { 34 | command({ 35 | name: 'a b c', 36 | }); 37 | }).toThrow('Invalid command name "a b c". Command names must be one word.'); 38 | }); 39 | 40 | test('duplicate command name', () => { 41 | expect(() => { 42 | cli( 43 | { 44 | commands: [ 45 | command({ 46 | name: 'duplicate', 47 | }), 48 | command({ 49 | name: 'duplicate', 50 | }), 51 | ], 52 | }, 53 | undefined, 54 | ['commandA', '--flagA', 'valueA'], 55 | ); 56 | }).toThrow('Duplicate command name found: "duplicate"'); 57 | }); 58 | 59 | test('duplicate command alias', () => { 60 | expect(() => { 61 | cli( 62 | { 63 | commands: [ 64 | command({ 65 | name: 'duplicate', 66 | }), 67 | command({ 68 | name: 'command', 69 | alias: 'duplicate', 70 | }), 71 | ], 72 | }, 73 | undefined, 74 | ['commandA', '--flagA', 'valueA'], 75 | ); 76 | }).toThrow('Duplicate command name found: "duplicate"'); 77 | }); 78 | 79 | test('empty alias string is ignored', () => { 80 | const callback = spy(); 81 | const commandA = command({ 82 | name: 'commandA', 83 | alias: '', 84 | }, callback); 85 | 86 | const parsed = cli( 87 | { 88 | commands: [commandA], 89 | }, 90 | undefined, 91 | ['commandA'], 92 | ); 93 | 94 | expect(parsed.command).toBe('commandA'); 95 | expect(callback.called).toBe(true); 96 | }); 97 | 98 | test('empty alias array is ignored', () => { 99 | const callback = spy(); 100 | const commandA = command({ 101 | name: 'commandA', 102 | alias: [], 103 | }, callback); 104 | 105 | const parsed = cli( 106 | { 107 | commands: [commandA], 108 | }, 109 | undefined, 110 | ['commandA'], 111 | ); 112 | 113 | expect(parsed.command).toBe('commandA'); 114 | expect(callback.called).toBe(true); 115 | }); 116 | }); 117 | 118 | describe('command', ({ test }) => { 119 | test('invoking command', () => { 120 | const callback = spy(); 121 | 122 | const commandA = command({ 123 | name: 'commandA', 124 | flags: { 125 | flagA: String, 126 | }, 127 | }, (parsed) => { 128 | expect(parsed.flags.flagA); 129 | expect(parsed.flags.help); 130 | callback(); 131 | }); 132 | 133 | const parsed = cli( 134 | { 135 | commands: [ 136 | commandA, 137 | ], 138 | }, 139 | undefined, 140 | ['commandA', '--flagA', 'valueA'], 141 | ); 142 | 143 | expect(parsed.command).toBe('commandA'); 144 | 145 | // Narrow type 146 | if (parsed.command === 'commandA') { 147 | expect(parsed.flags.flagA).toBe('valueA'); 148 | 149 | // @ts-expect-error non exixtent property 150 | expect(parsed.flags.flagC); 151 | } 152 | 153 | expect(callback.called).toBe(true); 154 | }); 155 | 156 | test('invoking command via alias', () => { 157 | const callback = spy(); 158 | 159 | const commandA = command({ 160 | name: 'commandA', 161 | 162 | alias: 'a', 163 | 164 | flags: { 165 | flagA: String, 166 | }, 167 | }, (parsed) => { 168 | expect(parsed.flags.flagA); 169 | expect(parsed.flags.help); 170 | callback(); 171 | }); 172 | 173 | const parsed = cli( 174 | { 175 | commands: [ 176 | commandA, 177 | ], 178 | }, 179 | undefined, 180 | ['a', '--flagA', 'valueA'], 181 | ); 182 | 183 | expect(parsed.command).toBe('commandA'); 184 | 185 | // Narrow type 186 | if (parsed.command === 'commandA') { 187 | expect(parsed.flags.flagA).toBe('valueA'); 188 | 189 | // @ts-expect-error non exixtent property 190 | expect(parsed.flags.flagC); 191 | } 192 | 193 | expect(callback.called).toBe(true); 194 | }); 195 | 196 | test('invoking command via alias array', () => { 197 | const callback = spy(); 198 | 199 | const commandA = command({ 200 | name: 'commandA', 201 | 202 | alias: ['a', 'b'], 203 | 204 | flags: { 205 | flagA: String, 206 | }, 207 | }, (parsed) => { 208 | expect(parsed.flags.flagA); 209 | expect(parsed.flags.help); 210 | callback(); 211 | }); 212 | 213 | const parsed = cli( 214 | { 215 | commands: [ 216 | commandA, 217 | ], 218 | }, 219 | undefined, 220 | ['b', '--flagA', 'valueA'], 221 | ); 222 | 223 | expect(parsed.command).toBe('commandA'); 224 | 225 | // Narrow type 226 | if (parsed.command === 'commandA') { 227 | expect(parsed.flags.flagA).toBe('valueA'); 228 | 229 | // @ts-expect-error non exixtent property 230 | expect(parsed.flags.flagC); 231 | } 232 | 233 | expect(callback.called).toBe(true); 234 | }); 235 | 236 | test('smoke', () => { 237 | const callback = spy(); 238 | 239 | const commandA = command({ 240 | name: 'commandA', 241 | flags: { 242 | flagA: String, 243 | }, 244 | }, (parsed) => { 245 | expect(parsed.flags.flagA); 246 | expect(parsed.flags.help); 247 | }); 248 | 249 | const commandB = command({ 250 | name: 'commandB', 251 | version: '1.0.0', 252 | parameters: ['', '[cmd-B]'], 253 | flags: { 254 | flagB: { 255 | type: String, 256 | default: 'true', 257 | description: 'flagB description', 258 | }, 259 | }, 260 | }, (parsed) => { 261 | expect(parsed.flags.flagB); 262 | expect(parsed.flags.help); 263 | }); 264 | 265 | const argv = cli( 266 | { 267 | version: '1.0.0', 268 | 269 | parameters: ['[parsed-A]', '[valueB]'], 270 | 271 | flags: { 272 | flagC: Number, 273 | }, 274 | 275 | commands: [ 276 | commandA, 277 | commandB, 278 | ], 279 | }, 280 | (parsed) => { 281 | expect(parsed.flags.version); 282 | expect(parsed.flags.help); 283 | expect(parsed.flags.flagC); 284 | callback(); 285 | }, 286 | ['--flagA', 'valueA', '--flagB', '123'], 287 | ); 288 | 289 | if (argv.command === undefined) { 290 | expect(argv.flags.flagC); 291 | expect(argv._.parsedA); 292 | expect(argv._.valueB); 293 | } 294 | 295 | if (argv.command === 'commandA') { 296 | expect(argv.flags.flagA); 297 | 298 | // @ts-expect-error non exixtent property 299 | expect(argv.flags.flagC); 300 | } 301 | 302 | if (argv.command === 'commandB') { 303 | expect(argv.flags.flagB); 304 | expect(argv.flags.version); 305 | expect(argv._.cmdA); 306 | expect(argv._.cmdB); 307 | } 308 | 309 | expect(callback.called).toBe(true); 310 | }); 311 | }); 312 | 313 | describe('ignoreArgv', ({ test }) => { 314 | test('ignore after arguments', () => { 315 | const callback = spy(); 316 | const argv = ['commandA', '--unknown', 'arg', '--help']; 317 | 318 | let receivedArgument = false; 319 | const commandA = command({ 320 | name: 'commandA', 321 | flags: { 322 | flagA: String, 323 | }, 324 | ignoreArgv(type) { 325 | if (receivedArgument) { 326 | return true; 327 | } 328 | if (type === 'argument') { 329 | receivedArgument = true; 330 | return true; 331 | } 332 | }, 333 | }, (parsed) => { 334 | expect<(string | boolean)[]>(parsed.unknownFlags.unknown).toStrictEqual([true]); 335 | callback(); 336 | }); 337 | 338 | const parsed = cli( 339 | { 340 | commands: [ 341 | commandA, 342 | ], 343 | }, 344 | undefined, 345 | argv, 346 | ); 347 | 348 | expect(parsed.command).toBe('commandA'); 349 | 350 | // Narrow type 351 | if (parsed.command === 'commandA') { 352 | expect<(string | boolean)[]>(parsed.unknownFlags.unknown).toStrictEqual([true]); 353 | } 354 | 355 | expect(callback.called).toBe(true); 356 | }); 357 | }); 358 | 359 | describe('command vs flag ambiguity', ({ test }) => { 360 | test('command name conflicts with flag name', () => { 361 | const commandCallback = spy(); 362 | const cliCallback = spy(); 363 | 364 | const testCommand = command({ 365 | name: 'test', 366 | }, commandCallback); 367 | 368 | const parsed = cli( 369 | { 370 | flags: { 371 | test: Boolean, // Flag with the same name as the command 372 | }, 373 | commands: [ 374 | testCommand, 375 | ], 376 | }, 377 | cliCallback, 378 | ['test'], // Ambiguous: command 'test' or flag '--test'? 379 | ); 380 | 381 | // It should be parsed as the command 382 | expect(parsed.command).toBe('test'); 383 | expect(commandCallback.called).toBe(true); 384 | expect(cliCallback.called).toBe(false); 385 | }); 386 | }); 387 | 388 | describe('async command callbacks', ({ test }) => { 389 | test('cli Promise waits for command callback to complete', async () => { 390 | let commandCompleted = false; 391 | 392 | const testCommand = command({ 393 | name: 'test', 394 | }, async () => { 395 | await setImmediate(); 396 | commandCompleted = true; 397 | }); 398 | 399 | const result = cli( 400 | { 401 | commands: [testCommand], 402 | }, 403 | undefined, 404 | ['test'], 405 | ); 406 | 407 | // Command callback shouldn't have completed yet 408 | expect(commandCompleted).toBe(false); 409 | 410 | // After awaiting cli, command callback should be complete 411 | await result; 412 | expect(commandCompleted).toBe(true); 413 | }); 414 | 415 | test('cli Promise never resolves if command callback never resolves', async () => { 416 | let cliResolved = false; 417 | 418 | const testCommand = command({ 419 | name: 'test', 420 | }, async () => { 421 | // Never resolve - hang forever 422 | await new Promise(() => {}); 423 | }); 424 | 425 | const result = cli( 426 | { 427 | commands: [testCommand], 428 | }, 429 | undefined, 430 | ['test'], 431 | ); 432 | 433 | // Race the cli promise against a timeout 434 | await Promise.race([ 435 | result.then(() => { 436 | cliResolved = true; 437 | }), 438 | setImmediate(50), 439 | ]); 440 | 441 | // cli should not have resolved 442 | expect(cliResolved).toBe(false); 443 | }); 444 | }); 445 | 446 | describe('strictFlags inheritance', ({ test }) => { 447 | test('command inherits strictFlags from parent', () => { 448 | const mocked = mockEnvFunctions(); 449 | 450 | const buildCommand = command({ 451 | name: 'build', 452 | flags: { 453 | watch: Boolean, 454 | }, 455 | }); 456 | 457 | cli( 458 | { 459 | strictFlags: true, 460 | commands: [buildCommand], 461 | }, 462 | undefined, 463 | ['build', '--wathc'], 464 | ); 465 | mocked.restore(); 466 | 467 | expect(mocked.consoleError.called).toBe(true); 468 | expect(mocked.consoleError.calls[0][0]).toContain('--wathc'); 469 | expect(mocked.consoleError.calls[0][0]).toContain('--watch'); 470 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 471 | }); 472 | 473 | test('command can override strictFlags to false', () => { 474 | const mocked = mockEnvFunctions(); 475 | 476 | const buildCommand = command({ 477 | name: 'build', 478 | flags: { 479 | watch: Boolean, 480 | }, 481 | strictFlags: false, 482 | }); 483 | 484 | const parsed = cli( 485 | { 486 | strictFlags: true, 487 | commands: [buildCommand], 488 | }, 489 | undefined, 490 | ['build', '--unknown'], 491 | ); 492 | mocked.restore(); 493 | 494 | expect(mocked.consoleError.called).toBe(false); 495 | expect(mocked.processExit.called).toBe(false); 496 | expect(parsed.unknownFlags.unknown).toEqual([true]); 497 | }); 498 | 499 | test('command can enable strictFlags independently', () => { 500 | const mocked = mockEnvFunctions(); 501 | 502 | const buildCommand = command({ 503 | name: 'build', 504 | flags: { 505 | watch: Boolean, 506 | }, 507 | strictFlags: true, 508 | }); 509 | 510 | cli( 511 | { 512 | commands: [buildCommand], 513 | }, 514 | undefined, 515 | ['build', '--wathc'], 516 | ); 517 | mocked.restore(); 518 | 519 | expect(mocked.consoleError.called).toBe(true); 520 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 521 | }); 522 | }); 523 | }); 524 | }); 525 | -------------------------------------------------------------------------------- /tests/specs/types.ts: -------------------------------------------------------------------------------- 1 | import { testSuite } from 'manten'; 2 | import { expectTypeOf } from 'expect-type'; 3 | import { cli, command, type Flags } from '#cleye'; 4 | 5 | export default testSuite(({ describe }) => { 6 | describe('types', ({ test }) => { 7 | test('cli with parameters and flags', () => { 8 | const parsed = cli({ 9 | parameters: ['', '[bar...]'], 10 | 11 | flags: { 12 | booleanFlag: Boolean, 13 | booleanFlagDefault: { 14 | type: Boolean, 15 | default: false, 16 | }, 17 | stringFlag: String, 18 | stringFlagDefault: { 19 | type: String, 20 | default: 'hello', 21 | }, 22 | numberFlag: Number, 23 | numberFlagDefault: { 24 | type: Number, 25 | default: 1, 26 | }, 27 | extraOptions: { 28 | type: Boolean, 29 | alias: 'e', 30 | default: false, 31 | description: 'Some description', 32 | }, 33 | }, 34 | }, undefined, ['value1']); 35 | 36 | // Type check when no command is matched 37 | if (parsed.command === undefined) { 38 | // Check parameters 39 | expectTypeOf(parsed._.foo).toBeString(); 40 | expectTypeOf(parsed._.bar).toEqualTypeOf(); 41 | 42 | // Check it's also an Arguments type (has '--') 43 | expectTypeOf(parsed._['--']).toEqualTypeOf(); 44 | 45 | expectTypeOf(parsed.flags).toEqualTypeOf<{ 46 | booleanFlag: boolean | undefined; 47 | booleanFlagDefault: boolean; 48 | stringFlag: string | undefined; 49 | stringFlagDefault: string; 50 | numberFlag: number | undefined; 51 | numberFlagDefault: number; 52 | extraOptions: boolean; 53 | help: boolean | undefined; 54 | }>(); 55 | } 56 | }); 57 | 58 | test('cli with commands', () => { 59 | const parsed = cli({ 60 | flags: { 61 | booleanFlag: Boolean, 62 | booleanFlagDefault: { 63 | type: Boolean, 64 | default: false, 65 | }, 66 | stringFlag: String, 67 | stringFlagDefault: { 68 | type: String, 69 | default: 'hello', 70 | }, 71 | numberFlag: Number, 72 | numberFlagDefault: { 73 | type: Number, 74 | default: 1, 75 | }, 76 | extraOptions: { 77 | type: Boolean, 78 | alias: 'e', 79 | default: false, 80 | description: 'Some description', 81 | }, 82 | }, 83 | 84 | commands: [ 85 | command({ 86 | name: 'commandA', 87 | 88 | parameters: [ 89 | '', 90 | '', 91 | '[bar...]', 92 | ], 93 | 94 | flags: { 95 | booleanFlag: Boolean, 96 | booleanFlagDefault: { 97 | type: Boolean, 98 | default: false, 99 | }, 100 | stringFlag: String, 101 | stringFlagDefault: { 102 | type: String, 103 | default: 'hello', 104 | }, 105 | numberFlag: Number, 106 | numberFlagDefault: { 107 | type: Number, 108 | default: 1, 109 | }, 110 | extraOptions: { 111 | type: Boolean, 112 | alias: 'e', 113 | default: false, 114 | description: 'Some description', 115 | }, 116 | }, 117 | }), 118 | ], 119 | }, undefined, ['commandA', 'value1', 'value2']); 120 | 121 | // Type check when commandA is matched 122 | if (parsed.command === 'commandA') { 123 | // Check parameters 124 | expectTypeOf(parsed._.foo).toBeString(); 125 | expectTypeOf(parsed._.helloWorld).toBeString(); 126 | expectTypeOf(parsed._.bar).toEqualTypeOf(); 127 | 128 | // Check it's also an Arguments type (has '--') 129 | expectTypeOf(parsed._['--']).toEqualTypeOf(); 130 | 131 | expectTypeOf(parsed.flags).toEqualTypeOf<{ 132 | booleanFlag: boolean | undefined; 133 | booleanFlagDefault: boolean; 134 | stringFlag: string | undefined; 135 | stringFlagDefault: string; 136 | numberFlag: number | undefined; 137 | numberFlagDefault: number; 138 | extraOptions: boolean; 139 | help: boolean | undefined; 140 | }>(); 141 | } 142 | }); 143 | 144 | test('parameter name normalization', () => { 145 | const parsed = cli({ 146 | parameters: [ 147 | '', 148 | ], 149 | }, undefined, ['a']); 150 | 151 | if (parsed.command === undefined) { 152 | // Parameter with spaces should normalize to camelCase 153 | expectTypeOf(parsed._).toHaveProperty('helloWorld'); 154 | expectTypeOf(parsed._.helloWorld).toBeString(); 155 | } 156 | }); 157 | 158 | test('flag types without defaults', () => { 159 | const parsed = cli({ 160 | flags: { 161 | stringFlag: String, 162 | numberFlag: Number, 163 | booleanFlag: Boolean, 164 | }, 165 | }, undefined, []); 166 | 167 | if (parsed.command === undefined) { 168 | expectTypeOf(parsed.flags.stringFlag).toEqualTypeOf(); 169 | expectTypeOf(parsed.flags.numberFlag).toEqualTypeOf(); 170 | expectTypeOf(parsed.flags.booleanFlag).toEqualTypeOf(); 171 | expectTypeOf(parsed.flags.help).toEqualTypeOf(); 172 | } 173 | }); 174 | 175 | test('flag types with defaults', () => { 176 | const parsed = cli({ 177 | flags: { 178 | stringFlag: { 179 | type: String, 180 | default: 'default', 181 | }, 182 | numberFlag: { 183 | type: Number, 184 | default: 42, 185 | }, 186 | booleanFlag: { 187 | type: Boolean, 188 | default: true, 189 | }, 190 | }, 191 | }, undefined, []); 192 | 193 | if (parsed.command === undefined) { 194 | expectTypeOf(parsed.flags.stringFlag).toBeString(); 195 | expectTypeOf(parsed.flags.numberFlag).toBeNumber(); 196 | expectTypeOf(parsed.flags.booleanFlag).toBeBoolean(); 197 | } 198 | }); 199 | 200 | test('optional and required parameters', () => { 201 | const parsed = cli({ 202 | parameters: ['', '[variadic...]'], 203 | }, undefined, ['req', 'var1', 'var2']); 204 | 205 | if (parsed.command === undefined) { 206 | expectTypeOf(parsed._.required).toBeString(); 207 | expectTypeOf(parsed._.variadic).toEqualTypeOf(); 208 | } 209 | }); 210 | 211 | test('command discriminated union', () => { 212 | const parsed = cli({ 213 | commands: [ 214 | command({ 215 | name: 'cmd1', 216 | parameters: [''], 217 | }), 218 | command({ 219 | name: 'cmd2', 220 | parameters: [''], 221 | }), 222 | ], 223 | }, undefined, ['cmd1', 'value']); 224 | 225 | // Type narrows correctly based on command name 226 | if (parsed.command === 'cmd1') { 227 | expectTypeOf(parsed._).toHaveProperty('param1'); 228 | expectTypeOf(parsed._).not.toHaveProperty('param2'); 229 | } 230 | 231 | if (parsed.command === 'cmd2') { 232 | expectTypeOf(parsed._).toHaveProperty('param2'); 233 | expectTypeOf(parsed._).not.toHaveProperty('param1'); 234 | } 235 | }); 236 | 237 | test('no parameters', () => { 238 | const parsed = cli({ 239 | flags: { 240 | flag: String, 241 | }, 242 | }, undefined, []); 243 | 244 | if (parsed.command === undefined) { 245 | // Check it's an Arguments type (has '--' property) 246 | expectTypeOf(parsed._['--']).toEqualTypeOf(); 247 | // And extends string[] 248 | expectTypeOf(parsed._).toMatchTypeOf(); 249 | } 250 | }); 251 | 252 | test('double dash arguments', () => { 253 | const parsed = cli({}, undefined, ['--', 'arg1', 'arg2']); 254 | 255 | if (parsed.command === undefined) { 256 | expectTypeOf(parsed._['--']).toEqualTypeOf(); 257 | } 258 | }); 259 | 260 | test('command callback flag types', () => { 261 | command({ 262 | name: 'commandA', 263 | 264 | flags: { 265 | booleanFlag: Boolean, 266 | booleanFlagDefault: { 267 | type: Boolean, 268 | default: false, 269 | }, 270 | }, 271 | }, (argv) => { 272 | expectTypeOf(argv.flags).toEqualTypeOf<{ 273 | help: boolean | undefined; 274 | booleanFlag: boolean | undefined; 275 | booleanFlagDefault: boolean; 276 | }>(); 277 | }); 278 | }); 279 | 280 | test('async cli() returns promise', () => { 281 | const result = cli({ 282 | flags: { 283 | foo: String, 284 | }, 285 | }, async (argv) => { 286 | console.log(argv.flags.foo); 287 | }, []); 288 | 289 | // Should have Promise methods 290 | expectTypeOf(result.then).toBeFunction(); 291 | expectTypeOf(result.catch).toBeFunction(); 292 | expectTypeOf(result.finally).toBeFunction(); 293 | 294 | // Should also have parsed properties 295 | expectTypeOf(result.flags).toEqualTypeOf<{ 296 | foo: string | undefined; 297 | help: boolean | undefined; 298 | }>(); 299 | }); 300 | 301 | test('sync cli() has parsed properties without Promise', () => { 302 | const result = cli({ 303 | flags: { 304 | bar: Number, 305 | }, 306 | }, (argv) => { 307 | console.log(argv.flags.bar); 308 | }, []); 309 | 310 | // Should have parsed properties 311 | expectTypeOf(result.flags).toEqualTypeOf<{ 312 | bar: number | undefined; 313 | help: boolean | undefined; 314 | }>(); 315 | 316 | // Should NOT have Promise methods (sync callback) 317 | expectTypeOf(result).not.toMatchTypeOf>(); 318 | }); 319 | 320 | test('async cli() without commands returns Promise', () => { 321 | const result = cli({ 322 | flags: { 323 | foo: String, 324 | }, 325 | }, async (argv) => { 326 | console.log(argv.flags.foo); 327 | }, []); 328 | 329 | // Should have Promise methods 330 | expectTypeOf(result.then).toBeFunction(); 331 | expectTypeOf(result.catch).toBeFunction(); 332 | expectTypeOf(result.finally).toBeFunction(); 333 | }); 334 | 335 | test('cli() without callback has no Promise', () => { 336 | const result = cli({ 337 | flags: { 338 | baz: String, 339 | }, 340 | }); 341 | 342 | // Should have parsed properties 343 | expectTypeOf(result.flags).toEqualTypeOf<{ 344 | baz: string | undefined; 345 | help: boolean | undefined; 346 | }>(); 347 | 348 | // Should NOT have Promise methods (no callback) 349 | expectTypeOf(result).not.toMatchTypeOf>(); 350 | }); 351 | 352 | test('async cli() with commands returns promise', () => { 353 | const result = cli({ 354 | commands: [ 355 | command({ 356 | name: 'test', 357 | flags: { 358 | flag: Boolean, 359 | }, 360 | }), 361 | ], 362 | }, async () => { 363 | // async main callback 364 | }, ['test']); 365 | 366 | // Should have Promise methods 367 | expectTypeOf(result.then).toBeFunction(); 368 | expectTypeOf(result.catch).toBeFunction(); 369 | expectTypeOf(result.finally).toBeFunction(); 370 | }); 371 | 372 | test('Flags type is exported and usable', () => { 373 | // Flags type should be importable and usable for defining shared flags 374 | const sharedFlags = { 375 | verbose: { 376 | type: Boolean, 377 | alias: 'v', 378 | description: 'Enable verbose output', 379 | }, 380 | config: { 381 | type: String, 382 | description: 'Config file path', 383 | placeholder: '', 384 | }, 385 | } satisfies Flags; 386 | 387 | // Flags type should be assignable 388 | const flags: Flags = sharedFlags; 389 | expectTypeOf(flags).toMatchTypeOf(); 390 | 391 | // Should work with cli() 392 | const result = cli({ 393 | flags: sharedFlags, 394 | }, undefined, []); 395 | 396 | expectTypeOf(result.flags.verbose).toEqualTypeOf(); 397 | expectTypeOf(result.flags.config).toEqualTypeOf(); 398 | }); 399 | 400 | test('unknown cli options cause type errors', () => { 401 | // This test verifies that TypeScript catches typos in cli options 402 | // at compile time via @ts-expect-error directives. 403 | // The StrictOptions type maps unknown keys to `never`. 404 | 405 | cli({ 406 | name: 'test', 407 | // @ts-expect-error - 'params' is not a valid option (typo for 'parameters') 408 | params: [''], 409 | }); 410 | 411 | cli({ 412 | name: 'test', 413 | // @ts-expect-error - 'unknownOption' is not a valid option 414 | unknownOption: true, 415 | }); 416 | }); 417 | 418 | test('ignoreArgv callback with 3 parameters', () => { 419 | // Issue #26: ignoreArgv should accept callbacks with all 3 parameters 420 | // The third parameter (value) is optional but users should be able to declare it 421 | cli({ 422 | name: 'test', 423 | ignoreArgv(type, flagOrArgv, value) { 424 | // All parameters should be properly typed 425 | expectTypeOf(type).toEqualTypeOf<'argument' | 'known-flag' | 'unknown-flag'>(); 426 | expectTypeOf(flagOrArgv).toBeString(); 427 | expectTypeOf(value).toEqualTypeOf(); 428 | return false; 429 | }, 430 | }); 431 | 432 | // Should also work with required third parameter 433 | cli({ 434 | name: 'test', 435 | ignoreArgv(_type, _flagOrArgv, _value) { 436 | return false; 437 | }, 438 | }); 439 | }); 440 | 441 | test('Parameters is not never', () => { 442 | // Issue #36: Parameters should not resolve to never 443 | // This ensures cli function signature is properly typed for wrapper functions 444 | type CliParameters = Parameters; 445 | 446 | // Should not be never - if it is, this test will fail at compile time 447 | expectTypeOf().not.toBeNever(); 448 | 449 | // Should be a tuple with at least one element (the options parameter) 450 | expectTypeOf().not.toBeNever(); 451 | }); 452 | 453 | test('cli() return type is not void', () => { 454 | // Issue #36: cli({}) return type should not be void 455 | const result = cli({}); 456 | 457 | // Return type should have flags property 458 | expectTypeOf(result).toHaveProperty('flags'); 459 | 460 | // Return type should have showHelp and showVersion 461 | expectTypeOf(result).toHaveProperty('showHelp'); 462 | expectTypeOf(result).toHaveProperty('showVersion'); 463 | 464 | // Return type should not be void 465 | expectTypeOf(result).not.toBeVoid(); 466 | }); 467 | }); 468 | }); 469 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { typeFlag } from 'type-flag'; 2 | import { closest, distance } from 'fastest-levenshtein'; 3 | import type { 4 | CallbackFunction, 5 | CliOptions, 6 | CliOptionsInternal, 7 | ParseArgv, 8 | parsedType, 9 | HelpOptions, 10 | HelpDocumentNode, 11 | StrictOptions, 12 | MaybePromise, 13 | } from './types'; 14 | import type { Command } from './command'; 15 | import { generateHelp, Renderers } from './render-help'; 16 | import { camelCase } from './utils/convert-case'; 17 | import { isValidScriptName } from './utils/script-name'; 18 | 19 | const { stringify } = JSON; 20 | 21 | const specialCharactersPattern = /[|\\{}()[\]^$+*?.]/; 22 | 23 | type ParsedParameter = { 24 | name: string; 25 | required: boolean; 26 | spread: boolean; 27 | }; 28 | 29 | function parseParameters(parameters: string[]) { 30 | const parsedParameters: ParsedParameter[] = []; 31 | 32 | let hasOptional: string | undefined; 33 | let hasSpread: string | undefined; 34 | 35 | for (const parameter of parameters) { 36 | if (hasSpread) { 37 | throw new Error(`Invalid parameter: Spread parameter ${stringify(hasSpread)} must be last`); 38 | } 39 | 40 | const firstCharacter = parameter[0]; 41 | const lastCharacter = parameter.at(-1); 42 | 43 | let required: boolean | undefined; 44 | if (firstCharacter === '<' && lastCharacter === '>') { 45 | required = true; 46 | 47 | if (hasOptional) { 48 | throw new Error(`Invalid parameter: Required parameter ${stringify(parameter)} cannot come after optional parameter ${stringify(hasOptional)}`); 49 | } 50 | } 51 | 52 | if (firstCharacter === '[' && lastCharacter === ']') { 53 | required = false; 54 | hasOptional = parameter; 55 | } 56 | 57 | if (required === undefined) { 58 | throw new Error(`Invalid parameter: ${stringify(parameter)}. Must be wrapped in <> (required parameter) or [] (optional parameter)`); 59 | } 60 | 61 | let name = parameter.slice(1, -1); 62 | 63 | const spread = name.slice(-3) === '...'; 64 | 65 | if (spread) { 66 | hasSpread = parameter; 67 | name = name.slice(0, -3); 68 | } 69 | 70 | const invalidCharacter = name.match(specialCharactersPattern); 71 | if (invalidCharacter) { 72 | throw new Error(`Invalid parameter: ${stringify(parameter)}. Invalid character found ${stringify(invalidCharacter[0])}`); 73 | } 74 | 75 | parsedParameters.push({ 76 | name, 77 | required, 78 | spread, 79 | }); 80 | } 81 | 82 | return parsedParameters; 83 | } 84 | 85 | function mapParametersToArguments( 86 | mapping: Record, 87 | parameters: ParsedParameter[], 88 | cliArguments: string[], 89 | showHelp: () => void, 90 | ) { 91 | for (let i = 0; i < parameters.length; i += 1) { 92 | const { name, required, spread } = parameters[i]; 93 | const camelCaseName = camelCase(name); 94 | if (camelCaseName in mapping) { 95 | throw new Error(`Invalid parameter: ${stringify(name)} is used more than once.`); 96 | } 97 | 98 | const value = spread ? cliArguments.slice(i) : cliArguments[i]; 99 | 100 | if (spread) { 101 | i = parameters.length; 102 | } 103 | 104 | if ( 105 | required 106 | && (!value || (spread && value.length === 0)) 107 | ) { 108 | console.error(`Error: Missing required parameter ${stringify(name)}\n`); 109 | showHelp(); 110 | return process.exit(1); 111 | } 112 | 113 | mapping[camelCaseName] = value; 114 | } 115 | } 116 | 117 | function helpEnabled(help: false | undefined | HelpOptions): help is (HelpOptions | undefined) { 118 | return help !== false; 119 | } 120 | 121 | const getKnownFlagNames = (flags: Record): string[] => { 122 | const names: string[] = []; 123 | for (const [name, config] of Object.entries(flags)) { 124 | names.push(name); 125 | if (config && typeof config === 'object' && 'alias' in config) { 126 | const { alias } = config as { alias?: string | string[] }; 127 | if (typeof alias === 'string' && alias) { 128 | names.push(alias); 129 | } else if (Array.isArray(alias)) { 130 | names.push(...alias.filter(Boolean)); 131 | } 132 | } 133 | } 134 | return names; 135 | }; 136 | 137 | const findClosestFlag = ( 138 | unknown: string, 139 | knownFlags: string[], 140 | ): string | undefined => { 141 | // Don't suggest for very short flags (e.g. -a vs -b) 142 | if (unknown.length < 3 || knownFlags.length === 0) { 143 | return undefined; 144 | } 145 | const match = closest(unknown, knownFlags); 146 | return distance(unknown, match) <= 2 ? match : undefined; 147 | }; 148 | 149 | const handleUnknownFlags = ( 150 | unknownFlags: Record, 151 | knownFlagNames: string[], 152 | ): void => { 153 | const unknownFlagNames = Object.keys(unknownFlags); 154 | if (unknownFlagNames.length === 0) { 155 | return; 156 | } 157 | 158 | for (const flag of unknownFlagNames) { 159 | const closestMatch = findClosestFlag(flag, knownFlagNames); 160 | const suggestion = closestMatch ? ` (Did you mean --${closestMatch}?)` : ''; 161 | console.error(`Error: Unknown flag: --${flag}.${suggestion}`); 162 | } 163 | 164 | process.exit(1); 165 | }; 166 | 167 | function cliBase< 168 | CommandName extends string | undefined, 169 | Options extends CliOptionsInternal, 170 | Parameters extends string[], 171 | >( 172 | command: CommandName, 173 | options: Options, 174 | callback: CallbackFunction> | undefined, 175 | argv: string[], 176 | ) { 177 | const flags = { ...options.flags }; 178 | // Expected to work even if flag is overwritten; add tests 179 | const isVersionEnabled = options.version && !('version' in flags); 180 | 181 | if (isVersionEnabled) { 182 | flags.version = { 183 | type: Boolean, 184 | description: 'Show version', 185 | }; 186 | } 187 | 188 | const { help } = options; 189 | const isHelpEnabled = helpEnabled(help); 190 | 191 | // Expected to work even if overwritten; add tests 192 | if (isHelpEnabled && !('help' in flags)) { 193 | flags.help = { 194 | type: Boolean, 195 | alias: 'h', 196 | description: 'Show help', 197 | }; 198 | } 199 | 200 | const parsed = typeFlag( 201 | flags, 202 | argv, 203 | { 204 | ignore: options.ignoreArgv, 205 | }, 206 | ); 207 | 208 | const showVersion = () => { 209 | console.log(options.version); 210 | }; 211 | 212 | if ( 213 | isVersionEnabled 214 | 215 | // Can be overridden to different type 216 | && parsed.flags.version === true 217 | ) { 218 | showVersion(); 219 | return process.exit(0); 220 | } 221 | 222 | const helpRenderers = new Renderers(); 223 | const renderHelpFunction = ( 224 | (isHelpEnabled && help?.render) 225 | ? help.render 226 | : (nodes: HelpDocumentNode | HelpDocumentNode[]) => helpRenderers.render(nodes) 227 | ); 228 | 229 | const showHelp = (helpOptions?: HelpOptions) => { 230 | const nodes = generateHelp({ 231 | ...options, 232 | ...(helpOptions ? { help: helpOptions } : {}), 233 | flags, 234 | }); 235 | 236 | console.log(renderHelpFunction(nodes, helpRenderers)); 237 | }; 238 | 239 | if ( 240 | isHelpEnabled 241 | 242 | // Can be overridden to different type 243 | && parsed.flags.help === true 244 | ) { 245 | showHelp(); 246 | return process.exit(0); 247 | } 248 | 249 | // Check for unknown flags if strictFlags is enabled 250 | // Inherit from parent if not explicitly set 251 | const strictFlags = options.strictFlags ?? options.parent?.strictFlags; 252 | if (strictFlags) { 253 | handleUnknownFlags(parsed.unknownFlags, getKnownFlagNames(flags)); 254 | } 255 | 256 | if (options.parameters) { 257 | let { parameters } = options; 258 | let cliArguments = parsed._ as string[]; 259 | const hasEof = parameters.indexOf('--'); 260 | const eofParameters = parameters.slice(hasEof + 1); 261 | const mapping: Record = Object.create(null); 262 | 263 | let eofArguments: string[] = []; 264 | if (hasEof > -1 && eofParameters.length > 0) { 265 | parameters = parameters.slice(0, hasEof); 266 | eofArguments = parsed._['--']; 267 | cliArguments = cliArguments.slice(0, -eofArguments.length || undefined); 268 | } 269 | 270 | mapParametersToArguments( 271 | mapping, 272 | parseParameters(parameters), 273 | cliArguments, 274 | showHelp, 275 | ); 276 | 277 | if (hasEof > -1 && eofParameters.length > 0) { 278 | mapParametersToArguments( 279 | mapping, 280 | parseParameters(eofParameters), 281 | eofArguments, 282 | showHelp, 283 | ); 284 | } 285 | 286 | Object.assign( 287 | parsed._, 288 | mapping, 289 | ); 290 | } 291 | 292 | const parsedWithApi = { 293 | ...parsed, 294 | showVersion, 295 | showHelp, 296 | }; 297 | 298 | // Already flattened 299 | const result = { 300 | command, 301 | ...parsedWithApi, 302 | }; 303 | 304 | if (typeof callback === 'function') { 305 | const callbackResult = callback(parsedWithApi as any); 306 | 307 | // Make it awaitable in case the callback returns a promise 308 | if (callbackResult && 'then' in callbackResult) { 309 | return Object.assign( 310 | // We wrap the promise again incase a fake promise was returned 311 | Promise.resolve(callbackResult), 312 | result, 313 | ); 314 | } 315 | } 316 | 317 | return result; 318 | } 319 | 320 | function getCommand( 321 | potentialCommand: string, 322 | commands: [...Commands], 323 | ) { 324 | const commandMap = new Map(); 325 | for (const command of commands) { 326 | const names = [command.options.name]; 327 | 328 | const { alias } = command.options; 329 | if (alias) { 330 | if (Array.isArray(alias)) { 331 | names.push(...alias); 332 | } else { 333 | names.push(alias); 334 | } 335 | } 336 | 337 | for (const name of names) { 338 | if (commandMap.has(name)) { 339 | throw new Error(`Duplicate command name found: ${stringify(name)}`); 340 | } 341 | 342 | commandMap.set(name, command); 343 | } 344 | } 345 | 346 | return commandMap.get(potentialCommand); 347 | } 348 | 349 | // Overload 1: No commands, no callback - returns without Promise 350 | function cli< 351 | Options extends CliOptions, 352 | Parameters extends string[], 353 | >( 354 | options: StrictOptions 355 | & CliOptions 356 | & { commands?: undefined }, 357 | callback?: undefined, 358 | argv?: string[], 359 | ): { 360 | [ 361 | Key in keyof ParseArgv< 362 | Options, 363 | Parameters, 364 | undefined 365 | > 366 | ]: ParseArgv< 367 | Options, 368 | Parameters, 369 | undefined 370 | >[Key]; 371 | }; 372 | 373 | // Overload 2: No commands, with callback - returns MaybePromise based on callback return 374 | function cli< 375 | Options extends CliOptions, 376 | Parameters extends string[], 377 | CallbackReturn extends void | Promise, 378 | >( 379 | options: StrictOptions 380 | & CliOptions 381 | & { commands?: undefined }, 382 | callback: (parsed: ParseArgv) => CallbackReturn, 383 | argv?: string[], 384 | ): MaybePromise< 385 | { 386 | [ 387 | Key in keyof ParseArgv< 388 | Options, 389 | Parameters, 390 | undefined 391 | > 392 | ]: ParseArgv< 393 | Options, 394 | Parameters, 395 | undefined 396 | >[Key]; 397 | }, 398 | CallbackReturn 399 | >; 400 | 401 | // Overload 3: With commands, no callback 402 | // Always returns Promise since commands may have async callbacks 403 | function cli< 404 | Options extends CliOptions<[...Commands], [...Parameters]>, 405 | Commands extends Command[], 406 | Parameters extends string[], 407 | >( 408 | options: StrictOptions 409 | & CliOptions<[...Commands], [...Parameters]> 410 | & { commands: [...Commands] }, 411 | callback?: undefined, 412 | argv?: string[], 413 | ): ( 414 | { 415 | [ 416 | Key in keyof ParseArgv< 417 | Options, 418 | Parameters, 419 | undefined 420 | > 421 | ]: ParseArgv< 422 | Options, 423 | Parameters, 424 | undefined 425 | >[Key]; 426 | } 427 | | { 428 | [KeyA in keyof Commands]: ( 429 | Commands[KeyA] extends Command 430 | ? ( 431 | { 432 | [ 433 | KeyB in keyof Commands[KeyA][typeof parsedType] 434 | ]: Commands[KeyA][typeof parsedType][KeyB]; 435 | } 436 | ) : never 437 | ); 438 | }[number] 439 | ) & Promise; 440 | 441 | // Overload 4: With commands, with callback 442 | // Always returns Promise since commands may have async callbacks 443 | function cli< 444 | Options extends CliOptions<[...Commands], [...Parameters]>, 445 | Commands extends Command[], 446 | Parameters extends string[], 447 | >( 448 | options: StrictOptions 449 | & CliOptions<[...Commands], [...Parameters]> 450 | & { commands: [...Commands] }, 451 | callback: (parsed: ParseArgv) => void | Promise, 452 | argv?: string[], 453 | ): ( 454 | ( 455 | { 456 | [ 457 | Key in keyof ParseArgv< 458 | Options, 459 | Parameters, 460 | undefined 461 | > 462 | ]: ParseArgv< 463 | Options, 464 | Parameters, 465 | undefined 466 | >[Key]; 467 | } 468 | | { 469 | [KeyA in keyof Commands]: ( 470 | Commands[KeyA] extends Command 471 | ? ( 472 | { 473 | [ 474 | KeyB in keyof Commands[KeyA][typeof parsedType] 475 | ]: Commands[KeyA][typeof parsedType][KeyB]; 476 | } 477 | ) : never 478 | ); 479 | }[number] 480 | ) & Promise 481 | ); 482 | 483 | // General overload for Parameters to extract from 484 | function cli( 485 | options: CliOptions, 486 | callback?: CallbackFunction, 487 | argv?: string[], 488 | ): ParseArgv; 489 | 490 | function cli< 491 | Options extends CliOptions<[...Commands], [...Parameters]>, 492 | Commands extends Command[], 493 | Parameters extends string[], 494 | >( 495 | options: Options | (Options & CliOptions<[...Commands], [...Parameters]>), 496 | callback?: CallbackFunction>, 497 | argv = process.argv.slice(2), 498 | ): any { 499 | // Because if not configured, it's probably being misused or overlooked 500 | if (!options) { 501 | throw new Error('Options is required'); 502 | } 503 | 504 | if ('name' in options && (!options.name || !isValidScriptName(options.name))) { 505 | throw new Error(`Invalid script name: ${stringify(options.name)}`); 506 | } 507 | 508 | const potentialCommand = argv[0]; 509 | 510 | if ( 511 | options.commands 512 | && potentialCommand 513 | && isValidScriptName(potentialCommand) 514 | ) { 515 | const command = getCommand(potentialCommand, options.commands); 516 | 517 | if (command) { 518 | return cliBase( 519 | command.options.name, 520 | { 521 | ...command.options, 522 | parent: options, 523 | }, 524 | command.callback, 525 | argv.slice(1), 526 | ); 527 | } 528 | } 529 | 530 | return cliBase(undefined, options, callback, argv); 531 | } 532 | 533 | export { cli }; 534 | -------------------------------------------------------------------------------- /tests/specs/flags.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { spy } from 'nanospy'; 3 | import { mockEnvFunctions } from '../utils/mock-env-functions'; 4 | import { cli } from '#cleye'; 5 | 6 | export default testSuite(({ describe }) => { 7 | describe('flags', ({ describe, test }) => { 8 | test('has return type & callback', () => { 9 | const callback = spy(); 10 | const argv = cli( 11 | { 12 | parameters: ['', '[value-B]'], 13 | flags: { 14 | flagA: String, 15 | flagB: { 16 | type: Number, 17 | }, 18 | }, 19 | }, 20 | (parsed) => { 21 | expect(parsed.flags.flagA).toBe('valueA'); 22 | expect(parsed.flags.flagB).toBe(123); 23 | callback(); 24 | }, 25 | ['--flagA', 'valueA', '--flagB', '123', 'valueA', 'valueB'], 26 | ); 27 | 28 | if (!argv.command) { 29 | expect(argv._.valueA).toBe('valueA'); 30 | expect(argv._.valueB).toBe('valueB'); 31 | expect(argv.flags.flagA).toBe('valueA'); 32 | expect(argv.flags.flagB).toBe(123); 33 | expect(callback.called).toBe(true); 34 | } 35 | }); 36 | 37 | describe('version', ({ test }) => { 38 | test('disabled', () => { 39 | const mocked = mockEnvFunctions(); 40 | const parsed = cli( 41 | {}, 42 | (p) => { 43 | expect<{ 44 | version?: undefined; 45 | help: boolean | undefined; 46 | }>(p.flags).toEqual({}); 47 | }, 48 | ['--version'], 49 | ); 50 | mocked.restore(); 51 | 52 | expect<{ 53 | version?: undefined; 54 | help: boolean | undefined; 55 | }>(parsed.flags).toEqual({}); 56 | expect(mocked.consoleLog.called).toBe(false); 57 | expect(mocked.processExit.called).toBe(false); 58 | }); 59 | 60 | test('enabled', () => { 61 | const mocked = mockEnvFunctions(); 62 | cli( 63 | { 64 | version: '1.0.0', 65 | flags: { 66 | flagA: String, 67 | }, 68 | }, 69 | ({ flags }) => { 70 | expect(flags.version).toBe(true); 71 | }, 72 | ['--version'], 73 | ); 74 | mocked.restore(); 75 | 76 | expect(mocked.consoleLog.called).toBe(true); 77 | expect(mocked.processExit.calls).toStrictEqual([[0]]); 78 | }); 79 | }); 80 | 81 | describe('help', ({ test }) => { 82 | test('disabled', () => { 83 | const mocked = mockEnvFunctions(); 84 | const parsed = cli( 85 | { 86 | help: false, 87 | }, 88 | (p) => { 89 | expect<{ 90 | help?: undefined; 91 | }>(p.flags).toEqual({}); 92 | }, 93 | ['--help'], 94 | ); 95 | mocked.restore(); 96 | 97 | expect<{ 98 | help?: undefined; 99 | }>(parsed.flags).toEqual({}); 100 | expect(mocked.consoleLog.called).toBe(false); 101 | expect(mocked.processExit.called).toBe(false); 102 | }); 103 | 104 | test('enabled', () => { 105 | const mocked = mockEnvFunctions(); 106 | cli( 107 | { 108 | flags: { 109 | flagA: String, 110 | }, 111 | }, 112 | ({ flags }) => { 113 | expect(flags.help).toBe(true); 114 | }, 115 | ['--help'], 116 | ); 117 | mocked.restore(); 118 | 119 | expect(mocked.consoleLog.called).toBe(true); 120 | expect(mocked.processExit.calls).toStrictEqual([[0]]); 121 | }); 122 | }); 123 | 124 | describe('flag overrides', ({ test }) => { 125 | test('overriding --help flag', () => { 126 | const mocked = mockEnvFunctions(); 127 | const parsed = cli( 128 | { 129 | flags: { 130 | help: { 131 | type: String, 132 | description: 'A custom help flag that accepts a string', 133 | }, 134 | }, 135 | }, 136 | undefined, 137 | ['--help', 'custom-value'], 138 | ); 139 | mocked.restore(); 140 | 141 | // Should not print help and should not exit 142 | expect(mocked.consoleLog.called).toBe(false); 143 | expect(mocked.processExit.called).toBe(false); 144 | 145 | // Should parse the flag as a string 146 | if (parsed.command === undefined) { 147 | // Type assertion needed because TS sees union of built-in (boolean) + override (string) 148 | expect(parsed.flags.help as string | undefined).toBe('custom-value'); 149 | } 150 | }); 151 | 152 | test('overriding --version flag', () => { 153 | const mocked = mockEnvFunctions(); 154 | const parsed = cli( 155 | { 156 | version: '1.0.0', // Enables --version behavior 157 | flags: { 158 | version: { 159 | type: Number, 160 | description: 'A custom version flag that accepts a number', 161 | }, 162 | }, 163 | }, 164 | undefined, 165 | ['--version', '42'], 166 | ); 167 | mocked.restore(); 168 | 169 | // Should not print version and should not exit 170 | expect(mocked.consoleLog.called).toBe(false); 171 | expect(mocked.processExit.called).toBe(false); 172 | 173 | // Should parse the flag as a number 174 | if (parsed.command === undefined) { 175 | // Type assertion needed because TS sees union of built-in (boolean) + override (number) 176 | expect(parsed.flags.version as number | undefined).toBe(42); 177 | } 178 | }); 179 | }); 180 | 181 | describe('custom flag type', ({ test }) => { 182 | const possibleSizes = ['small', 'medium', 'large'] as const; 183 | type Sizes = typeof possibleSizes[number]; 184 | const Size = (size: Sizes) => { 185 | if (!possibleSizes.includes(size)) { 186 | throw new Error(`Invalid size: "${size}"`); 187 | } 188 | return size; 189 | }; 190 | 191 | test('parses valid custom type', () => { 192 | const parsed = cli( 193 | { 194 | flags: { 195 | size: Size, 196 | }, 197 | }, 198 | undefined, 199 | ['--size', 'medium'], 200 | ); 201 | if (parsed.command === undefined) { 202 | expect(parsed.flags.size).toBe('medium'); 203 | } 204 | }); 205 | 206 | test('throws on invalid custom type', () => { 207 | expect(() => { 208 | cli( 209 | { 210 | flags: { 211 | size: Size, 212 | }, 213 | }, 214 | undefined, 215 | ['--size', 'xlarge'], 216 | ); 217 | }).toThrow('Invalid size: "xlarge"'); 218 | }); 219 | }); 220 | 221 | describe('flag parsing variants', ({ test }) => { 222 | test('parses array flags', () => { 223 | const parsed = cli( 224 | { 225 | flags: { 226 | item: [String], 227 | }, 228 | }, 229 | undefined, 230 | ['--item', 'a', '--item', 'b'], 231 | ); 232 | if (parsed.command === undefined) { 233 | expect(parsed.flags.item).toStrictEqual(['a', 'b']); 234 | } 235 | }); 236 | 237 | test('parses equals-syntax flags', () => { 238 | const parsed = cli( 239 | { 240 | flags: { 241 | name: String, 242 | }, 243 | }, 244 | undefined, 245 | ['--name=hiroki'], 246 | ); 247 | if (parsed.command === undefined) { 248 | expect(parsed.flags.name).toBe('hiroki'); 249 | } 250 | }); 251 | 252 | test('parses combined short aliases', () => { 253 | const parsed = cli( 254 | { 255 | flags: { 256 | read: { 257 | type: Boolean, 258 | alias: 'r', 259 | }, 260 | write: { 261 | type: Boolean, 262 | alias: 'w', 263 | }, 264 | execute: { 265 | type: Boolean, 266 | alias: 'x', 267 | }, 268 | }, 269 | }, 270 | undefined, 271 | ['-rx'], 272 | ); 273 | if (parsed.command === undefined) { 274 | expect(parsed.flags.read).toBe(true); 275 | expect(parsed.flags.write).toBe(undefined); 276 | expect(parsed.flags.execute).toBe(true); 277 | } 278 | }); 279 | 280 | test('parses short alias with value', () => { 281 | const parsed = cli( 282 | { 283 | flags: { 284 | number: { 285 | type: Number, 286 | alias: 'n', 287 | }, 288 | }, 289 | }, 290 | undefined, 291 | ['-n', '42'], 292 | ); 293 | if (parsed.command === undefined) { 294 | expect(parsed.flags.number).toBe(42); 295 | } 296 | }); 297 | 298 | test('default value function', () => { 299 | const defaultFunction = spy(() => 'hello'); 300 | const parsed = cli( 301 | { 302 | flags: { 303 | myFlag: { 304 | type: String, 305 | default: defaultFunction, 306 | }, 307 | }, 308 | }, 309 | undefined, 310 | [], 311 | ); 312 | if (parsed.command === undefined) { 313 | expect(parsed.flags.myFlag).toBe('hello'); 314 | expect(defaultFunction.called).toBe(true); 315 | } 316 | }); 317 | }); 318 | 319 | describe('ignoreArgv', ({ test }) => { 320 | test('ignore after arguments', () => { 321 | const argv = ['--unknown', 'arg', '--help']; 322 | 323 | let receivedArgument = false; 324 | const parsed = cli( 325 | { 326 | ignoreArgv(type) { 327 | if (receivedArgument) { 328 | return true; 329 | } 330 | if (type === 'argument') { 331 | receivedArgument = true; 332 | return true; 333 | } 334 | }, 335 | }, 336 | (p) => { 337 | expect(argv).toStrictEqual(['arg', '--help']); 338 | expect(p.unknownFlags).toStrictEqual({ 339 | unknown: [true], 340 | }); 341 | }, 342 | argv, 343 | ); 344 | 345 | expect(argv).toStrictEqual(['arg', '--help']); 346 | expect(parsed.unknownFlags).toStrictEqual({ 347 | unknown: [true], 348 | }); 349 | }); 350 | }); 351 | 352 | describe('unknown flags default behavior', ({ test }) => { 353 | test('unknown flag captured', () => { 354 | const parsed = cli( 355 | { 356 | flags: { 357 | known: String, 358 | }, 359 | }, 360 | undefined, 361 | ['--unknown', '--known', 'value'], 362 | ); 363 | 364 | expect(parsed.unknownFlags.unknown).toEqual([true]); 365 | expect(parsed.flags.known).toBe('value'); 366 | }); 367 | 368 | test('multiple unknown flags', () => { 369 | const parsed = cli( 370 | {}, 371 | undefined, 372 | ['--unknown1', '--unknown2', 'value'], 373 | ); 374 | 375 | expect(parsed.unknownFlags.unknown1).toEqual([true]); 376 | expect(parsed.unknownFlags.unknown2).toEqual([true]); 377 | }); 378 | }); 379 | 380 | describe('strictFlags', ({ test }) => { 381 | test('errors on unknown flag', () => { 382 | const mocked = mockEnvFunctions(); 383 | cli( 384 | { 385 | flags: { 386 | verbose: Boolean, 387 | }, 388 | strictFlags: true, 389 | }, 390 | undefined, 391 | ['--unknown'], 392 | ); 393 | mocked.restore(); 394 | 395 | expect(mocked.consoleError.called).toBe(true); 396 | expect(mocked.consoleError.calls[0][0]).toContain('Unknown flag'); 397 | expect(mocked.consoleError.calls[0][0]).toContain('--unknown'); 398 | expect(mocked.processExit.calls).toStrictEqual([[1]]); 399 | }); 400 | 401 | test('suggests closest match when within distance 2', () => { 402 | const mocked = mockEnvFunctions(); 403 | cli( 404 | { 405 | flags: { 406 | verbose: Boolean, 407 | }, 408 | strictFlags: true, 409 | }, 410 | undefined, 411 | ['--verbos'], // Missing 'e' 412 | ); 413 | mocked.restore(); 414 | 415 | expect(mocked.consoleError.calls[0][0]).toContain('--verbose'); 416 | expect(mocked.consoleError.calls[0][0]).toMatch(/did you mean/i); 417 | }); 418 | 419 | test('no suggestion when flag is too different', () => { 420 | const mocked = mockEnvFunctions(); 421 | cli( 422 | { 423 | flags: { 424 | verbose: Boolean, 425 | }, 426 | strictFlags: true, 427 | }, 428 | undefined, 429 | ['--xyz'], 430 | ); 431 | mocked.restore(); 432 | 433 | expect(mocked.consoleError.calls[0][0]).not.toMatch(/did you mean/i); 434 | }); 435 | 436 | test('no suggestion for very short unknown flags', () => { 437 | const mocked = mockEnvFunctions(); 438 | cli( 439 | { 440 | flags: { 441 | ab: Boolean, 442 | ac: Boolean, 443 | }, 444 | strictFlags: true, 445 | }, 446 | undefined, 447 | ['--ad'], // Short unknown flag (2 chars) shouldn't get suggestions 448 | ); 449 | mocked.restore(); 450 | 451 | expect(mocked.consoleError.called).toBe(true); 452 | expect(mocked.consoleError.calls[0][0]).toContain('--ad'); 453 | expect(mocked.consoleError.calls[0][0]).not.toMatch(/did you mean/i); 454 | }); 455 | 456 | test('reports multiple unknown flags', () => { 457 | const mocked = mockEnvFunctions(); 458 | cli( 459 | { 460 | flags: { 461 | verbose: Boolean, 462 | output: String, 463 | }, 464 | strictFlags: true, 465 | }, 466 | undefined, 467 | ['--verbos', '--outpu'], 468 | ); 469 | mocked.restore(); 470 | 471 | expect(mocked.consoleError.callCount).toBe(2); 472 | expect(mocked.consoleError.calls[0][0]).toContain('--verbos'); 473 | expect(mocked.consoleError.calls[1][0]).toContain('--outpu'); 474 | }); 475 | 476 | test('known flags still work', () => { 477 | const mocked = mockEnvFunctions(); 478 | const parsed = cli( 479 | { 480 | flags: { 481 | verbose: Boolean, 482 | output: String, 483 | }, 484 | strictFlags: true, 485 | }, 486 | undefined, 487 | ['--verbose', '--output', 'file.txt'], 488 | ); 489 | mocked.restore(); 490 | 491 | expect(mocked.consoleError.called).toBe(false); 492 | expect(mocked.processExit.called).toBe(false); 493 | expect(parsed.flags.verbose).toBe(true); 494 | expect(parsed.flags.output).toBe('file.txt'); 495 | }); 496 | 497 | test('strictFlags disabled by default', () => { 498 | const mocked = mockEnvFunctions(); 499 | const parsed = cli( 500 | { 501 | flags: { 502 | verbose: Boolean, 503 | }, 504 | }, 505 | undefined, 506 | ['--unknown'], 507 | ); 508 | mocked.restore(); 509 | 510 | expect(mocked.consoleError.called).toBe(false); 511 | expect(mocked.processExit.called).toBe(false); 512 | expect(parsed.unknownFlags.unknown).toEqual([true]); 513 | }); 514 | 515 | test('suggests flag aliases', () => { 516 | const mocked = mockEnvFunctions(); 517 | cli( 518 | { 519 | flags: { 520 | verbose: { 521 | type: Boolean, 522 | alias: 'v', 523 | }, 524 | }, 525 | strictFlags: true, 526 | }, 527 | undefined, 528 | ['--verbos'], 529 | ); 530 | mocked.restore(); 531 | 532 | expect(mocked.consoleError.calls[0][0]).toContain('--verbose'); 533 | }); 534 | }); 535 | }); 536 | }); 537 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 |

6 | cleye 7 |
8 | 9 |

10 | 11 | The intuitive command-line interface (CLI) development tool. 12 | 13 | ### Features 14 | - Minimal API surface 15 | - Powerful flag parsing 16 | - Strongly typed parameters and flags 17 | - Command support 18 | - Help documentation generation (customizable too!) 19 | 20 | → [Try it out online](https://stackblitz.com/edit/cleye-demo?devtoolsheight=50&file=examples/greet.ts&view=editor) 21 | 22 |
23 | 24 |

25 | 26 | 27 |

28 |

Already a sponsor? Join the discussion in the Development repo!

29 | 30 | ## Install 31 | 32 | ```bash 33 | npm i cleye 34 | ``` 35 | 36 | ## About 37 | _Cleye_ makes it very easy to develop command-line scripts in Node.js. It handles argv parsing to give you strongly typed parameters + flags and generates `--help` documentation based on the provided information. 38 | 39 | Here's an example script that simply logs: `Good morning/evening !`: 40 | 41 | _greet.js:_ 42 | ```ts 43 | import { cli } from 'cleye' 44 | 45 | // Parse argv 46 | const argv = cli({ 47 | name: 'greet.js', 48 | 49 | // Define parameters 50 | parameters: [ 51 | '', // First name is required 52 | '[last name]' // Last name is optional 53 | ], 54 | 55 | // Define flags/options 56 | flags: { 57 | 58 | // Parses `--time` as a string 59 | time: { 60 | type: String, 61 | description: 'Time of day to greet (morning or evening)', 62 | default: 'morning' 63 | } 64 | } 65 | }) 66 | 67 | const name = [argv._.firstName, argv._.lastName].filter(Boolean).join(' ') 68 | 69 | if (argv.flags.time === 'morning') { 70 | console.log(`Good morning ${name}!`) 71 | } else { 72 | console.log(`Good evening ${name}!`) 73 | } 74 | ``` 75 | 76 | 🛠 In development, type hints are provided on parsed flags and parameters: 77 |

78 |
79 | 80 |
81 | Type hints for Cleye's output are very verbose and readable 82 |
83 |
84 |

85 | 86 | 📖 Generated help documentation can be viewed with the `--help` flag: 87 | 88 | ```sh 89 | $ node greet.js --help 90 | 91 | greet.js 92 | 93 | Usage: 94 | greet.js [flags...] [last name] 95 | 96 | Flags: 97 | -h, --help Show help 98 | --time Time of day to greet (morning or evening) (default: "morning") 99 | ``` 100 | 101 | ✅ Run the script to see it in action: 102 | 103 | ```sh 104 | $ node greet.js John Doe --time evening 105 | 106 | Good evening John Doe! 107 | ``` 108 | 109 | ## Examples 110 | Want to dive right into some code? Check out some of these examples: 111 | 112 | - [**greet.js**](/examples/greet/index.ts): Working example from above 113 | - [**npm install**](/examples/npm/index.ts): Reimplementation of [`npm install`](https://docs.npmjs.com/cli/install/)'s CLI 114 | - [**tsc**](/examples/tsc/index.ts): Reimplementation of TypeScript [`tsc`](https://www.typescriptlang.org/docs/handbook/compiler-options.html)'s CLI 115 | - [**snap-tweet**](/examples/snap-tweet/index.ts): Reimplementation of [`snap-tweet`](https://github.com/privatenumber/snap-tweet)'s CLI 116 | - [**pkg-size**](/examples/pkg-size/index.ts): Reimplementation of [`pkg-size`](https://github.com/pkg-size/pkg-size)'s CLI 117 | 118 | ## Usage 119 | 120 | ### Arguments 121 | Arguments are values passed into the script that are not associated with any flags/options. 122 | 123 | For example, in the following command, the first argument is `file-a.txt` and the second is `file-b.txt`: 124 | 125 | ``` 126 | $ my-script file-a.txt file-b.txt 127 | ``` 128 | 129 | Arguments can be accessed from the `_` array-property of the returned object. 130 | 131 | Example: 132 | 133 | ```ts 134 | const argv = cli({ /* ... */ }) 135 | 136 | // $ my-script file-a.txt file-b.txt 137 | 138 | argv._ // => ["file-a.txt", "file-b.txt"] (string[]) 139 | ``` 140 | 141 | #### Parameters 142 | Parameters (aka _positional arguments_) are the names that map against argument values. Think of parameters as variable names and arguments as values associated with the variables. 143 | 144 | Parameters can be defined in the `parameters` array-property to make specific arguments accessible by name. This is useful for writing more readable code, enforcing validation, and generating help documentation. 145 | 146 | Parameters are defined in the following formats: 147 | - **Required parameters** are indicated by angle brackets (eg. ``). 148 | - **Optional parameters** are indicated by square brackets (eg. `[parameter name]`). 149 | - **Spread parameters** are indicated by `...` suffix (eg. `` or `[parameter name...]`). 150 | 151 | Note, required parameters cannot come after optional parameters, and spread parameters must be last. 152 | 153 | Parameters can be accessed in camelCase on the `_` property of the returned object. 154 | 155 | Example: 156 | 157 | ```ts 158 | const argv = cli({ 159 | parameters: [ 160 | '', 161 | '[optional parameter]', 162 | '[optional spread...]' 163 | ] 164 | }) 165 | 166 | // $ my-script a b c d 167 | 168 | argv._.requiredParameter // => "a" (string) 169 | argv._.optionalParameter // => "b" (string | undefined) 170 | argv._.optionalSpread // => ["c", "d"] (string[]) 171 | ``` 172 | 173 | #### End-of-flags 174 | End-of-flags (`--`) (aka _end-of-options_) allows users to pass in a subset of arguments. This is useful for passing in arguments that should be parsed separately from the rest of the arguments or passing in arguments that look like flags. 175 | 176 | An example of this is [`npm run`](https://docs.npmjs.com/cli/v8/commands/npm-run-script): 177 | ```sh 178 | $ npm run