├── .gitattributes ├── .prettierignore ├── .eslintignore ├── assets ├── colors.png ├── suggest.png ├── caporal.pxd │ ├── metadata.info │ └── QuickLook │ │ ├── Icon.tiff │ │ └── Thumbnail.tiff └── caporal.svg ├── .gitignore ├── .prettierrc.json ├── .editorconfig ├── dist-tests ├── tsconfig.json └── module.test.ts ├── src ├── help │ ├── templates │ │ ├── index.ts │ │ ├── header.ts │ │ ├── program.ts │ │ ├── usage.ts │ │ ├── custom.ts │ │ └── command.ts │ ├── __tests__ │ │ ├── utils.test.ts │ │ ├── help.spec.ts │ │ └── help.test.ts │ ├── types.ts │ ├── index.ts │ └── utils.ts ├── argument │ ├── find.ts │ ├── index.ts │ ├── synopsis.ts │ ├── __tests__ │ │ └── argument.spec.ts │ └── validate.ts ├── error │ ├── option-synopsis-syntax.ts │ ├── action.ts │ ├── invalid-validator.ts │ ├── base.ts │ ├── missing-flag.ts │ ├── missing-argument.ts │ ├── index.ts │ ├── fatal.ts │ ├── no-action.ts │ ├── multi-validation.ts │ ├── too-many-arguments.ts │ ├── __tests__ │ │ └── fatal.spec.ts │ ├── unknown-option.ts │ ├── unknown-command.ts │ └── validation.ts ├── utils │ ├── version.ts │ ├── fs.ts │ ├── __tests__ │ │ ├── levenshtein.test.ts │ │ ├── fs.test.ts │ │ └── suggest.test.ts │ ├── colorize.ts │ ├── levenshtein.ts │ ├── web │ │ └── process.ts │ └── suggest.ts ├── command │ ├── __fixtures__ │ │ └── example-cmd.ts │ ├── import.ts │ ├── __tests__ │ │ ├── import.test.ts │ │ ├── scan.test.ts │ │ └── find.test.ts │ ├── validate-call.ts │ ├── scan.ts │ └── find.ts ├── option │ ├── find.ts │ ├── mapping.ts │ ├── validate.ts │ ├── __tests__ │ │ ├── option.test.ts │ │ └── global.test.ts │ ├── utils.ts │ └── index.ts ├── __tests__ │ └── issue-163.spec.ts ├── web.ts ├── config │ └── index.ts ├── validator │ ├── array.ts │ ├── function.ts │ ├── regexp.ts │ ├── __tests__ │ │ ├── validate.spec.ts │ │ ├── regexp.test.ts │ │ ├── array.test.ts │ │ ├── function.spec.ts │ │ ├── utils.spec.ts │ │ └── caporal.spec.ts │ ├── validate.ts │ ├── utils.ts │ └── caporal.ts ├── index.ts ├── logger │ ├── __tests__ │ │ └── logger.spec.ts │ └── index.ts ├── parser │ └── index.ts ├── types.ts └── program │ └── __tests__ │ └── program.test.ts ├── examples ├── discover.ts ├── hello.js ├── validate-number.js ├── prog-without-command.ts ├── discover │ └── commands │ │ ├── create │ │ ├── job.ts │ │ └── service.ts │ │ ├── config │ │ ├── unset.ts │ │ └── set.ts │ │ ├── create.ts │ │ ├── describe.ts │ │ └── get.ts ├── validate-string.js ├── package.json ├── validate-boolean.js ├── hello-multi-args.js ├── hello-with-option.js ├── validate-function-async.js ├── validate-user-array.js ├── hello-with-option-alt.js ├── hello-world.js ├── validate-function.js ├── validate-array.js ├── pizza-hit.ts ├── pizza.ts └── pizza.js ├── test-utils └── jest-setup.ts ├── tsconfig.json ├── README.md ├── CONTRIBUTING.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ └── ci.yml ├── scripts ├── gen-readme.js ├── post-typedoc.sh ├── gen-contributors.js └── gen-dependents.js ├── DEVELOPING.md ├── .eslintrc.js ├── LICENSE ├── CONTRIBUTORS.md └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | examples/hello.js 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /assets/colors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/Caporal.js/main/assets/colors.png -------------------------------------------------------------------------------- /assets/suggest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/Caporal.js/main/assets/suggest.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | coverage 4 | .DS_Store 5 | .idea 6 | dist 7 | *.tgz 8 | .secret.md 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "printWidth": 90 6 | } 7 | -------------------------------------------------------------------------------- /assets/caporal.pxd/metadata.info: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/Caporal.js/main/assets/caporal.pxd/metadata.info -------------------------------------------------------------------------------- /assets/caporal.pxd/QuickLook/Icon.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/Caporal.js/main/assets/caporal.pxd/QuickLook/Icon.tiff -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /assets/caporal.pxd/QuickLook/Thumbnail.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/Caporal.js/main/assets/caporal.pxd/QuickLook/Thumbnail.tiff -------------------------------------------------------------------------------- /dist-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./**/*.ts"], 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@donmccurdy/caporal": ["../"] }, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dist-tests/module.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { program, Program } from "@donmccurdy/caporal" 3 | 4 | test("exports", (t) => { 5 | t.true(program instanceof Program, "exports.program") 6 | }) 7 | -------------------------------------------------------------------------------- /src/help/templates/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | export * from "./command.js" 6 | export * from "./header.js" 7 | export * from "./program.js" 8 | export * from "./usage.js" 9 | export * from "./custom.js" 10 | -------------------------------------------------------------------------------- /examples/discover.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | import { program } from "@caporal/core" 3 | import path from "path" 4 | 5 | program 6 | .name("kubectl") 7 | .version("1.0.0") 8 | .description("Mimics the kubectl CLI") 9 | .discover(path.join(__dirname, "discover/commands")) 10 | 11 | program.run() 12 | -------------------------------------------------------------------------------- /examples/hello.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { program } = require("@caporal/core") 4 | 5 | program 6 | .argument("", "Name to greet") 7 | .action(({ logger, args }) => { 8 | logger.info("Hello, %s!", args.name) 9 | }) 10 | 11 | program.run() 12 | 13 | /* 14 | $ ./hello.js Matt 15 | Hello, Matt! 16 | */ 17 | -------------------------------------------------------------------------------- /src/argument/find.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { Argument } from "../types.js" 7 | import type { Command } from "../command/index.js" 8 | 9 | export function findArgument(cmd: Command, name: string): Argument | undefined { 10 | return cmd.args.find((a) => a.name === name) 11 | } 12 | -------------------------------------------------------------------------------- /src/error/option-synopsis-syntax.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | 8 | export class OptionSynopsisSyntaxError extends BaseError { 9 | constructor(synopsis: string) { 10 | super(`Syntax error in option synopsis: ${synopsis}`, { synopsis }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/error/action.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | 8 | export class ActionError extends BaseError { 9 | constructor(error: string | Error) { 10 | const message = typeof error === "string" ? error : error.message 11 | super(message, { error }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/error/invalid-validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { Validator } from "../types.js" 8 | export class InvalidValidatorError extends BaseError { 9 | constructor(validator: Validator) { 10 | super("Caporal setup error: Invalid flag validator setup.", { validator }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/validate-number.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .argument("", "Year of birth", { validator: program.NUMBER }) 6 | .action(({ logger, args }) => { 7 | // will print something like: "year: 1985 (type: number)" 8 | logger.info("year: %d (type: %s)", args.year, typeof args.year) 9 | }) 10 | 11 | program.run() 12 | -------------------------------------------------------------------------------- /examples/prog-without-command.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | import { program } from "@caporal/core" 3 | 4 | program 5 | .version("1.0.0") 6 | .description("Take a ride") 7 | .argument("", "What's your destination", { 8 | validator: ["New-York", "Portland", "Paris"], 9 | }) 10 | .option("--tip", "Tip to give to the driver", { validator: program.NUMBER }) 11 | 12 | program.run() 13 | -------------------------------------------------------------------------------- /src/utils/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import path from "path" 6 | 7 | export function detectVersion(): string | undefined { 8 | try { 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | return require(path.join(__filename, "..", "..", "..", "package.json")).version 11 | // eslint-disable-next-line no-empty 12 | } catch (e) {} 13 | } 14 | -------------------------------------------------------------------------------- /examples/discover/commands/create/job.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Create a job with the specified name.") 5 | .argument("", "Job name") 6 | .action(({ logger }) => { 7 | logger.info("Output of `kubectl create job` command") 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /examples/validate-string.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .argument("", "Postal code", { validator: program.STRING }) 6 | .action(({ logger, args }) => { 7 | // will print something like: "Postal code: 75001 (type: string)" 8 | logger.info("Postal code: %s (type: %s)", args.postalCode, typeof args.postalCode) 9 | }) 10 | 11 | program.run() 12 | -------------------------------------------------------------------------------- /test-utils/jest-setup.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Winston `Console transport` uses console._stdout.write() 3 | to log things, which is polluting jest logs, so let's 4 | overwrite that 5 | */ 6 | Object.defineProperty(global.console, "_stdout", { 7 | value: { 8 | write: jest.fn(), 9 | }, 10 | }) 11 | 12 | // Because we instantiate a lot of `Program` in tests, 13 | // we set a lot of listeners on process 14 | process.setMaxListeners(100) 15 | -------------------------------------------------------------------------------- /examples/discover/commands/config/unset.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Sets an individual value in a kubeconfig file") 5 | .argument("", "Property name") 6 | .action(({ logger }) => { 7 | logger.info("Output of `kubectl config unset` command") 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /examples/discover/commands/create.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Create a resource from a file or from stdin.") 5 | .option("-f ", "JSON and YAML file") 6 | .action(({ logger }) => { 7 | logger.info("Output of `kubectl create` command") 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /examples/discover/commands/create/service.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Create a service with the specified name.") 5 | .argument("", "Service name") 6 | .action(({ logger }) => { 7 | logger.info("Output of `kubectl create service` command") 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caporal-examples", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "dependencies": { 9 | "@types/node": "^11.9.4", 10 | "@caporal/core": "file:../" 11 | }, 12 | "bin": { 13 | "pizza": "./pizza.js" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "private": true 18 | } 19 | -------------------------------------------------------------------------------- /examples/validate-boolean.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .argument("", "Your answer", { validator: program.BOOLEAN }) 6 | .action(({ logger, args }) => { 7 | // will print something like: "Answer: true (type: boolean)" 8 | // even if "1" is provided as argument 9 | logger.info("Answer: %s (type: %s)", args.answer, typeof args.answer) 10 | }) 11 | 12 | program.run() 13 | -------------------------------------------------------------------------------- /src/command/__fixtures__/example-cmd.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @ignore 4 | */ 5 | /* istanbul ignore file */ 6 | import type { Command } from "../" 7 | import type { CreateCommandParameters } from "../../types" 8 | 9 | export default function ({ createCommand }: CreateCommandParameters): Command { 10 | return createCommand("My command").action(({ logger }) => { 11 | logger.info("Output of my command") 12 | return "hey" 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /examples/discover/commands/config/set.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Sets an individual value in a kubeconfig file") 5 | .argument("", "Property name") 6 | .argument("", "Property value") 7 | .action(({ logger }) => { 8 | logger.info("Output of `kubectl config set` command") 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /examples/hello-multi-args.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | program 4 | .argument("", "Name to greet") 5 | .argument("", "Another argument") 6 | .argument("[third-arg]", "This argument is optional") 7 | .action(({ logger, args }) => 8 | // Notice that args are camel-cased, so becomes otherName 9 | logger.info("Hello, %s and %s!", args.name, args.otherName), 10 | ) 11 | 12 | program.run() 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "compilerOptions": { 4 | "paths": { "@donmccurdy/caporal": ["./"] }, 5 | "moduleResolution": "nodenext", 6 | "typeRoots": ["node_modules/@types"], 7 | "target": "ES2020", 8 | "module": "ESNext", 9 | "lib": ["ES2020"], 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "strict": false, 13 | "noImplicitAny": true, 14 | "forceConsistentCasingInFileNames": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import { glob } from "glob" 6 | import fs from "fs" 7 | 8 | export function readdir(dirPath: string, extensions = "js,ts"): Promise { 9 | return new Promise((resolve, reject) => { 10 | if (!fs.existsSync(dirPath)) { 11 | return reject(new Error(`'${dirPath}' does not exist!`)) 12 | } 13 | glob(`**/*.{${extensions}}`, { cwd: dirPath }).then(resolve).catch(reject) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /examples/discover/commands/describe.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Show details of a specific resource or group of resources") 5 | .argument("", "Type of object") 6 | .argument("", "Name prefix") 7 | .action(({ logger }) => { 8 | logger.info("Output of `kubectl describe` command") 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /examples/discover/commands/get.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCommandParameters, Command } from "@caporal/core" 2 | 3 | export default function ({ createCommand }: CreateCommandParameters): Command { 4 | return createCommand("Display one or many resources") 5 | .option("-o, --output", "Output format", { 6 | validator: ["json", "yaml", "wide"], 7 | default: "yaml", 8 | }) 9 | .action(({ logger }) => { 10 | logger.info("Output of `kubectl get` command") 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /examples/hello-with-option.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const program = require("@caporal/core") 3 | program 4 | .argument("", "Name to greet") 5 | .option("--greating ", "Greating to use", { 6 | default: "Hello", 7 | }) 8 | .action(({ logger, args, options }) => { 9 | logger.info("%s, %s!", options.greating, args.name) 10 | }) 11 | 12 | program.run() 13 | 14 | /* 15 | $ ./hello.js Matt --greeting Hi 16 | Hi, Matt! 17 | 18 | $ ./hello.js Matt 19 | Hello, Matt! 20 | */ 21 | -------------------------------------------------------------------------------- /examples/validate-function-async.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-undef */ 3 | const { program } = require("@caporal/core") 4 | 5 | program 6 | .description("Get username from ID") 7 | .argument("", "User ID", { 8 | validator: function (id) { 9 | return fetch(`/api/user/${id}`).then(() => { 10 | return id 11 | }) 12 | }, 13 | }) 14 | .action(({ logger, args }) => { 15 | logger.info("User ID: %s", args.id) 16 | }) 17 | 18 | program.run() 19 | -------------------------------------------------------------------------------- /src/error/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { ErrorMetadata } from "../types.js" 7 | 8 | export class BaseError extends Error { 9 | public meta: ErrorMetadata 10 | 11 | constructor(message: string, meta: ErrorMetadata = {}) { 12 | super(message) 13 | Object.setPrototypeOf(this, new.target.prototype) 14 | this.name = this.constructor.name 15 | this.meta = meta 16 | Error.captureStackTrace(this, this.constructor) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/validate-user-array.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .argument("", "Vote for your favorite City", { 6 | // just provide an array 7 | validator: ["Paris", "New-York", "Tokyo"], 8 | }) 9 | .action(({ logger, args }) => { 10 | // the program will throw an error 11 | // if user provided city is not in ["Paris", "New-York", "Tokyo"] 12 | logger.info("Favorite city: %s", args.city) 13 | }) 14 | 15 | program.run() 16 | -------------------------------------------------------------------------------- /src/error/missing-flag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { Command } from "../command/index.js" 8 | import { Option } from "../types.js" 9 | import chalk from "chalk" 10 | 11 | export class MissingFlagError extends BaseError { 12 | constructor(flag: Option, command: Command) { 13 | const msg = `Missing required flag ${chalk.bold(flag.allNotations.join(" | "))}.` 14 | super(msg, { flag, command }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/error/missing-argument.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { Command } from "../command/index.js" 8 | import { Argument } from "../types.js" 9 | import chalk from "chalk" 10 | 11 | export class MissingArgumentError extends BaseError { 12 | constructor(argument: Argument, command: Command) { 13 | const msg = `Missing required argument ${chalk.bold(argument.name)}.` 14 | super(msg, { argument, command }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/help/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { getDefaultValueHint } from "../utils.js" 3 | import { createArgument } from "../../argument/index.js" 4 | import { CaporalValidator } from "../../validator/caporal.js" 5 | 6 | test("getDefaultValueHint() should return the correct value hint", (t) => { 7 | const arg = createArgument("", "My arg", { 8 | validator: CaporalValidator.BOOLEAN, 9 | default: true, 10 | }) 11 | t.is(getDefaultValueHint(arg), "default: true") 12 | }) 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fork of the `@caporal/core` package, with updated dependencies and builds. For the original project and documentation, see https://caporal.io/. 4 | 5 | ## Quickstart 6 | 7 | Installation: 8 | 9 | ```bash 10 | npm install @donmccurdy/caporal 11 | ``` 12 | 13 | Import: 14 | 15 | ```js 16 | // CommonJS 17 | const { program } = require('@donmccurdy/caporal'); 18 | 19 | // ESM 20 | import { program } from '@donmccurdy/caporal'; 21 | 22 | program 23 | .command(...) 24 | .action(...); 25 | ``` 26 | -------------------------------------------------------------------------------- /src/command/import.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-ignore */ 2 | /* eslint-disable @typescript-eslint/camelcase */ 3 | /** 4 | * @packageDocumentation 5 | * @internal 6 | */ 7 | import { CommandCreator } from "../types.js" 8 | import path from "path" 9 | 10 | export async function importCommand(file: string): Promise { 11 | const { dir, name } = path.parse(file) 12 | const filename = path.join(dir, name) 13 | const mod = require(filename) 14 | return mod.default ?? mod 15 | } 16 | -------------------------------------------------------------------------------- /src/option/find.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { Command } from "../command/index.js" 7 | import type { Option } from "../types.js" 8 | 9 | /** 10 | * Find an option from its name for a given command 11 | * 12 | * @param cmd Command object 13 | * @param name Option name, short or long, camel-cased 14 | */ 15 | export function findOption(cmd: Command, name: string): Option | undefined { 16 | return cmd.options.find((o) => o.allNames.find((opt) => opt === name)) 17 | } 18 | -------------------------------------------------------------------------------- /examples/hello-with-option-alt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | program 4 | .argument("", "Name to greet") 5 | // we will be able to use either `-g` or `--greeting` in the command line 6 | .option("-g, --greating ", "Greating to use", { 7 | default: "Hello", 8 | }) 9 | .action(({ logger, args, options }) => 10 | logger.info("%s, %s!", options.greating, args.name), 11 | ) 12 | 13 | program.run() 14 | 15 | /* 16 | $ ./hello.js Matt -g Hi 17 | Hi, Matt! 18 | */ 19 | -------------------------------------------------------------------------------- /src/command/__tests__/import.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { importCommand } from "../import.js" 3 | import path from "path" 4 | import { fileURLToPath } from "url" 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | // TODO(test): Support scan w/o require()? 9 | test.skip("importCommand() should import a command from a file", async (t) => { 10 | const creator = await importCommand( 11 | path.resolve(__dirname, "../__fixtures__/example-cmd.ts"), 12 | ) 13 | t.truthy(creator) 14 | }) 15 | -------------------------------------------------------------------------------- /examples/hello-world.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // file: hello-world.js (make the file executable using `chmod +x hello.js`) 3 | 4 | // Caporal provides you with a program instance 5 | const { program } = require("@caporal/core") 6 | 7 | // Simplest program ever: this program does only one thing 8 | program.action(({ logger }) => { 9 | logger.info("Hello, world!") 10 | }) 11 | 12 | // always run the program at the end 13 | program.run() 14 | 15 | /* 16 | # Now, in your terminal: 17 | 18 | $ ./hello-world.js 19 | Hello, world! 20 | 21 | */ 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/levenshtein.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { levenshtein } from "../levenshtein.js" 3 | 4 | const FIXTURES: [string, string, number][] = [ 5 | ["hello", "bye", 5], 6 | ["some", "sometimes", 5], 7 | ["john", "jane", 3], 8 | ["exemple", "example", 1], 9 | ["", "", 0], 10 | ["not-empty", "", 9], 11 | ["", "not-empty", 9], 12 | ] 13 | 14 | test("levenshtein", (t) => { 15 | for (const [a, b, expected] of FIXTURES) { 16 | t.is(levenshtein(a, b), expected, `.levenshtein('${a}', '${b}')`) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/error/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | export * from "./action.js" 6 | export * from "./base.js" 7 | export * from "./fatal.js" 8 | export * from "./invalid-validator.js" 9 | export * from "./missing-argument.js" 10 | export * from "./missing-flag.js" 11 | export * from "./multi-validation.js" 12 | export * from "./no-action.js" 13 | export * from "./option-synopsis-syntax.js" 14 | export * from "./unknown-option.js" 15 | export * from "./unknown-command.js" 16 | export * from "./validation.js" 17 | export * from "./too-many-arguments.js" 18 | -------------------------------------------------------------------------------- /src/utils/colorize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import c from "chalk" 6 | 7 | export function colorize(text: string): string { 8 | return text 9 | .replace(/<([^>]+)>/gi, (match) => { 10 | return c.hex("#569cd6")(match) 11 | }) 12 | .replace(//gi, (match) => { 13 | return c.keyword("orange")(match) 14 | }) 15 | .replace(/\[([^[\]]+)\]/gi, (match) => { 16 | return c.hex("#aaa")(match) 17 | }) 18 | .replace(/ --?([^\s,]+)/gi, (match) => { 19 | return c.green(match) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /examples/validate-function.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .argument("", "Year of birth", { 6 | validator: function (value) { 7 | if (value > new Date().getFullYear()) { 8 | throw Error("Year cannot be in the future!") 9 | } 10 | // cast to Number 11 | return +value 12 | }, 13 | }) 14 | .action(({ logger, args }) => { 15 | // will print something like: "year: 1985 (type: number)" 16 | logger.info("year: %d (type: %s)", args.year, typeof args.year) 17 | }) 18 | 19 | program.run() 20 | -------------------------------------------------------------------------------- /src/help/templates/header.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { TemplateContext, Template } from "../types.js" 6 | 7 | export const header: Template = (ctx: TemplateContext) => { 8 | const { prog, chalk: c, spaces, eol, eol2 } = ctx 9 | const version = process.env?.NODE_ENV === "test" ? "" : prog.getVersion() 10 | return ( 11 | eol + 12 | spaces + 13 | (prog.getName() || prog.getBin()) + 14 | " " + 15 | (version || "") + 16 | (prog.getDescription() ? " \u2014 " + c.dim(prog.getDescription()) : "") + 17 | eol2 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/__tests__/issue-163.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseArgv } from "../parser" 2 | 3 | describe("issue #163", () => { 4 | it("should combine short and long versions of option flag when using array option", () => { 5 | const results = parseArgv( 6 | { 7 | variadic: ["file"], 8 | alias: { 9 | f: "file", 10 | }, 11 | }, 12 | ["-f", "my-value", "--file", "joe", "--file", "1", "-f", "xxx"], 13 | ) 14 | expect(results.options.f).toEqual(["my-value", "joe", 1, "xxx"]) 15 | expect(results.options.file).toEqual(["my-value", "joe", 1, "xxx"]) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/error/fatal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { logger } from "../logger/index.js" 7 | import type { BaseError } from "./base.js" 8 | 9 | /** 10 | * @param err - Error object 11 | */ 12 | export function fatalError(error: BaseError): void { 13 | if (logger.level == "debug") { 14 | logger.log({ 15 | level: "error", 16 | ...error, 17 | message: error.message + "\n\n" + error.stack, 18 | stack: error.stack, 19 | name: error.name, 20 | }) 21 | } else { 22 | logger.error(error.message) 23 | } 24 | process.exitCode = 1 25 | } 26 | -------------------------------------------------------------------------------- /src/error/no-action.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { Command } from "../command/index.js" 8 | 9 | export class NoActionError extends BaseError { 10 | constructor(cmd?: Command) { 11 | let message 12 | if (cmd && !cmd.isProgramCommand()) { 13 | message = `Caporal Error: You haven't defined any action for command '${cmd.name}'.\nUse .action() to do so.` 14 | } else { 15 | message = `Caporal Error: You haven't defined any action for program.\nUse .action() to do so.` 16 | } 17 | super(message, { cmd }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/option/mapping.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { Command } from "../command/index.js" 7 | import map from "lodash/map.js" 8 | import zipObject from "lodash/zipObject.js" 9 | import invert from "lodash/invert.js" 10 | import pickBy from "lodash/pickBy.js" 11 | 12 | export function getOptsMapping(cmd: Command): Record { 13 | const names = map(cmd.options, "name") 14 | const aliases = map(cmd.options, (o) => o.shortName || o.longName) 15 | const result = zipObject(names, aliases) 16 | return pickBy({ ...result, ...invert(result) }) as Record 17 | } 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Caporal 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | ## How to contribute 6 | 7 | 1. Create a issue explaining your contribution then wait for our GO. 8 | 2. Create a branch, prefixed by `feat-` or `fix-` for features or bugfixes. 9 | 3. Develop 10 | 4. Ensure you add some tests covering your code. (test with `npm test` and `npm run coverage`) 11 | 5. Ensure `npm test` passes 12 | 6. Add your files to git staging aread (`git add ...`) 13 | 7. Commit your changes (you should be asked for some infomations in order to properly format you commit message) 14 | 8. Push your branch 15 | 9. Open a Pull Request 16 | -------------------------------------------------------------------------------- /examples/validate-array.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | // here we combine the 2 validators, to get back an array of strings 6 | .argument("", "Favorite years(s)", { validator: program.ARRAY | program.STRING }) 7 | .action(({ logger, args }) => { 8 | // if "2032,2056" is provided, will print: "years: ["2032","2056"] (is array: true)" 9 | // if "2032" is provided, will print: "years: ["2032"] (is array: true)" 10 | // if "yes" is provided, will print: "years: ["yes"] (is array: true)" 11 | logger.info("years: %j (is array: %s)", args.years, Array.isArray(args.years)) 12 | }) 13 | 14 | program.run() 15 | -------------------------------------------------------------------------------- /src/web.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | /// 6 | /// 7 | export * from "./index.js" 8 | import { program, chalk } from "./index.js" 9 | 10 | require("expose-loader?process!./utils/web/process") 11 | 12 | // specific error handling for web 13 | window.addEventListener("unhandledrejection", function (err: PromiseRejectionEvent) { 14 | program.emit("error", err.reason) 15 | }) 16 | 17 | // override chalk level for web 18 | chalk.level = 3 19 | 20 | program 21 | .version("1.0.0") 22 | .name("Caporal Playground") 23 | .description("Dynamicaly generated playground program") 24 | .bin("play") 25 | -------------------------------------------------------------------------------- /src/command/__tests__/scan.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { scanCommands } from "../scan.js" 3 | import { Program } from "../../program/index.js" 4 | import path from "path" 5 | import { Command } from "../index.js" 6 | import { fileURLToPath } from "url" 7 | import { dirname } from "path" 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)) 10 | 11 | // TODO(test): Support scan w/o require()? 12 | test.skip("scanCommands() should scan commands", async (t) => { 13 | const prog = new Program() 14 | const commands = await scanCommands(prog, path.join(__dirname, "../__fixtures__/")) 15 | commands.forEach((cmd) => { 16 | t.true(cmd instanceof Command) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { Configurator } from "../types.js" 6 | 7 | export function createConfigurator(defaults: T): Configurator { 8 | const _defaults = defaults 9 | let config = defaults 10 | return { 11 | reset(): T { 12 | config = _defaults 13 | return config 14 | }, 15 | get(key: K): T[K] { 16 | return config[key] 17 | }, 18 | getAll(): T { 19 | return config 20 | }, 21 | set(props: Partial): T { 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | return Object.assign(config as any, props) 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/help/templates/program.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { TemplateContext, Template } from "../types.js" 6 | import { getOptionsTable, getCommandsTable } from "../utils.js" 7 | 8 | export const program: Template = async (ctx: TemplateContext) => { 9 | const { prog, globalOptions, eol, eol3, colorize, tpl } = ctx 10 | const commands = await prog.getAllCommands() 11 | const options = Array.from(globalOptions.keys()) 12 | const help = 13 | (await prog.getSynopsis()) + 14 | eol3 + 15 | (await tpl("custom", ctx)) + 16 | getCommandsTable(commands, ctx) + 17 | eol + 18 | getOptionsTable(options, ctx, "GLOBAL OPTIONS") 19 | 20 | return colorize(help) 21 | } 22 | -------------------------------------------------------------------------------- /src/error/multi-validation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import chalk from "chalk" 8 | import { colorize } from "../utils/colorize.js" 9 | import type { Command } from "../command/index.js" 10 | 11 | export class ValidationSummaryError extends BaseError { 12 | constructor(cmd: Command, errors: BaseError[]) { 13 | const plural = errors.length > 1 ? "s" : "" 14 | const msg = 15 | `The following error${plural} occured:\n` + 16 | errors.map((e) => "- " + e.message.replace(/\n/g, "\n ")).join("\n") + 17 | "\n\n" + 18 | chalk.dim("Synopsis: ") + 19 | colorize(cmd.synopsis) 20 | super(msg, { errors }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/__tests__/fs.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { readdir } from "../fs.js" 3 | import { fileURLToPath } from "url" 4 | import { dirname } from "path" 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | test("should resolve to a file list on success", async (t) => { 9 | const files = await readdir(__dirname) 10 | t.deepEqual( 11 | files.sort(), 12 | ["fs.test.ts", "levenshtein.test.ts", "suggest.test.ts"], 13 | "resolves file list", 14 | ) 15 | }) 16 | 17 | test("should reject if directory does note exist", async (t) => { 18 | await t.throwsAsync( 19 | async () => await readdir("/does/not/exist"), 20 | undefined, 21 | "throws on invalid directory", 22 | ) 23 | }) 24 | -------------------------------------------------------------------------------- /src/utils/levenshtein.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | export function levenshtein(a: string, b: string): number { 6 | if (a === b) { 7 | return 0 8 | } 9 | if (!a.length || !b.length) { 10 | return a.length || b.length 11 | } 12 | let cell = 0 13 | let lcell = 0 14 | let dcell = 0 15 | const row = [...Array(b.length + 1).keys()] 16 | for (let i = 0; i < a.length; i++) { 17 | dcell = i 18 | lcell = i + 1 19 | for (let j = 0; j < b.length; j++) { 20 | cell = a[i] === b[j] ? dcell : Math.min(...[dcell, row[j + 1], lcell]) + 1 21 | dcell = row[j + 1] 22 | row[j] = lcell 23 | lcell = cell 24 | } 25 | row[row.length - 1] = cell 26 | } 27 | return cell 28 | } 29 | -------------------------------------------------------------------------------- /examples/pizza-hit.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | // file: pizza-hit.ts 3 | import program from "@caporal/core" 4 | 5 | program 6 | // First possible command: "order" 7 | .command("order", "Order a pizza") 8 | .argument("", "Type of pizza") 9 | .option("-e, --extra-ingredients ", "Extra ingredients") 10 | .action(({ logger, args, options }) => { 11 | logger.info("Order received: %s", args.type) 12 | if (options.extraIngredients) { 13 | logger.info("Extra: %s", options.extraIngredients) 14 | } 15 | }) 16 | 17 | // Another command: "cancel" 18 | .command("cancel", "Cancel an order") 19 | .argument("", "Order id") 20 | .action(({ logger, args }) => { 21 | logger.info("Order canceled: %s", args.orderId) 22 | }) 23 | 24 | program.run() 25 | -------------------------------------------------------------------------------- /src/utils/web/process.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A process mock for the web 3 | * 4 | * @packageDocumentation 5 | * @internal 6 | */ 7 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 8 | /* eslint-disable @typescript-eslint/no-empty-function */ 9 | /* eslint-disable no-console */ 10 | export const version = process.version 11 | export const argv = ["node", "play.ts"] 12 | export const execArgv = [] 13 | export const exitCode = 0 14 | export const fake = true 15 | export const on = () => {} 16 | export const once = () => {} 17 | export const exit = function (code = 0) { 18 | if (code > 0) { 19 | return console.debug( 20 | `[playground process exiting with code ${code} - usually a fatal error]`, 21 | ) 22 | } 23 | console.debug(`[process exiting with code ${code}]`) 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | **Would you be able to work on it and provide a pull request ?** 22 | 23 | - [ ] Yes 24 | - [ ] No 25 | -------------------------------------------------------------------------------- /src/help/templates/usage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { TemplateContext, Template } from "../types.js" 6 | 7 | export const usage: Template = async (ctx: TemplateContext) => { 8 | const { tpl, prog, chalk: c, spaces, eol } = ctx 9 | let { cmd } = ctx 10 | 11 | // if help is asked without a `cmd` and that no command exists 12 | // within the program, override `cmd` with the program-command 13 | if (!cmd && !(await prog.hasCommands())) { 14 | ctx.cmd = cmd = prog.progCommand 15 | } 16 | 17 | // usage 18 | const usage = `${spaces + c.bold("USAGE")} ${cmd?.name ? "— " + c.dim(cmd.name) : ""} 19 | ${eol + spaces + spaces + c.dim("\u25B8")} ` 20 | 21 | const next = cmd ? await tpl("command", ctx) : await tpl("program", ctx) 22 | 23 | return usage + next 24 | } 25 | -------------------------------------------------------------------------------- /src/help/templates/custom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { TemplateContext, Template } from "../types.js" 6 | 7 | export const custom: Template = (ctx: TemplateContext) => { 8 | const { prog, cmd, eol2, eol3, chalk, colorize, customHelp, indent } = ctx 9 | const data = customHelp.get(cmd || prog) 10 | if (data) { 11 | const txt = data 12 | .map(({ text, options }) => { 13 | let str = "" 14 | if (options.sectionName) { 15 | str += chalk.bold(options.sectionName) + eol2 16 | } 17 | const subtxt = options.colorize ? colorize(text) : text 18 | str += options.sectionName ? indent(subtxt) : subtxt 19 | return str + eol3 20 | }) 21 | .join("") 22 | return indent(txt) + eol3 23 | } 24 | return "" 25 | } 26 | -------------------------------------------------------------------------------- /src/validator/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { ParserTypes, Argument, Option } from "../types.js" 7 | import { ValidationError } from "../error/index.js" 8 | 9 | /** 10 | * Validate using an array of valid values. 11 | * 12 | * @param validator 13 | * @param value 14 | * @ignore 15 | */ 16 | export function validateWithArray( 17 | validator: ParserTypes[], 18 | value: ParserTypes | ParserTypes[], 19 | context: Argument | Option, 20 | ): ParserTypes | ParserTypes[] { 21 | if (Array.isArray(value)) { 22 | value.forEach((v) => validateWithArray(validator, v, context)) 23 | } else if (validator.includes(value) === false) { 24 | throw new ValidationError({ 25 | validator, 26 | value, 27 | context, 28 | }) 29 | } 30 | return value 31 | } 32 | -------------------------------------------------------------------------------- /src/command/validate-call.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import { ParserResult, ParserProcessedResult } from "../types.js" 6 | import { validateArgs } from "../argument/validate.js" 7 | import { validateOptions } from "../option/validate.js" 8 | import type { Command } from "./index.js" 9 | 10 | export async function validateCall( 11 | cmd: Command, 12 | result: ParserResult, 13 | ): Promise { 14 | const { args: parsedArgs, options: parsedFlags } = result 15 | // check if there are some global flags to handle 16 | const { options: flags, errors: flagsErrors } = await validateOptions(cmd, parsedFlags) 17 | const { args, errors: argsErrors } = await validateArgs(cmd, parsedArgs) 18 | return { ...result, args, options: flags, errors: [...argsErrors, ...flagsErrors] } 19 | } 20 | -------------------------------------------------------------------------------- /src/validator/function.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { ParserTypes, FunctionValidator, Argument, Option } from "../types.js" 7 | import { ValidationError } from "../error/index.js" 8 | 9 | export async function validateWithFunction( 10 | validator: FunctionValidator, 11 | value: ParserTypes | ParserTypes[], 12 | context: Argument | Option, 13 | ): Promise { 14 | if (Array.isArray(value)) { 15 | return Promise.all( 16 | value.map((v) => { 17 | return validateWithFunction(validator, v, context) as Promise 18 | }), 19 | ) 20 | } 21 | try { 22 | return await validator(value) 23 | } catch (error) { 24 | throw new ValidationError({ 25 | validator, 26 | value, 27 | error, 28 | context, 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/validator/regexp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { ParserTypes, Argument, Option } from "../types.js" 7 | import { ValidationError } from "../error/index.js" 8 | 9 | /** 10 | * Validate using a RegExp 11 | * 12 | * @param validator 13 | * @param value 14 | * @ignore 15 | */ 16 | export function validateWithRegExp( 17 | validator: RegExp, 18 | value: ParserTypes | ParserTypes[], 19 | context: Argument | Option, 20 | ): ParserTypes | ParserTypes[] { 21 | if (Array.isArray(value)) { 22 | return value.map((v) => { 23 | return validateWithRegExp(validator, v, context) as ParserTypes 24 | }) 25 | } 26 | if (!validator.test(value + "")) { 27 | throw new ValidationError({ 28 | validator: validator, 29 | value, 30 | context, 31 | }) 32 | } 33 | return value 34 | } 35 | -------------------------------------------------------------------------------- /src/validator/__tests__/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "../validate" 2 | import { createArgument } from "../../argument" 3 | import { FunctionValidator } from "../../types" 4 | 5 | describe("validate()", () => { 6 | const arg = createArgument("", "Fake arg") 7 | it("should handle function validators", () => { 8 | const validator: FunctionValidator = function (value) { 9 | if (value !== "hey" && value !== "ho") { 10 | throw Error("my error") 11 | } 12 | return value 13 | } 14 | return expect(validate("hey", validator, arg)).resolves.toEqual("hey") 15 | }) 16 | it("should handle regexp validators", () => { 17 | return expect(validate("TEST", /[A-Z]+/, arg)).toEqual("TEST") 18 | }) 19 | it("should handle unknown validators", () => { 20 | return expect(validate("TEST", 1000, arg)).toEqual("TEST") 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/command/__tests__/find.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { Program } from "../../program/index.js" 3 | import { findCommand } from "../find.js" 4 | 5 | test("findCommand()", async (t) => { 6 | const prog = new Program() 7 | prog.discover(".") 8 | const knownCmd = prog.command("my-command", "my command") 9 | 10 | t.is(await findCommand(prog, ["my-command"]), knownCmd, "should find a known command") 11 | 12 | // TODO(test): Test command discovery. 13 | // const discoverableCmd = createCommand(prog, "discoverable", "my command") 14 | // const commandCreator = sinon.stub().returns(discoverableCmd) 15 | // const importCommandMock = importCommand as jest.MockedFunction 16 | // importCommandMock.mockResolvedValue(commandCreator) 17 | // t.is(await findCommand(prog, ["git init"]), discoverableCmd, "should find a discoverable command") 18 | }) 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: mattallty 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | ```ts 16 | // Javascript or Typescript code to reproduce bug 17 | ``` 18 | 19 | **Expected behavior** 20 | A clear and concise description/output of what you expected to happen. 21 | 22 | **Actual behavior** 23 | A clear and concise description/output of what actually happens. 24 | 25 | **Environment informations (please complete the following information):** 26 | 27 | - OS: [e.g. Linux, Mac] 28 | - OS version: 29 | - Shell: [e.g. bash, zsh, fish] 30 | - Caporal version: [e.g. 1.0.1] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /src/help/templates/command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 6 | import type { TemplateContext, Template } from "../types.js" 7 | import { getOptionsTable, getArgumentsTable } from "../utils.js" 8 | import sortBy from "lodash/sortBy.js" 9 | 10 | export const command: Template = async (ctx: TemplateContext) => { 11 | const { cmd, globalOptions: globalFlags, eol, eol3, colorize, tpl } = ctx 12 | 13 | const options = sortBy(cmd!.options, "name"), 14 | globalOptions = Array.from(globalFlags.keys()) 15 | 16 | const help = 17 | cmd!.synopsis + 18 | eol3 + 19 | (await tpl("custom", ctx)) + 20 | getArgumentsTable(cmd!.args, ctx) + 21 | eol + 22 | getOptionsTable(options, ctx) + 23 | eol + 24 | getOptionsTable(globalOptions, ctx, "GLOBAL OPTIONS") 25 | 26 | return colorize(help) 27 | } 28 | -------------------------------------------------------------------------------- /src/error/too-many-arguments.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { ArgumentsRange } from "../types.js" 8 | import { Command } from "../command/index.js" 9 | import { format } from "util" 10 | import c from "chalk" 11 | 12 | export class TooManyArgumentsError extends BaseError { 13 | constructor(cmd: Command, range: ArgumentsRange, argsCount: number) { 14 | const expArgsStr = 15 | range.min === range.max 16 | ? `exactly ${range.min}.` 17 | : `between ${range.min} and ${range.max}.` 18 | 19 | const cmdName = cmd.isProgramCommand() ? "" : `for command ${c.bold(cmd.name)}` 20 | const message = format( 21 | `Too many argument(s) %s. Got %s, expected %s`, 22 | cmdName, 23 | c.bold.redBright(argsCount), 24 | c.bold.greenBright(expArgsStr), 25 | ) 26 | super(message, { command: cmd }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/gen-readme.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require("fs") 3 | const path = require("path") 4 | const replace = require("lodash/replace") 5 | 6 | const README_PATH = path.join(__dirname, "..", "docs", "README.md") 7 | 8 | let readme = fs.readFileSync(README_PATH, "utf-8") 9 | const regex = /()([\s\S]*?)(?=)/gm 10 | 11 | const newReadme = replace(readme, regex, function (r1, r2, r3) { 12 | const file = path.join(__dirname, "..", "docs", r3) 13 | try { 14 | const contents = fs.readFileSync(file, "utf-8") 15 | return r2 + "\n\n" + contents.trim() + "\n\n" 16 | } catch (e) { 17 | console.error("Error reading %s", file) 18 | return r1 19 | } 20 | }) 21 | 22 | try { 23 | fs.writeFileSync(README_PATH, newReadme) 24 | console.log("%s updated.", README_PATH) 25 | process.exit(0) 26 | } catch (e) { 27 | console.error(e) 28 | process.exit(1) 29 | } 30 | -------------------------------------------------------------------------------- /scripts/post-typedoc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Rewrite headers 4 | if [[ "$OSTYPE" == "darwin"* ]]; then 5 | find ./docs/api -type f -name '*.md' | xargs sed -i '' -E 's/# (Module|Interface|Class|Enumeration):/#/g' 6 | else 7 | find ./docs/api -type f -name '*.md' | xargs sed -i -E 's/# (Module|Interface|Class|Enumeration):/#/g' 8 | fi 9 | 10 | 11 | ex ./docs/api/classes/caporal_program.program.md < { 18 | const files = await readdir(dirPath) 19 | const imp = await Promise.all(files.map((f) => importCommand(path.join(dirPath, f)))) 20 | const data = zipObject(files, imp) 21 | return map(data, (cmdBuilder, filename) => { 22 | const { dir, name } = path.parse(filename) 23 | const cmd = dir ? [...dir.split("/"), name].join(" ") : name 24 | const options = { 25 | createCommand: createCommand.bind(null, program, cmd), 26 | program, 27 | } 28 | return cmdBuilder(options) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## Running & testing 4 | 5 | ```bash 6 | # Run all tests 7 | npm run test 8 | ``` 9 | 10 | ```bash 11 | # Watch mode (also generate code coverage into /coverage/ directory) 12 | npm run test:watch 13 | ``` 14 | 15 | ## Run linter (eslint) 16 | 17 | The linter will run over `src/**/*.ts` and `examples/**/*.{ts,js}` files. 18 | 19 | ```bash 20 | npm run lint 21 | ``` 22 | 23 | ## Building 24 | 25 | - `npm run build` builds the final javascript, typescript declarations, and _typedoc_ documentation into `/dist/`. 26 | - `npm run build:all` builds all of the above, plus the public website. 27 | 28 | ```bash 29 | npm run build 30 | ``` 31 | 32 | ## Committing 33 | 34 | Caporal is using [commitizen](https://github.com/commitizen/cz-cli), meaning that when you commit, you'll be prompted to fill out any required commit fields, hence generating [conventional-changelog](https://github.com/conventional-changelog/conventional-changelog) compatible commit messages. 35 | 36 | Please pay attention to your commit messages as they are used to generate changelog entries. 37 | -------------------------------------------------------------------------------- /src/utils/__tests__/suggest.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { getSuggestions, boldDiffString } from "../suggest.js" 3 | import c from "chalk" 4 | 5 | test("getSuggestions() should return proper suggestions", (t) => { 6 | const suggestions = getSuggestions("foo", ["foa", "foab", "foabc", "afoo", "bfoap"]) 7 | t.deepEqual(suggestions, ["foa", "afoo", "foab"]) 8 | }) 9 | test("boldDiffString() should make the diff bold at the end of the word", (t) => { 10 | t.is(boldDiffString("foo", "foob"), "foo" + c.bold.greenBright("b")) 11 | }) 12 | test("boldDiffString() should make the diff bold at the beginning of the word", (t) => { 13 | t.is(boldDiffString("foo", "doo"), c.bold.greenBright("d") + "oo") 14 | }) 15 | 16 | test("boldDiffString() should make the diff bold at the middle of the word", (t) => { 17 | t.is(boldDiffString("foo", "fao"), "f" + c.bold.greenBright("a") + "o") 18 | }) 19 | 20 | test("boldDiffString() should make the diff bold anywhere in the word", (t) => { 21 | t.is( 22 | boldDiffString("minimum", "maximum"), 23 | "m" + c.bold.greenBright("a") + c.bold.greenBright("x") + "imum", 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | es6: true, 6 | }, 7 | parser: "@typescript-eslint/parser", 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | sourceType: "module", 11 | }, 12 | extends: ["eslint:recommended", "plugin:prettier/recommended"], 13 | overrides: [ 14 | { 15 | files: ["**/*.ts"], 16 | extends: [ 17 | "eslint:recommended", 18 | "plugin:@typescript-eslint/eslint-recommended", 19 | "plugin:@typescript-eslint/recommended", 20 | "prettier/@typescript-eslint", 21 | ], 22 | plugins: ["@typescript-eslint"], 23 | rules: { 24 | "@typescript-eslint/explicit-function-return-type": [ 25 | "warn", 26 | { 27 | allowExpressions: true, 28 | }, 29 | ], 30 | "@typescript-eslint/no-unused-vars": "warn", 31 | "@typescript-eslint/no-use-before-define": "off", 32 | "no-console": "error", 33 | // nededed for mixins to avoid unecessary warnings 34 | "@typescript-eslint/no-empty-interface": "off", 35 | }, 36 | }, 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present Matthias ETIENNE 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 | -------------------------------------------------------------------------------- /src/error/__tests__/fatal.spec.ts: -------------------------------------------------------------------------------- 1 | import { fatalError } from "../fatal" 2 | import { BaseError } from "../base" 3 | import { logger } from "../../logger" 4 | 5 | describe("fatalError()", () => { 6 | const loggerLogSpy = jest.spyOn(logger, "log") 7 | const loggerErrSpy = jest.spyOn(logger, "error") 8 | 9 | afterEach(() => { 10 | logger.level = "info" 11 | }) 12 | 13 | it("should always call process.exit(1)", () => { 14 | const err = new BaseError("my error") 15 | fatalError(err) 16 | expect(process.exitCode).toEqual(1) 17 | }) 18 | 19 | it("should always logger.error in normal situation", () => { 20 | const err = new BaseError("my error") 21 | fatalError(err) 22 | expect(loggerErrSpy).toHaveBeenCalledWith(err.message) 23 | }) 24 | 25 | it("should always logger.log in debug mode, with more info", () => { 26 | const err = new BaseError("my error") 27 | logger.level = "debug" 28 | fatalError(err) 29 | expect(loggerLogSpy).toHaveBeenCalledWith( 30 | expect.objectContaining({ 31 | message: expect.stringContaining("my error"), 32 | stack: err.stack, 33 | name: "BaseError", 34 | }), 35 | ) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/argument/synopsis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { ArgumentSynopsis } from "../types.js" 6 | import { getCleanNameFromNotation } from "../option/utils.js" 7 | 8 | /** 9 | * Check if the argument is explicitly required 10 | * 11 | * @ignore 12 | * @param synopsis 13 | */ 14 | export function isRequired(synopsis: string): boolean { 15 | return synopsis.substring(0, 1) === "<" 16 | } 17 | 18 | /** 19 | * 20 | * @param synopsis 21 | */ 22 | export function isVariadic(synopsis: string): boolean { 23 | return synopsis.substr(-4, 3) === "..." || synopsis.endsWith("...") 24 | } 25 | 26 | export function parseArgumentSynopsis(synopsis: string): ArgumentSynopsis { 27 | synopsis = synopsis.trim() 28 | const rawName = getCleanNameFromNotation(synopsis, false) 29 | const name = getCleanNameFromNotation(synopsis) 30 | const required = isRequired(synopsis) 31 | const variadic = isVariadic(synopsis) 32 | const cleanSynopsis = required 33 | ? `<${rawName}${variadic ? "..." : ""}>` 34 | : `[${rawName}${variadic ? "..." : ""}]` 35 | return { 36 | name, 37 | synopsis: cleanSynopsis, 38 | required, 39 | variadic, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/validator/validate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { 7 | Validator, 8 | Promisable, 9 | ParsedOption, 10 | ParsedArgument, 11 | Argument, 12 | Option, 13 | } from "../types.js" 14 | 15 | import { validateWithRegExp } from "./regexp.js" 16 | import { validateWithArray } from "./array.js" 17 | import { validateWithFunction } from "./function.js" 18 | import { validateWithCaporal } from "./caporal.js" 19 | import { isCaporalValidator } from "./utils.js" 20 | 21 | export function validate( 22 | value: ParsedOption | ParsedArgument, 23 | validator: Validator, 24 | context: Argument | Option, 25 | ): Promisable { 26 | if (typeof validator === "function") { 27 | return validateWithFunction(validator, value, context) 28 | } else if (validator instanceof RegExp) { 29 | return validateWithRegExp(validator, value, context) 30 | } else if (Array.isArray(validator)) { 31 | return validateWithArray(validator, value, context) 32 | } 33 | // Caporal flag validator 34 | else if (isCaporalValidator(validator)) { 35 | return validateWithCaporal(validator, value, context) 36 | } 37 | return value 38 | } 39 | -------------------------------------------------------------------------------- /src/error/unknown-option.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import type { Option } from "../types.js" 8 | import { getDashedOpt } from "../option/utils.js" 9 | import { getGlobalOptions } from "../option/index.js" 10 | import { getSuggestions, boldDiffString } from "../utils/suggest.js" 11 | import c from "chalk" 12 | import type { Command } from "../command/index.js" 13 | import filter from "lodash/fp/filter.js" 14 | import map from "lodash/fp/map.js" 15 | 16 | /** 17 | * @todo Rewrite 18 | */ 19 | export class UnknownOptionError extends BaseError { 20 | constructor(flag: string, command: Command) { 21 | const longFlags = filter((f: Option) => f.name.length > 1), 22 | getFlagNames = map((f: Option) => f.name), 23 | possibilities = getFlagNames([ 24 | ...longFlags(command.options), 25 | ...getGlobalOptions().keys(), 26 | ]), 27 | suggestions = getSuggestions(flag, possibilities) 28 | 29 | let msg = `Unknown option ${c.bold.redBright(getDashedOpt(flag))}. ` 30 | if (suggestions.length) { 31 | msg += 32 | "Did you mean " + 33 | suggestions 34 | .map((s) => boldDiffString(getDashedOpt(flag), getDashedOpt(s))) 35 | .join(" or maybe ") + 36 | " ?" 37 | } 38 | super(msg, { flag, command }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/error/unknown-command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { BaseError } from "./base.js" 7 | import { getSuggestions, boldDiffString } from "../utils/suggest.js" 8 | import chalk from "chalk" 9 | import type { Program } from "../program/index.js" 10 | import flatMap from "lodash/flatMap.js" 11 | import filter from "lodash/filter.js" 12 | import wrap from "wrap-ansi" 13 | 14 | /** 15 | * @todo Rewrite 16 | */ 17 | export class UnknownOrUnspecifiedCommandError extends BaseError { 18 | constructor(program: Program, command?: string) { 19 | const possibilities = filter( 20 | flatMap(program.getCommands(), (c) => [c.name, ...c.getAliases()]), 21 | ) 22 | let msg = "" 23 | if (command) { 24 | msg = `Unknown command ${chalk.bold(command)}.` 25 | const suggestions = getSuggestions(command, possibilities) 26 | if (suggestions.length) { 27 | msg += 28 | " Did you mean " + 29 | suggestions.map((s) => boldDiffString(command, s)).join(" or maybe ") + 30 | " ?" 31 | } 32 | } else { 33 | msg = 34 | "Unspecified command. Available commands are:\n" + 35 | wrap(possibilities.map((p) => chalk.whiteBright(p)).join(", "), 60) + 36 | "." + 37 | `\n\nFor more help, type ${chalk.whiteBright(program.getBin() + " --help")}` 38 | } 39 | 40 | super(msg, { command }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/validator/__tests__/regexp.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { validateWithRegExp } from "../regexp.js" 3 | import { createArgument } from "../../argument/index.js" 4 | import { createOption } from "../../option/index.js" 5 | import { ValidationError } from "../../error/index.js" 6 | 7 | const arg = createArgument("", "Fake arg") 8 | const opt = createOption("--file ", "File") 9 | 10 | const isError = { instanceOf: ValidationError } 11 | 12 | test("should validate a valid string", (t) => { 13 | t.is(validateWithRegExp(/^ex/, "example", arg), "example", "arg") 14 | t.is(validateWithRegExp(/^ex/, "example", opt), "example", "opt") 15 | }) 16 | 17 | test("should throw for an invalid string", (t) => { 18 | t.throws(() => validateWithRegExp(/^ex/, "invalid", arg), isError, "arg") 19 | t.throws(() => validateWithRegExp(/^ex/, "invalid", opt), isError, "opt") 20 | }) 21 | 22 | test("should validate a valid string[]", (t) => { 23 | t.deepEqual( 24 | validateWithRegExp(/^ex/, ["example", "executor"], arg), 25 | ["example", "executor"], 26 | "arg", 27 | ) 28 | t.deepEqual( 29 | validateWithRegExp(/^ex/, ["example", "executor"], opt), 30 | ["example", "executor"], 31 | "opt", 32 | ) 33 | }) 34 | 35 | test("should throw for an invalid string[]", (t) => { 36 | t.throws(() => validateWithRegExp(/^ex/, ["example", "invalid"], arg), isError, "arg") 37 | t.throws(() => validateWithRegExp(/^ex/, ["example", "invalid"], opt), isError, "opt") 38 | }) 39 | -------------------------------------------------------------------------------- /src/utils/suggest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import chalk from "chalk" 6 | import { levenshtein } from "./levenshtein.js" 7 | 8 | interface Suggestion { 9 | distance: number 10 | suggestion: string 11 | } 12 | 13 | const MAX_DISTANCE = 2 14 | 15 | const sortByDistance = (a: Suggestion, b: Suggestion): number => a.distance - b.distance 16 | 17 | const keepMeaningfulSuggestions = (s: Suggestion): boolean => s.distance <= MAX_DISTANCE 18 | 19 | const possibilitesMapper = (input: string, p: string): Suggestion => { 20 | return { suggestion: p, distance: levenshtein(input, p) } 21 | } 22 | 23 | /** 24 | * Get autocomplete suggestions 25 | * 26 | * @param {String} input - User input 27 | * @param {String[]} possibilities - Possibilities to retrieve suggestions from 28 | */ 29 | export function getSuggestions(input: string, possibilities: string[]): string[] { 30 | return possibilities 31 | .map((p) => possibilitesMapper(input, p)) 32 | .filter(keepMeaningfulSuggestions) 33 | .sort(sortByDistance) 34 | .map((p) => p.suggestion) 35 | } 36 | 37 | /** 38 | * Make diff bolder in a string 39 | * 40 | * @param from original string 41 | * @param to target string 42 | */ 43 | export function boldDiffString(from: string, to: string): string { 44 | return [...to] 45 | .map((char, index) => { 46 | if (char != from.charAt(index)) { 47 | return chalk.bold.greenBright(char) 48 | } 49 | return char 50 | }) 51 | .join("") 52 | } 53 | -------------------------------------------------------------------------------- /src/help/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @module caporal/types 4 | */ 5 | import { Command } from "../command/index.js" 6 | import { Program } from "../program/index.js" 7 | import chalk from "chalk" 8 | import { colorize } from "../utils/colorize.js" 9 | import { buildTable } from "./utils.js" 10 | import type { GlobalOptions } from "../types.js" 11 | 12 | export interface CustomizedHelpOpts { 13 | /** 14 | * Name of the section to be added in help. 15 | */ 16 | sectionName: string 17 | /** 18 | * Enable or disable the automatic coloration of text. 19 | */ 20 | colorize: boolean 21 | } 22 | export interface CustomizedHelp { 23 | /** 24 | * Various display options. 25 | */ 26 | options: CustomizedHelpOpts 27 | /** 28 | * Help text. Padding of the text is automatically handled for you. 29 | */ 30 | text: string 31 | } 32 | 33 | export type CustomizedHelpMap = Map 34 | 35 | export interface Template { 36 | (ctx: TemplateContext): Promise | string 37 | } 38 | 39 | export interface TemplateFunction { 40 | (name: string, ctx: TemplateContext): Promise | string 41 | } 42 | 43 | export interface TemplateContext { 44 | prog: Program 45 | cmd?: Command 46 | customHelp: CustomizedHelpMap 47 | globalOptions: GlobalOptions 48 | chalk: typeof chalk 49 | colorize: typeof colorize 50 | tpl: TemplateFunction 51 | table: typeof buildTable 52 | indent: (str: string) => string 53 | eol: string 54 | eol2: string 55 | eol3: string 56 | spaces: string 57 | } 58 | -------------------------------------------------------------------------------- /src/validator/__tests__/array.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { validateWithArray } from "../array.js" 3 | import { createArgument } from "../../argument/index.js" 4 | import { createOption } from "../../option/index.js" 5 | import { ValidationError } from "../../error/index.js" 6 | 7 | const arg = createArgument("", "Fake arg") 8 | const opt = createOption("--file ", "File") 9 | 10 | const isError = { instanceOf: ValidationError } 11 | 12 | test("should validate a valid string", (t) => { 13 | t.is(validateWithArray(["foo", "bar"], "bar", arg), "bar", "arg") 14 | t.is(validateWithArray(["foo", "bar"], "bar", opt), "bar", "opt") 15 | }) 16 | 17 | test("should throw for an invalid string", (t) => { 18 | t.throws(() => validateWithArray(["foo", "bar"], "invalid", arg), isError, "arg") 19 | t.throws(() => validateWithArray(["foo", "bar"], "invalid", opt), isError, "opt") 20 | }) 21 | 22 | test("should validate a valid string[]", (t) => { 23 | t.deepEqual( 24 | validateWithArray(["foo", "bar", "hey"], ["bar", "hey"], arg), 25 | ["bar", "hey"], 26 | "arg", 27 | ) 28 | t.deepEqual( 29 | validateWithArray(["foo", "bar", "hey"], ["bar", "hey"], opt), 30 | ["bar", "hey"], 31 | "opt", 32 | ) 33 | }) 34 | 35 | test("should throw for an invalid string[]", (t) => { 36 | t.throws( 37 | () => validateWithArray(["foo", "bar", "hey"], ["bar", "bad"], arg), 38 | isError, 39 | "arg", 40 | ) 41 | t.throws( 42 | () => validateWithArray(["foo", "bar", "hey"], ["bar", "bad"], opt), 43 | isError, 44 | "opt", 45 | ) 46 | }) 47 | -------------------------------------------------------------------------------- /src/command/find.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import type { Program } from "../program/index.js" 6 | import type { Command } from "./index.js" 7 | import { importCommand } from "./import.js" 8 | import { createCommand } from "./index.js" 9 | import path from "path" 10 | 11 | export async function findCommand( 12 | program: Program, 13 | argv: string[], 14 | ): Promise { 15 | const commands = program.getCommands() 16 | const findRegisteredCommand = (search: string): Command | undefined => 17 | commands.find((c) => c.name === search || c.hasAlias(search)) 18 | 19 | let foundCommand 20 | let i 21 | for (i = 0; i < argv.length; i++) { 22 | const cmd = argv.slice(0, i + 1).join(" ") 23 | // break as soon as possible 24 | if (argv[i].startsWith("-")) { 25 | break 26 | } 27 | const potentialCmd = 28 | findRegisteredCommand(cmd) || (await discoverCommand(program, cmd)) 29 | foundCommand = potentialCmd || foundCommand 30 | } 31 | 32 | return foundCommand 33 | } 34 | 35 | /** 36 | * Search for a command in discovery path 37 | */ 38 | async function discoverCommand( 39 | program: Program, 40 | cmd: string, 41 | ): Promise { 42 | if (program.discoveryPath === undefined) { 43 | return 44 | } 45 | const filename = cmd.split(" ").join("/") 46 | try { 47 | const fullPath = path.join(program.discoveryPath, filename) 48 | const cmdBuilder = await importCommand(fullPath) 49 | const options = { 50 | createCommand: createCommand.bind(null, program, cmd), 51 | program, 52 | } 53 | return cmdBuilder(options) 54 | // eslint-disable-next-line no-empty 55 | } catch (e) {} 56 | } 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main Caporal module. 3 | * 4 | * ## program 5 | * 6 | * This represents your program. You don't have to instanciate the {@link Program} class, 7 | * it's already done for you. 8 | * 9 | * **Usage** 10 | * 11 | * ```ts 12 | * // The Program instance generated for you 13 | * import program from "@caporal/core" 14 | * 15 | * program 16 | * .command(...) 17 | * .action(...) 18 | * [...] 19 | * ``` 20 | * 21 | * 22 | * ## parseArgv() 23 | * 24 | * This is the command line parser internaly used by Caporal. 25 | * 26 | * ::: tip Advanced usage 27 | * Usually, **you won't need to use the parser** directly, but if you 28 | * just want to parse some args without all capabilities brought 29 | * by Caporal, feel free to play with it. 30 | * ::: 31 | * 32 | * **Usage** 33 | * 34 | * ```ts 35 | * import { parseArgv } from "@caporal/core" 36 | * 37 | * const {args, options} = parseArgv({ 38 | * // ... options 39 | * }) 40 | * ``` 41 | * 42 | * Checkout `parseArgv()` [documentation here](/api/modules/parser.md). 43 | * 44 | * 45 | * ## chalk 46 | * 47 | * `chalk` npm module re-export 48 | * 49 | * **Usage** 50 | * 51 | * ```ts 52 | * import { program, chalk } from "caporal" 53 | * 54 | * program 55 | * .command('pay') 56 | * .argument('', 'Amount to pay', Validator.NUMBER) 57 | * .action(({logger, args}) => { 58 | * logger.info("You paid $%s", chalk.red(args.amount)) 59 | * }) 60 | * [...] 61 | * ``` 62 | * 63 | * 64 | * @packageDocumentation 65 | * @module @caporal/core 66 | */ 67 | import { Program } from "./program/index.js" 68 | export { Command } from "./command/index.js" 69 | export * from "./types.js" 70 | 71 | /** 72 | * @ignore 73 | */ 74 | export { default as chalk } from "chalk" 75 | /** 76 | * @ignore 77 | */ 78 | export { parseArgv, parseLine } from "./parser/index.js" 79 | 80 | /** 81 | * @ignore 82 | */ 83 | export const program = new Program() 84 | 85 | /** 86 | * @ignore 87 | */ 88 | export { Program } 89 | -------------------------------------------------------------------------------- /src/validator/__tests__/function.spec.ts: -------------------------------------------------------------------------------- 1 | import { validateWithFunction } from "../function" 2 | import { createArgument } from "../../argument" 3 | import { createOption } from "../../option" 4 | import { ValidationError } from "../../error" 5 | import { FunctionValidator } from "../../types" 6 | 7 | describe("validateWithFunction()", () => { 8 | const validator: FunctionValidator = function (value) { 9 | if (value !== "hey" && value !== "ho") { 10 | throw Error("my error") 11 | } 12 | return value 13 | } 14 | const arg = createArgument("", "Fake arg", { validator }) 15 | const opt = createOption("--file ", "File") 16 | 17 | it("should validate a valid value", async () => { 18 | await expect(validateWithFunction(validator, "hey", arg)).resolves.toEqual("hey") 19 | await expect(validateWithFunction(validator, "hey", opt)).resolves.toEqual("hey") 20 | }) 21 | 22 | it("should throw for an invalid value", async () => { 23 | await expect(validateWithFunction(validator, "invalid", arg)).rejects.toBeInstanceOf( 24 | ValidationError, 25 | ) 26 | await expect(validateWithFunction(validator, "invalid", opt)).rejects.toBeInstanceOf( 27 | ValidationError, 28 | ) 29 | }) 30 | it("should validate a valid array", async () => { 31 | await expect(validateWithFunction(validator, ["ho"], arg)).resolves.toEqual(["ho"]) 32 | await expect(validateWithFunction(validator, ["ho"], opt)).resolves.toEqual(["ho"]) 33 | await expect(validateWithFunction(validator, ["ho", "hey"], arg)).resolves.toEqual([ 34 | "ho", 35 | "hey", 36 | ]) 37 | await expect(validateWithFunction(validator, ["ho", "hey"], opt)).resolves.toEqual([ 38 | "ho", 39 | "hey", 40 | ]) 41 | }) 42 | it("should throw for an invalid array", async () => { 43 | await expect( 44 | validateWithFunction(validator, ["invalid", "hey"], arg), 45 | ).rejects.toBeInstanceOf(ValidationError) 46 | 47 | await expect( 48 | validateWithFunction(validator, ["invalid", "hey"], opt), 49 | ).rejects.toBeInstanceOf(ValidationError) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /examples/pizza.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | import { program } from "@caporal/core" 3 | 4 | program 5 | .version("1.0.0") 6 | .description("Order pizza right from your terminal!") 7 | // the "order" command 8 | .command("order", "Order a pizza") 9 | .alias("give-it-to-me") 10 | // will be auto-magicaly autocompleted by providing the user with 3 choices 11 | .argument( 12 | "", 13 | "Kind of pizza you want to order from the shop. You may want to add new types of pizza by asking the chef, but the validator won't pass!", 14 | { default: ["margherita", "hawaiian", "fredo"] }, 15 | ) 16 | .argument("", "Which store to order from", { 17 | validator: ["New-York", "Portland", "Paris"], 18 | }) 19 | 20 | .argument("", "Which account id to use", { 21 | validator: program.NUMBER, 22 | default: 1875896, 23 | }) 24 | .argument("[other-request...]", "Other requests") 25 | 26 | .option("-n, --number ", "Number of pizza", { 27 | validator: program.NUMBER, 28 | required: true, 29 | }) 30 | .option("-d, --discount ", "Discount code", { 31 | validator: /^[a-z]{3}[0-9]{5,}$/i, 32 | }) 33 | .option("-p, --pay-by ", "Pay by option", { 34 | validator: ["cash", "card"], 35 | required: true, 36 | }) 37 | 38 | // enable auto-completion for -p | --pay-by argument using a Promise 39 | 40 | // --extra will be auto-magicaly autocompleted by providing the user with 3 choices 41 | .option("-e ", "Add extra ingredients", { 42 | validator: ["pepperoni", "onion", "cheese"], 43 | }) 44 | .option("--add-ingredients ", "Add extra ingredients", { 45 | validator: program.ARRAY, 46 | }) 47 | .action(function ({ args, options, logger }) { 48 | logger.info("Command 'order' called with:") 49 | logger.info("arguments: %j", args) 50 | logger.info("options: %j", options) 51 | }) 52 | 53 | .command("cancel", "Cancel an order") 54 | .argument("", "Order id", { validator: program.NUMBER }) 55 | .action(({ args, logger }) => { 56 | logger.info("Order id #%s canceled", args.orderId) 57 | }) 58 | 59 | program.run() 60 | -------------------------------------------------------------------------------- /src/logger/__tests__/logger.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { program } from "../../index" 3 | import { Program } from "../../program" 4 | import { logger } from ".." 5 | import c from "chalk" 6 | import stripAnsi from "strip-ansi" 7 | 8 | let prog = program 9 | const EOL = require("os").EOL 10 | 11 | describe("logger", () => { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 13 | // @ts-ignore 14 | const logStdoutSpy = jest.spyOn(console._stdout, "write") // winston use this 15 | 16 | beforeEach(() => { 17 | prog = new Program() 18 | prog.name("test-prog") 19 | prog.bin("test-prog") 20 | prog.version("xyz") 21 | logStdoutSpy.mockClear() 22 | logger.level = "info" 23 | }) 24 | 25 | test("logger should handle metadata", () => { 26 | logger.info("foo", { blabla: "joe" }) 27 | expect(stripAnsi((logStdoutSpy.mock.calls[0][0] as unknown) as string)).toBe( 28 | `info: foo${EOL}info: { blabla: 'joe' }${EOL}`, 29 | ) 30 | }) 31 | 32 | test("level string should be colorized by default", () => { 33 | logger.info("my-info") 34 | expect(logStdoutSpy).toHaveBeenLastCalledWith( 35 | `${c.hex("#569cd6")("info")}: my-info${EOL}`, 36 | ) 37 | logger.warn("my-warn") 38 | expect(logStdoutSpy).toHaveBeenLastCalledWith( 39 | `${c.hex("#FF9900")("warn")}: my-warn${EOL}`, 40 | ) 41 | logger.error("my-error") 42 | expect(logStdoutSpy).toHaveBeenLastCalledWith( 43 | `${EOL}${c.bold.redBright("error")}: my-error${EOL}${EOL}`, 44 | ) 45 | // set level to "debug" for the next test 46 | logger.level = "debug" 47 | logger.debug("my-debug") 48 | expect(logStdoutSpy).toHaveBeenLastCalledWith(`${c.dim("debug")}: my-debug${EOL}`) 49 | 50 | // set level to "silly" for the next test 51 | logger.level = "silly" 52 | logger.silly("my-silly") 53 | expect(logStdoutSpy).toHaveBeenLastCalledWith(`${c.dim("silly")}: my-silly${EOL}`) 54 | 55 | logger.http("foo") 56 | expect(logStdoutSpy).toHaveBeenLastCalledWith(`http: foo${EOL}`) 57 | }) 58 | 59 | test("logger.disableColors() should disable colors", () => { 60 | logger.disableColors() 61 | logger.info("my-info") 62 | expect(logStdoutSpy).toHaveBeenLastCalledWith(`info: my-info${EOL}`) 63 | // revert back 64 | logger.colorsEnabled = true 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /scripts/gen-contributors.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require("path") 3 | const fs = require("fs") 4 | const take = require("lodash/take") 5 | const pick = require("lodash/pick") 6 | const chunk = require("lodash/chunk") 7 | const filter = require("lodash/filter") 8 | const { Octokit } = require("@octokit/rest") 9 | const mdTable = require("markdown-table") 10 | 11 | const CONTRIBUTORS_PATH = path.join(__dirname, "..", "docs", "CONTRIBUTORS.md") 12 | 13 | const OWNER = "mattallty", 14 | REPO = "Caporal.js", 15 | octokit = new Octokit({ 16 | userAgent: `${OWNER}/${REPO}`, 17 | auth: process.env.GH_TOKEN, 18 | }) 19 | 20 | async function getContributors() { 21 | const contributors = await octokit.paginate("GET /repos/:owner/:repo/contributors", { 22 | owner: OWNER, 23 | repo: REPO, 24 | }) 25 | const contributorsInfos = ( 26 | await Promise.all(contributors.map((c) => octokit.request(c.url))) 27 | ).map(({ data: user }) => 28 | pick(user, ["login", "html_url", "avatar_url", "name", "blog"]), 29 | ) 30 | 31 | const contributorsObject = contributorsInfos.reduce((acc, c, index) => { 32 | acc[c.login] = Object.assign({}, contributors[index], c) 33 | return acc 34 | }, {}) 35 | 36 | const main = filter(contributorsObject, (c) => c.contributions > 1) 37 | const other = filter(contributorsObject, (c) => c.contributions === 1) 38 | 39 | return { main, other } 40 | } 41 | 42 | async function buildContributorsTable(options) { 43 | options = { columns: 5, ...options } 44 | const { main, other } = await getContributors() 45 | const cells = main.map((info) => { 46 | return `${ 47 | info.name || `@${info.login}` 48 | }
` 49 | }) 50 | const rows = chunk(cells, options.columns) 51 | const table = mdTable(rows, { align: "c" }) 52 | const contribs = 53 | table + 54 | "\n\n...but also " + 55 | take(other, 15) 56 | .map((info) => `${info.name || `@${info.login}`}`) 57 | .join(", ") + 58 | '. See full list.' 59 | return contribs 60 | } 61 | 62 | ;(async function () { 63 | const md = await buildContributorsTable() 64 | fs.writeFileSync(CONTRIBUTORS_PATH, md) 65 | console.log("%s updated", CONTRIBUTORS_PATH) 66 | })() 67 | -------------------------------------------------------------------------------- /src/option/validate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import reduce from "lodash/reduce.js" 7 | import { findOption } from "./find.js" 8 | import { MissingFlagError, UnknownOptionError, BaseError } from "../error/index.js" 9 | import { findGlobalOption } from "./index.js" 10 | import { validate } from "../validator/validate.js" 11 | 12 | import type { ParsedOption, ParsedOptions, Promisable, Option } from "../types.js" 13 | import type { Command } from "../command/index.js" 14 | 15 | function validateOption(opt: Option, value: ParsedOption): ReturnType { 16 | return opt.validator ? validate(value, opt.validator, opt) : value 17 | } 18 | 19 | export function checkRequiredOpts(cmd: Command, opts: ParsedOptions): BaseError[] { 20 | return cmd.options.reduce((acc, opt) => { 21 | if (opts[opt.name] === undefined && opt.required) { 22 | acc.push(new MissingFlagError(opt, cmd)) 23 | } 24 | return acc 25 | }, [] as BaseError[]) 26 | } 27 | 28 | function applyDefaults(cmd: Command, opts: ParsedOptions): ParsedOptions { 29 | return cmd.options.reduce((acc, opt) => { 30 | if (acc[opt.name] === undefined && opt.default !== undefined) { 31 | acc[opt.name] = opt.default 32 | } 33 | return acc 34 | }, opts) 35 | } 36 | 37 | type OptionsPromises = Record> 38 | 39 | interface OptionsValidationResult { 40 | options: ParsedOptions 41 | errors: BaseError[] 42 | } 43 | 44 | export async function validateOptions( 45 | cmd: Command, 46 | options: ParsedOptions, 47 | ): Promise { 48 | options = applyDefaults(cmd, options) 49 | const errors: BaseError[] = [] 50 | const validations = reduce( 51 | options, 52 | (...args) => { 53 | const [acc, value, name] = args 54 | const opt = findGlobalOption(name) || findOption(cmd, name) 55 | try { 56 | if (opt) { 57 | acc[name] = validateOption(opt, value) 58 | } else if (cmd.strictOptions) { 59 | throw new UnknownOptionError(name, cmd) 60 | } 61 | } catch (e) { 62 | errors.push(e) 63 | } 64 | return acc 65 | }, 66 | {} as OptionsPromises, 67 | ) 68 | const result = await reduce( 69 | validations, 70 | async (prevPromise, value, key): Promise => { 71 | const collection = await prevPromise 72 | collection[key] = await value 73 | return collection 74 | }, 75 | Promise.resolve({}) as Promise, 76 | ) 77 | 78 | errors.push(...checkRequiredOpts(cmd, result)) 79 | return { options: result, errors } 80 | } 81 | -------------------------------------------------------------------------------- /src/error/validation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { format } from "util" 7 | import c from "chalk" 8 | import { BaseError } from "./base.js" 9 | import { Validator, ParserTypes, Argument, Option, CaporalValidator } from "../types.js" 10 | import { isCaporalValidator } from "../validator/utils.js" 11 | 12 | interface ValidationErrorParameters { 13 | value: ParserTypes | ParserTypes[] 14 | error?: Error | string 15 | validator: Validator 16 | context: Argument | Option 17 | } 18 | 19 | function isOptionObject(obj: Option | Argument): obj is Option { 20 | return "allNotations" in obj 21 | } 22 | 23 | export class ValidationError extends BaseError { 24 | constructor({ value, error, validator, context }: ValidationErrorParameters) { 25 | let message = error instanceof Error ? error.message : error 26 | const varName = isOptionObject(context) ? "option" : "argument" 27 | const name = isOptionObject(context) 28 | ? context.allNotations.join("|") 29 | : context.synopsis 30 | 31 | if (isCaporalValidator(validator)) { 32 | switch (validator) { 33 | case CaporalValidator.NUMBER: 34 | message = format( 35 | 'Invalid value for %s %s.\nExpected a %s but got "%s".', 36 | varName, 37 | c.redBright(name), 38 | c.underline("number"), 39 | c.redBright(value), 40 | ) 41 | break 42 | case CaporalValidator.BOOLEAN: 43 | message = format( 44 | 'Invalid value for %s %s.\nExpected a %s (true, false, 0, 1), but got "%s".', 45 | varName, 46 | c.redBright(name), 47 | c.underline("boolean"), 48 | c.redBright(value), 49 | ) 50 | break 51 | case CaporalValidator.STRING: 52 | message = format( 53 | 'Invalid value for %s %s.\nExpected a %s, but got "%s".', 54 | varName, 55 | c.redBright(name), 56 | c.underline("string"), 57 | c.redBright(value), 58 | ) 59 | break 60 | } 61 | } else if (Array.isArray(validator)) { 62 | message = format( 63 | 'Invalid value for %s %s.\nExpected one of %s, but got "%s".', 64 | varName, 65 | c.redBright(name), 66 | "'" + validator.join("', '") + "'", 67 | c.redBright(value), 68 | ) 69 | } else if (validator instanceof RegExp) { 70 | message = format( 71 | 'Invalid value for %s %s.\nExpected a value matching %s, but got "%s".', 72 | varName, 73 | c.redBright(name), 74 | c.whiteBright(validator.toString()), 75 | c.redBright(value), 76 | ) 77 | } 78 | super(message + "") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/validator/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { Validator } from "../types.js" 7 | import { CaporalValidator, Argument, Option } from "../types.js" 8 | import isNumber from "lodash/isNumber.js" 9 | import { InvalidValidatorError } from "../error/index.js" 10 | 11 | export function isCaporalValidator( 12 | validator: Validator | undefined, 13 | ): validator is number { 14 | if (typeof validator !== "number") { 15 | return false 16 | } 17 | const mask = getCaporalValidatorsMask() 18 | const exist = (mask & validator) === validator 19 | return exist 20 | } 21 | 22 | export function isNumericValidator(validator: Validator | undefined): boolean { 23 | return isCaporalValidator(validator) && Boolean(validator & CaporalValidator.NUMBER) 24 | } 25 | 26 | export function isStringValidator(validator: Validator | undefined): boolean { 27 | return isCaporalValidator(validator) && Boolean(validator & CaporalValidator.STRING) 28 | } 29 | 30 | export function isBoolValidator(validator: Validator | undefined): boolean { 31 | return isCaporalValidator(validator) && Boolean(validator & CaporalValidator.BOOLEAN) 32 | } 33 | 34 | export function isArrayValidator(validator: Validator | undefined): boolean { 35 | return isCaporalValidator(validator) && Boolean(validator & CaporalValidator.ARRAY) 36 | } 37 | 38 | function getCaporalValidatorsMask(): number { 39 | return Object.values(CaporalValidator) 40 | .filter(isNumber) 41 | .reduce((a, b) => a | b, 0) 42 | } 43 | 44 | function checkCaporalValidator(validator: CaporalValidator): void { 45 | if (!isCaporalValidator(validator)) { 46 | throw new InvalidValidatorError(validator) 47 | } 48 | } 49 | 50 | function checkUserDefinedValidator(validator: Validator): void { 51 | if ( 52 | typeof validator !== "function" && 53 | !(validator instanceof RegExp) && 54 | !Array.isArray(validator) 55 | ) { 56 | throw new InvalidValidatorError(validator) 57 | } 58 | } 59 | 60 | export function checkValidator(validator: Validator | undefined): void { 61 | if (validator !== undefined) { 62 | typeof validator === "number" 63 | ? checkCaporalValidator(validator) 64 | : checkUserDefinedValidator(validator) 65 | } 66 | } 67 | 68 | export function getTypeHint(obj: Argument | Option): string | undefined { 69 | let hint 70 | if ( 71 | isBoolValidator(obj.validator) || 72 | ("boolean" in obj && obj.boolean && obj.default !== false) 73 | ) { 74 | hint = "boolean" 75 | } else if (isNumericValidator(obj.validator)) { 76 | hint = "number" 77 | } else if (Array.isArray(obj.validator)) { 78 | const stringified = JSON.stringify(obj.validator) 79 | if (stringified.length < 300) { 80 | hint = "one of " + stringified.substr(1, stringified.length - 2) 81 | } 82 | } 83 | return hint 84 | } 85 | -------------------------------------------------------------------------------- /src/argument/__tests__/argument.spec.ts: -------------------------------------------------------------------------------- 1 | import { createArgument } from ".." 2 | 3 | describe("createArgument()", () => { 4 | it("should create a basic required arg", () => { 5 | const arg = createArgument("", "My test argument") 6 | expect(arg).toMatchObject({ 7 | description: "My test argument", 8 | choices: [], 9 | name: "myArgument", 10 | required: true, 11 | synopsis: "", 12 | variadic: false, 13 | }) 14 | }) 15 | it("should create a basic optional arg", () => { 16 | const arg = createArgument("[my-argument]", "My test argument") 17 | expect(arg).toMatchObject({ 18 | description: "My test argument", 19 | choices: [], 20 | name: "myArgument", 21 | required: false, 22 | synopsis: "[my-argument]", 23 | variadic: false, 24 | }) 25 | }) 26 | 27 | it("should implicitly create an optional arg if no special character is set", () => { 28 | const arg = createArgument("my-argument", "My test argument") 29 | expect(arg).toMatchObject({ 30 | description: "My test argument", 31 | choices: [], 32 | name: "myArgument", 33 | required: false, 34 | synopsis: "[my-argument]", 35 | variadic: false, 36 | }) 37 | }) 38 | it("arg.choices should equal the array validator when provided", () => { 39 | const arg = createArgument("[my-argument]", "My test argument", { 40 | validator: ["one", "two"], 41 | }) 42 | expect(arg).toMatchObject({ 43 | description: "My test argument", 44 | choices: ["one", "two"], 45 | name: "myArgument", 46 | required: false, 47 | typeHint: 'one of "one","two"', 48 | synopsis: "[my-argument]", 49 | validator: ["one", "two"], 50 | variadic: false, 51 | }) 52 | }) 53 | it("arg.variadic should be true for an optional variadic argument", () => { 54 | const arg = createArgument("[my-argument...]", "My test argument", { 55 | validator: ["one", "two"], 56 | }) 57 | expect(arg).toMatchObject({ 58 | description: "My test argument", 59 | choices: ["one", "two"], 60 | name: "myArgument", 61 | required: false, 62 | typeHint: 'one of "one","two"', 63 | synopsis: "[my-argument...]", 64 | validator: ["one", "two"], 65 | variadic: true, 66 | }) 67 | }) 68 | it("arg.variadic should be true for an required variadic argument", () => { 69 | const arg = createArgument("", "My test argument", { 70 | validator: ["one", "two"], 71 | }) 72 | expect(arg).toMatchObject({ 73 | description: "My test argument", 74 | choices: ["one", "two"], 75 | name: "myArgument", 76 | required: true, 77 | typeHint: 'one of "one","two"', 78 | synopsis: "", 79 | validator: ["one", "two"], 80 | variadic: true, 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /examples/pizza.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require("@caporal/core") 3 | 4 | program 5 | .version("1.0.0") 6 | // the "order" command 7 | .help(`My Custom help !!`, { sectionName: "MY SECTION" }) 8 | .command("order", "Order a pizza") 9 | .help(`My Custom help about the order command !!\n2nd line`, { 10 | sectionName: "EXAMPLES", 11 | }) 12 | .alias("give-it-to-me") 13 | // will be auto-magicaly autocompleted by providing the user with 3 choices 14 | .argument("", "Kind of pizza", ["margherita", "hawaiian", "fredo"]) 15 | .argument("", "Which store to order from") 16 | // enable auto-completion for argument using a sync function returning an array 17 | .complete(function () { 18 | return ["store-1", "store-2", "store-3", "store-4", "store-5"] 19 | }) 20 | 21 | .argument("", "Which account id to use") 22 | // enable auto-completion for argument using a Promise 23 | .complete(function () { 24 | return Promise.resolve(["account-1", "account-2"]) 25 | }) 26 | 27 | .option("-n, --number ", "Number of pizza", program.INT, 1) 28 | .option("-d, --discount ", "Discount offer", program.FLOAT) 29 | .option("-p, --pay-by ", "Pay by option") 30 | // enable auto-completion for -p | --pay-by argument using a Promise 31 | .complete(function () { 32 | return Promise.resolve(["cash", "credit-card"]) 33 | }) 34 | 35 | // --extra will be auto-magicaly autocompleted by providing the user with 3 choices 36 | .option("-e ", "Add extra ingredients", ["pepperoni", "onion", "cheese"]) 37 | .option("--add-ingredients ", "Add extra ingredients", program.LIST) 38 | .action(function (args, options, logger) { 39 | logger.info("Command 'order' called with:") 40 | logger.info("arguments: %j", args) 41 | logger.info("options: %j", options) 42 | }) 43 | 44 | // the "return" command 45 | .command("return", "Return an order") 46 | // will be auto-magicaly autocompleted by providing the user with 3 choices 47 | .argument("", "Order id") 48 | // enable auto-completion for argument using a Promise 49 | .complete(function () { 50 | return Promise.resolve(["#82792", "#71727", "#526Z52"]) 51 | }) 52 | .argument("", "Store id") 53 | .option("--ask-change ", "Ask for other kind of pizza") 54 | .complete(function () { 55 | return Promise.resolve(["margherita", "hawaiian", "fredo"]) 56 | }) 57 | .option("--say-something ", "Say something to the manager") 58 | .action(function (args, options, logger) { 59 | return Promise.resolve("wooooo").then(function (ret) { 60 | logger.info("Command 'return' called with:") 61 | logger.info("arguments: %j", args) 62 | logger.info("options: %j", options) 63 | logger.info("promise succeed with: %s", ret) 64 | }) 65 | }) 66 | 67 | program.run() 68 | -------------------------------------------------------------------------------- /scripts/gen-dependents.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const cheerio = require("cheerio") 3 | const fetch = require("node-fetch") 4 | const map = require("lodash/map") 5 | const filter = require("lodash/filter") 6 | const orderBy = require("lodash/orderBy") 7 | const uniqBy = require("lodash/uniqBy") 8 | const take = require("lodash/take") 9 | const path = require("path") 10 | const fs = require("fs") 11 | const exec = require("child_process").exec 12 | 13 | const BASE_URL = "https://www.npmjs.com" 14 | let TOTAL_USING = 0 15 | 16 | const DEPENDENTS_PATH = path.join(__dirname, "..", "docs", "DEPENDENTS.md") 17 | 18 | function execShellCommand(cmd) { 19 | return new Promise((resolve) => { 20 | exec(cmd, (error, stdout, stderr) => { 21 | if (error) { 22 | console.warn(error) 23 | } 24 | resolve(stdout ? stdout.trimRight() : stderr) 25 | }) 26 | }) 27 | } 28 | 29 | async function computeDependentPackages(url, deps) { 30 | url = url || BASE_URL + "/browse/depended/caporal" 31 | deps = deps || [] 32 | const response = await fetch(url) 33 | const html = await response.text() 34 | const $ = cheerio.load(html) 35 | deps = deps.concat( 36 | $("main h3") 37 | .map(function () { 38 | return $(this).text() 39 | }) 40 | .get(), 41 | ) 42 | const nextPath = $('a:contains("Next")').attr("href") 43 | if (nextPath) { 44 | return await computeDependentPackages(BASE_URL + nextPath, deps) 45 | } 46 | return deps 47 | } 48 | 49 | async function getPackagesStats(package) { 50 | const response = await fetch( 51 | "https://api.npmjs.org/downloads/point/last-week/" + package, 52 | ) 53 | const { downloads } = await response.json() 54 | return { package, downloads } 55 | } 56 | 57 | async function getPackageDescription(obj) { 58 | obj.description = await execShellCommand(`npm info ${obj.package} description`) 59 | if (!obj.description.endsWith(".") && !obj.description.endsWith("!")) { 60 | obj.description += "." 61 | } 62 | return obj 63 | } 64 | 65 | async function getDependentPackages(max) { 66 | max = max || 12 67 | const pkgs = await Promise.all((await computeDependentPackages()).map(getPackagesStats)) 68 | TOTAL_USING = pkgs.length 69 | let packages = filter(pkgs, (p) => p.downloads >= 20) 70 | packages = uniqBy(packages, "package") 71 | packages = orderBy(packages, ["downloads"], ["desc"]) 72 | packages = take(packages, max) 73 | packages = await Promise.all(packages.map(getPackageDescription)) 74 | return packages 75 | } 76 | 77 | ;(async function () { 78 | const dependents = await getDependentPackages() 79 | const mdList = map(dependents, (pkg) => { 80 | return `- [${pkg.package}](https://www.npmjs.com/package/${pkg.package}): ${pkg.description}` 81 | }).join("\n") 82 | 83 | const md = `More than **${TOTAL_USING}** packages are using Caporal for their CLI, among which:\n\n` 84 | fs.writeFileSync(DEPENDENTS_PATH, md + mdList) 85 | console.log("%s updated", DEPENDENTS_PATH) 86 | })() 87 | -------------------------------------------------------------------------------- /src/validator/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { checkValidator, getTypeHint } from "../utils" 2 | import { createOption } from "../../option" 3 | import { InvalidValidatorError } from "../../error" 4 | import { CaporalValidator } from "../../types" 5 | import isNumber from "lodash/isNumber" 6 | 7 | const validators = Object.values(CaporalValidator).filter(isNumber) 8 | 9 | describe("validator / utils", () => { 10 | describe("checkValidator()", () => { 11 | it("should throw InvalidValidatorError for an invalid Caporal Validator", () => { 12 | expect(() => checkValidator(1001)).toThrowError(InvalidValidatorError) 13 | }) 14 | it("should throw InvalidValidatorError for all valid Caporal Validators", () => { 15 | expect(() => checkValidator(1001)).toThrowError(InvalidValidatorError) 16 | }) 17 | test.each(validators)("should not throw for %d", (v) => { 18 | expect(() => checkValidator(v)).not.toThrowError(InvalidValidatorError) 19 | }) 20 | 21 | it("should throw for an invalid caporal validator", () => { 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 23 | // @ts-ignore 24 | expect(() => checkValidator(1000)).toThrowError(InvalidValidatorError) 25 | }) 26 | 27 | it("should throw for an invalid user defined validator", () => { 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 29 | // @ts-ignore 30 | expect(() => checkValidator("wrong")).toThrowError(InvalidValidatorError) 31 | }) 32 | }) 33 | 34 | describe("getTypeHint()", () => { 35 | it("should return 'boolean' for a boolean option whose default is true", () => { 36 | const opt = createOption("--use-this", "Use this", { default: true }) 37 | expect(getTypeHint(opt)).toBe("boolean") 38 | }) 39 | it("should return a JSON for an array-validator", () => { 40 | const opt = createOption("--name ", "Your name", { 41 | validator: ["john", "jesse"], 42 | }) 43 | expect(getTypeHint(opt)).toBe('one of "john","jesse"') 44 | }) 45 | it("should return undefined for an array-validator if the JSON representation is too long", () => { 46 | const arr = new Array(120).fill("str".repeat(15)) 47 | const opt = createOption("--name ", "Your name", { 48 | validator: arr, 49 | }) 50 | expect(getTypeHint(opt)).toBeUndefined() 51 | }) 52 | it("should throw InvalidValidatorError for all valid Caporal Validators", () => { 53 | expect(() => checkValidator(1001)).toThrowError(InvalidValidatorError) 54 | }) 55 | test.each(validators)("should not throw for %d", (v) => { 56 | expect(() => checkValidator(v)).not.toThrowError(InvalidValidatorError) 57 | }) 58 | 59 | it("should throw for an invalid user defined validator", () => { 60 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 61 | // @ts-ignore 62 | expect(() => checkValidator("wrong")).toThrowError(InvalidValidatorError) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import { createLogger as winstonCreateLogger, transports, format } from "winston" 6 | import { inspect } from "util" 7 | import chalk from "chalk" 8 | import type { Logger } from "../types.js" 9 | import replace from "lodash/replace.js" 10 | import { EOL } from "os" 11 | 12 | const caporalFormat = format.printf((data) => { 13 | const { level, ...meta } = data 14 | let { message } = data 15 | let prefix = "" 16 | 17 | const levelStr = getLevelString(level) 18 | const metaStr = formatMeta(meta as unknown as Record) 19 | 20 | if (metaStr !== "") { 21 | message += `${EOL}${levelStr}: ${metaStr}` 22 | } 23 | 24 | if (level === "error") { 25 | // TODO(cleanup) 26 | const paddingLeft = (meta as unknown as { paddingLeft: number | undefined }) 27 | .paddingLeft 28 | const spaces = " ".repeat(paddingLeft || 7) 29 | prefix = EOL 30 | message = `${replace(message, new RegExp(EOL, "g"), EOL + spaces)}${EOL}` 31 | } 32 | 33 | return `${prefix}${levelStr}: ${message}` 34 | }) 35 | 36 | function formatMeta(meta: Record): string { 37 | delete meta.message 38 | delete meta[Symbol.for("level") as unknown as string] 39 | delete meta[Symbol.for("message") as unknown as string] 40 | delete meta[Symbol.for("splat") as unknown as string] 41 | if (Object.keys(meta).length) { 42 | return inspect(meta, { 43 | showHidden: false, 44 | colors: logger.colorsEnabled, 45 | }) 46 | } 47 | return "" 48 | } 49 | 50 | function getLevelString(level: string): string { 51 | if (!logger.colorsEnabled) { 52 | return level 53 | } 54 | let levelStr = level 55 | switch (level) { 56 | case "error": 57 | levelStr = chalk.bold.redBright(level) 58 | break 59 | case "warn": 60 | levelStr = chalk.hex("#FF9900")(level) 61 | break 62 | case "info": 63 | levelStr = chalk.hex("#569cd6")(level) 64 | break 65 | case "debug": 66 | case "silly": 67 | levelStr = chalk.dim(level) 68 | break 69 | } 70 | return levelStr 71 | } 72 | 73 | export let logger: Logger = createDefaultLogger() 74 | 75 | export function setLogger(loggerObj: Logger): void { 76 | logger = loggerObj 77 | } 78 | 79 | export function getLogger(): Logger { 80 | return logger 81 | } 82 | 83 | export function createDefaultLogger(): Logger { 84 | const logger = winstonCreateLogger({ 85 | transports: [ 86 | new transports.Console({ 87 | format: format.combine(format.splat(), caporalFormat), 88 | }), 89 | ], 90 | }) as Logger 91 | // disableColors() disable on the logger level, 92 | // while chalk supports the --color/--no-color flag 93 | // as well as the FORCE_COLOR env var 94 | logger.disableColors = () => { 95 | logger.transports[0].format = caporalFormat 96 | logger.colorsEnabled = false 97 | } 98 | logger.colorsEnabled = chalk.supportsColor !== false 99 | return logger 100 | } 101 | -------------------------------------------------------------------------------- /src/help/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @module caporal/help 4 | */ 5 | import { Command } from "../command/index.js" 6 | import { Program } from "../program/index.js" 7 | import replace from "lodash/replace.js" 8 | import chalk from "chalk" 9 | import { buildTable } from "./utils.js" 10 | import { colorize } from "../utils/colorize.js" 11 | import * as allTemplates from "./templates/index.js" 12 | import { getGlobalOptions } from "../option/index.js" 13 | import { CustomizedHelpMap, CustomizedHelpOpts, TemplateContext, Template } from "./types.js" 14 | 15 | const templates = new Map(Object.entries(allTemplates)) 16 | const customHelpMap: CustomizedHelpMap = new Map() 17 | 18 | /** 19 | * Customize the help 20 | * 21 | * @param obj 22 | * @param text 23 | * @param options 24 | * @internal 25 | */ 26 | export function customizeHelp( 27 | obj: Command | Program, 28 | text: string, 29 | options: Partial, 30 | ): void { 31 | const opts: CustomizedHelpOpts = { 32 | sectionName: "", 33 | colorize: true, 34 | ...options, 35 | } 36 | const data = customHelpMap.get(obj) || [] 37 | data.push({ text, options: opts }) 38 | customHelpMap.set(obj, data) 39 | } 40 | 41 | /** 42 | * Register a new help template 43 | * 44 | * @param name Template name 45 | * @param template Template function 46 | * 47 | */ 48 | export function registerTemplate( 49 | name: string, 50 | template: Template, 51 | ): Map { 52 | return templates.set(name, template) 53 | } 54 | 55 | /** 56 | * Helper to be used to call templates from within templates 57 | * 58 | * @param name Template name 59 | * @param ctx Execution context 60 | * @internal 61 | */ 62 | export async function tpl(name: string, ctx: TemplateContext): Promise { 63 | const template = templates.get(name) 64 | if (!template) { 65 | throw Error(`Caporal setup error: Unknown help template '${name}'`) 66 | } 67 | return template(ctx) 68 | } 69 | 70 | /** 71 | * @internal 72 | * @param program 73 | * @param command 74 | */ 75 | export function getContext(program: Program, command?: Command): TemplateContext { 76 | const spaces = " ".repeat(2) 77 | const ctx: TemplateContext = { 78 | prog: program, 79 | cmd: command, 80 | chalk: chalk, 81 | colorize: colorize, 82 | customHelp: customHelpMap, 83 | tpl, 84 | globalOptions: getGlobalOptions(), 85 | table: buildTable, 86 | spaces, 87 | indent(str: string, sp = spaces) { 88 | return sp + replace(str.trim(), /(\r\n|\r|\n)/g, "\n" + sp) 89 | }, 90 | eol: "\n", 91 | eol2: "\n\n", 92 | eol3: "\n\n\n", 93 | } 94 | return ctx 95 | } 96 | 97 | /** 98 | * Return the help text 99 | * 100 | * @param program Program instance 101 | * @param command Command instance, if any 102 | * @internal 103 | */ 104 | export async function getHelp(program: Program, command?: Command): Promise { 105 | const ctx = getContext(program, command) 106 | return [await tpl("header", ctx), await tpl("usage", ctx)].join("") 107 | } 108 | -------------------------------------------------------------------------------- /src/validator/caporal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import type { ParserTypes, Argument, Option } from "../types.js" 7 | import { CaporalValidator } from "../types.js" 8 | import { ValidationError } from "../error/index.js" 9 | import { isNumeric } from "../option/utils.js" 10 | import flatMap from "lodash/flatMap.js" 11 | 12 | import { 13 | isNumericValidator, 14 | isStringValidator, 15 | isBoolValidator, 16 | isArrayValidator, 17 | } from "./utils.js" 18 | 19 | // Re-export for convenience 20 | export { CaporalValidator } 21 | 22 | export function validateWithCaporal( 23 | validator: CaporalValidator, 24 | value: ParserTypes | ParserTypes[], 25 | context: Argument | Option, 26 | skipArrayValidation = false, 27 | ): ParserTypes | ParserTypes[] { 28 | if (!skipArrayValidation && isArrayValidator(validator)) { 29 | return validateArrayFlag(validator, value, context) 30 | } else if (Array.isArray(value)) { 31 | // should not happen! 32 | throw new ValidationError({ 33 | error: "Expected a scalar value, got an array", 34 | value, 35 | validator, 36 | context, 37 | }) 38 | } else if (isNumericValidator(validator)) { 39 | return validateNumericFlag(validator, value, context) 40 | } else if (isStringValidator(validator)) { 41 | return validateStringFlag(value) 42 | } else if (isBoolValidator(validator)) { 43 | return validateBoolFlag(value, context) 44 | } 45 | return value 46 | } 47 | 48 | /** 49 | * The string validator actually just cast the value to string 50 | * 51 | * @param value 52 | * @ignore 53 | */ 54 | export function validateBoolFlag( 55 | value: ParserTypes, 56 | context: Argument | Option, 57 | ): boolean { 58 | if (typeof value === "boolean") { 59 | return value 60 | } else if (/^(true|false|yes|no|0|1)$/i.test(String(value)) === false) { 61 | throw new ValidationError({ 62 | value, 63 | validator: CaporalValidator.BOOLEAN, 64 | context, 65 | }) 66 | } 67 | return /^0|no|false$/.test(String(value)) === false 68 | } 69 | 70 | export function validateNumericFlag( 71 | validator: number, 72 | value: ParserTypes, 73 | context: Argument | Option, 74 | ): number { 75 | const str = value + "" 76 | if (Array.isArray(value) || !isNumeric(str)) { 77 | throw new ValidationError({ 78 | value, 79 | validator, 80 | context, 81 | }) 82 | } 83 | return parseFloat(str) 84 | } 85 | 86 | export function validateArrayFlag( 87 | validator: number, 88 | value: ParserTypes | ParserTypes[], 89 | context: Argument | Option, 90 | ): ParserTypes | ParserTypes[] { 91 | const values: ParserTypes[] = 92 | typeof value === "string" ? value.split(",") : !Array.isArray(value) ? [value] : value 93 | return flatMap(values, (el) => validateWithCaporal(validator, el, context, true)) 94 | } 95 | 96 | /** 97 | * The string validator actually just cast the value to string 98 | * 99 | * @param value 100 | * @ignore 101 | */ 102 | export function validateStringFlag(value: ParserTypes | ParserTypes[]): string { 103 | return value + "" 104 | } 105 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | | Matthias ETIENNE
| Michael Inthilith
| Andreas Håkansson
| Fabio Spampinato
| @Szpadel
| 2 | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------: | 3 | 4 | ...and Alex Sears, Bart Busscots, Bobby Earl, Brayden Winterton, Daniel Ruf, Drew J. Sonne, Francisco Javier, George Cox, Ihor Halchevskyi, Ilyar, Jorge Bucaran, Krzysztof Budnik, Kévin Berthommier, Köble István, limichange, Lucas Santos, Luciano Mammino, Masayuki Nagamachi, Max Filenko, Michel Hua, Prayag Verma , Robert Rossmann, Romello Goodman, Stinson, Tiago Danin, Tim, Víctor Lara, Yousaf Nabi, @cazgp, @dwyschka. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - beta 7 | - alpha 8 | - next 9 | - next-major 10 | - 1.x 11 | - 1.4.x 12 | - 2.x 13 | jobs: 14 | release: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: 12 24 | 25 | - name: Get npm cache directory 26 | id: npm-cache 27 | run: | 28 | echo "::set-output name=dir::$(npm config get cache)" 29 | 30 | # See https://github.com/actions/cache/blob/master/examples.md#node---npm 31 | - name: Cache node modules 32 | uses: actions/cache@v1 33 | env: 34 | cache-name: cache-node-modules 35 | with: 36 | path: ${{ steps.npm-cache.outputs.dir }} 37 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-node- 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: Build 45 | run: npm run build 46 | 47 | - name: Test 48 | run: npm run test 49 | 50 | - name: Dist Test 51 | run: npm run test:dist 52 | 53 | - name: Semantic Release 54 | uses: cycjimmy/semantic-release-action@v2 55 | id: semantic 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | 60 | - name: Deploy website 61 | if: ${{ steps.semantic.outputs.new_release_published == 'true' }} 62 | uses: amondnet/now-deployment@v2 63 | id: now-deploy 64 | with: 65 | github-comment: false 66 | zeit-token: ${{ secrets.ZEIT_TOKEN }} 67 | now-args: "-A now.json --prod" 68 | now-org-id: ${{ secrets.ZEIT_ORG_ID}} 69 | now-project-id: ${{ secrets.ZEIT_PROJECT_ID}} 70 | 71 | - name: Inject slug/short variables 72 | uses: rlespinasse/github-slug-action@v2.x 73 | 74 | - name: Alias 75 | if: ${{ steps.semantic.outputs.new_release_published == 'true' }} 76 | id: caporal-preview 77 | env: 78 | NOW_ORG_ID: ${{ secrets.ZEIT_ORG_ID}} 79 | NOW_PROJECT_ID: ${{ secrets.ZEIT_PROJECT_ID}} 80 | run: | 81 | clean_url=$(echo "${{ steps.now-deploy.outputs.preview-url }}" | cut -c9-) 82 | clean_ref="${{ env.GITHUB_REF_SLUG_URL }}" 83 | if [ "$clean_ref" == "master" ]; then 84 | npm run now -- alias -A now.json --token ${{ secrets.ZEIT_TOKEN }} set $clean_url caporal.io 85 | clean_ref="www" 86 | fi 87 | npm run now -- alias -A now.json --token ${{ secrets.ZEIT_TOKEN }} set $clean_url ${clean_ref}.caporal.io 88 | echo "::set-output name=url_ref::https://${clean_ref}.caporal.io" 89 | 90 | - uses: chrnorm/deployment-action@releases/v1 91 | name: Create GitHub deployment for branch 92 | id: branch-deployment 93 | if: ${{ steps.semantic.outputs.new_release_published == 'true' }} 94 | with: 95 | initial_status: success 96 | token: ${{ secrets.GITHUB_TOKEN }} 97 | target_url: ${{ steps.caporal-preview.outputs.url_ref }} 98 | environment: Production 99 | -------------------------------------------------------------------------------- /src/help/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | import { table, getBorderCharacters } from "table" 6 | import filter from "lodash/filter.js" 7 | import type { TemplateContext } from "./types.js" 8 | import type { Option, Argument } from "../types.js" 9 | import type { Command } from "../command/index.js" 10 | 11 | export function buildTable(data: string[][], options = {}): string { 12 | return table(data, { 13 | border: getBorderCharacters(`void`), 14 | columnDefault: { 15 | paddingLeft: 0, 16 | paddingRight: 2, 17 | }, 18 | columns: { 19 | 0: { 20 | paddingLeft: 4, 21 | width: 35, 22 | }, 23 | 1: { 24 | width: 55, 25 | wrapWord: true, 26 | paddingRight: 0, 27 | }, 28 | }, 29 | drawHorizontalLine: () => { 30 | return false 31 | }, 32 | ...options, 33 | }) 34 | } 35 | 36 | export function getDefaultValueHint(obj: Argument | Option): string | undefined { 37 | return obj.default !== undefined && 38 | !("boolean" in obj && obj.boolean && obj.default === false) 39 | ? "default: " + JSON.stringify(obj.default) 40 | : undefined 41 | } 42 | 43 | function getOptionSynopsisHelp( 44 | opt: Option, 45 | { eol: crlf, chalk: c }: TemplateContext, 46 | ): string { 47 | return ( 48 | opt.synopsis + 49 | (opt.required && opt.default === undefined ? crlf + c.dim("required") : "") 50 | ) 51 | } 52 | 53 | export function getOptionsTable( 54 | options: Option[], 55 | ctx: TemplateContext, 56 | title = "OPTIONS", 57 | ): string { 58 | options = filter(options, "visible") 59 | if (!options.length) { 60 | return "" 61 | } 62 | const { chalk: c, eol: crlf, table, spaces } = ctx 63 | const help = spaces + c.bold(title) + crlf + crlf 64 | const rows = options.map((opt) => { 65 | const def = getDefaultValueHint(opt) 66 | const more = [opt.typeHint, def].filter((d) => d).join(", ") 67 | const syno = getOptionSynopsisHelp(opt, ctx) 68 | const desc = opt.description + (more.length ? crlf + c.dim(more) : "") 69 | return [syno, desc] 70 | }) 71 | return help + table(rows) 72 | } 73 | 74 | export function getArgumentsTable( 75 | args: Argument[], 76 | ctx: TemplateContext, 77 | title = "ARGUMENTS", 78 | ): string { 79 | if (!args.length) { 80 | return "" 81 | } 82 | const { chalk: c, eol, eol2, table, spaces } = ctx 83 | const help = spaces + c.bold(title) + eol2 84 | const rows = args.map((a) => { 85 | const def = getDefaultValueHint(a) 86 | const more = [a.typeHint, def].filter((d) => d).join(", ") 87 | const desc = a.description + (more.length ? eol + c.dim(more) : "") 88 | return [a.synopsis, desc] 89 | }) 90 | return help + table(rows) 91 | } 92 | 93 | export function getCommandsTable( 94 | commands: Command[], 95 | ctx: TemplateContext, 96 | title = "COMMANDS", 97 | ): string { 98 | const { chalk, prog, eol2, table, spaces } = ctx 99 | const cmdHint = `Type '${prog.getBin()} help ' to get some help about a command` 100 | const help = 101 | spaces + chalk.bold(title) + ` ${chalk.dim("\u2014")} ` + chalk.dim(cmdHint) + eol2 102 | const rows = commands 103 | .filter((c) => c.visible) 104 | .map((cmd) => { 105 | return [chalk.keyword("orange")(cmd.name), cmd.description || ""] 106 | }) 107 | 108 | return help + table(rows) 109 | } 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@donmccurdy/caporal", 3 | "version": "0.0.10", 4 | "description": "A full-featured framework for building command line applications (cli) with node.js", 5 | "type": "module", 6 | "sideEffects": false, 7 | "source": "./src/index.ts", 8 | "types": "./dist/index.d.ts", 9 | "main": "./dist/caporal.cjs", 10 | "exports": { 11 | "types": "./dist/index.d.ts", 12 | "require": "./dist/caporal.cjs", 13 | "default": "./dist/caporal.modern.js" 14 | }, 15 | "scripts": { 16 | "lint": "eslint src/**/*.ts examples", 17 | "lint:fix": "eslint --fix src/**/*.ts examples/**/*.{ts,js}", 18 | "prebuild": "npm run clean", 19 | "build": "microbundle --format modern,cjs --target node --no-compress", 20 | "build:ci": "npm run build", 21 | "watch": "microbundle watch --format modern,cjs --target node --no-compress", 22 | "postbuild:ci": "npm run test:ci", 23 | "refresh-markdown": "./scripts/gen-contributors.js && ./scripts/gen-dependents.js && ./scripts/gen-readme.js", 24 | "clean": "rimraf dist", 25 | "test": "ava src/**/*.test.ts", 26 | "test:ci": "echo \"not implemented\"", 27 | "test:dist": "ava dist-tests/**/*.test.ts", 28 | "preversion": "npm run build && npm run test && npm run test:dist", 29 | "version": "npm run build && git add -u", 30 | "postversion": "git push && git push --tags && npm publish" 31 | }, 32 | "files": [ 33 | "dist/**/*.{ts,js}" 34 | ], 35 | "engines": { 36 | "node": ">= 18" 37 | }, 38 | "homepage": "https://github.com/donmccurdy/Caporal.js", 39 | "keywords": [ 40 | "cli", 41 | "command", 42 | "commander", 43 | "clap", 44 | "cli-app", 45 | "minimist", 46 | "cli-table", 47 | "command line apps", 48 | "option", 49 | "parser", 50 | "argument", 51 | "flag", 52 | "args", 53 | "argv" 54 | ], 55 | "author": "Don McCurdy and Matthias ETIENNE (https://github.com/mattallty)", 56 | "repository": "donmccurdy/Caporal.js", 57 | "license": "MIT", 58 | "dependencies": { 59 | "@types/glob": "^8.1.0", 60 | "@types/lodash": "^4.14.197", 61 | "@types/node": "20.5.6", 62 | "@types/table": "5.0.0", 63 | "@types/wrap-ansi": "^8.0.1", 64 | "chalk": "3.0.0", 65 | "glob": "^10.3.3", 66 | "lodash": "^4.17.21", 67 | "table": "5.4.6", 68 | "winston": "3.10.0", 69 | "wrap-ansi": "^8.1.0" 70 | }, 71 | "devDependencies": { 72 | "@octokit/rest": "^17.2.1", 73 | "@types/sinon": "^10.0.16", 74 | "@typescript-eslint/eslint-plugin": "^6.4.0", 75 | "@typescript-eslint/parser": "^6.4.0", 76 | "ava": "^5.3.1", 77 | "cheerio": "^1.0.0-rc.3", 78 | "eslint": "^8.47.0", 79 | "eslint-config-prettier": "^9.0.0", 80 | "eslint-plugin-prettier": "^5.0.0", 81 | "json": "^9.0.4", 82 | "markdown-table": "^2.0.0", 83 | "memfs": "^3.1.2", 84 | "microbundle": "^0.15.1", 85 | "node-fetch": "^2.6.0", 86 | "npm-run-all": "^4.1.5", 87 | "prettier": "^3.0.2", 88 | "rimraf": "^5.0.1", 89 | "sinon": "^15.2.0", 90 | "strip-ansi": "^7.1.0", 91 | "suppress-experimental-warnings": "^1.1.17", 92 | "ts-node": "^10.9.1", 93 | "typescript": "5.1.6" 94 | }, 95 | "publishConfig": { 96 | "access": "public" 97 | }, 98 | "ava": { 99 | "extensions": { 100 | "ts": "module" 101 | }, 102 | "nodeArguments": [ 103 | "--loader=ts-node/esm", 104 | "--require=suppress-experimental-warnings" 105 | ] 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/option/__tests__/option.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { createOption } from "../index.js" 3 | import { OptionSynopsisSyntaxError } from "../../error/index.js" 4 | import { Option } from "../../types.js" 5 | 6 | test("createOption() should create a basic short option", (t) => { 7 | const opt = createOption("-f ", "My simple option") 8 | t.deepEqual(trimOption(opt), { 9 | allNames: ["f"], 10 | allNotations: ["-f"], 11 | shortName: "f", 12 | shortNotation: "-f", 13 | description: "My simple option", 14 | choices: [], 15 | name: "f", 16 | valueType: 1, 17 | boolean: false, 18 | valueRequired: true, 19 | required: false, 20 | synopsis: "-f ", 21 | variadic: false, 22 | visible: true, 23 | kind: "option", 24 | notation: "-f", 25 | }) 26 | }) 27 | 28 | test("createOption() should create a basic long option", (t) => { 29 | const opt = createOption("--file ", "My simple option") 30 | t.deepEqual(trimOption(opt), { 31 | allNames: ["file"], 32 | allNotations: ["--file"], 33 | longName: "file", 34 | longNotation: "--file", 35 | description: "My simple option", 36 | choices: [], 37 | name: "file", 38 | valueType: 1, 39 | boolean: false, 40 | valueRequired: true, 41 | required: false, 42 | synopsis: "--file ", 43 | variadic: false, 44 | visible: true, 45 | kind: "option", 46 | notation: "--file", 47 | }) 48 | }) 49 | 50 | test("createOption() should create a short option without value", (t) => { 51 | const opt = createOption("-f", "My simple option") 52 | t.deepEqual(trimOption(opt), { 53 | allNames: ["f"], 54 | allNotations: ["-f"], 55 | shortName: "f", 56 | shortNotation: "-f", 57 | description: "My simple option", 58 | choices: [], 59 | name: "f", 60 | boolean: true, 61 | valueRequired: false, 62 | valueType: 2, 63 | default: false, 64 | required: false, 65 | synopsis: "-f", 66 | variadic: false, 67 | visible: true, 68 | kind: "option", 69 | notation: "-f", 70 | }) 71 | }) 72 | 73 | test("createOption() should create a short option with optional value", (t) => { 74 | const opt = createOption("-p [mean]", "My simple option") 75 | t.deepEqual(trimOption(opt), { 76 | allNames: ["p"], 77 | allNotations: ["-p"], 78 | shortName: "p", 79 | shortNotation: "-p", 80 | description: "My simple option", 81 | choices: [], 82 | name: "p", 83 | boolean: false, 84 | valueRequired: false, 85 | valueType: 0, 86 | required: false, 87 | synopsis: "-p [mean]", 88 | variadic: false, 89 | visible: true, 90 | kind: "option", 91 | notation: "-p", 92 | }) 93 | }) 94 | 95 | test("createOption() should set choices if validator is an array", (t) => { 96 | const opt = createOption("-p [mean]", "My simple option", { 97 | validator: ["card", "cash"], 98 | }) 99 | t.deepEqual(trimOption(opt), { 100 | allNames: ["p"], 101 | allNotations: ["-p"], 102 | shortName: "p", 103 | shortNotation: "-p", 104 | description: "My simple option", 105 | choices: ["card", "cash"], 106 | name: "p", 107 | boolean: false, 108 | typeHint: 'one of "card","cash"', 109 | valueRequired: false, 110 | validator: ["card", "cash"], 111 | valueType: 0, 112 | required: false, 113 | synopsis: "-p [mean]", 114 | variadic: false, 115 | visible: true, 116 | kind: "option", 117 | notation: "-p", 118 | }) 119 | }) 120 | 121 | test("createOption() should throw on invalid options", (t) => { 122 | for (const [a] of [["bad synopsis"], ["another"], ["---fooo"]]) { 123 | t.throws(() => createOption(a, "My simple option"), { 124 | instanceOf: OptionSynopsisSyntaxError, 125 | }) 126 | } 127 | }) 128 | 129 | function trimOption(obj: Option): Record { 130 | const result = obj as unknown as Record 131 | for (let key in result) { 132 | if (result[key] === undefined) { 133 | delete result[key] 134 | } 135 | } 136 | return result 137 | } 138 | -------------------------------------------------------------------------------- /assets/caporal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/option/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import camelCase from "lodash/camelCase.js" 7 | import kebabCase from "lodash/kebabCase.js" 8 | import { OptionSynopsisSyntaxError } from "../error/index.js" 9 | import { OptionValueType } from "../types.js" 10 | import type { OptionSynopsis, ParserTypes } from "../types.js" 11 | 12 | const REG_SHORT_OPT = /^-[a-z]$/i 13 | const REG_LONG_OPT = /^--[a-z]{2,}/i 14 | const REG_OPT = /^(-[a-zA-Z]|--\D{1}[\w-]+)/ 15 | 16 | function isShortOpt(flag: string): boolean { 17 | return REG_SHORT_OPT.test(flag) 18 | } 19 | 20 | function isLongOpt(flag: string): boolean { 21 | return REG_LONG_OPT.test(flag) 22 | } 23 | 24 | /** 25 | * Specific version of camelCase which does not lowercase short flags 26 | * 27 | * @param name Flag short or long name 28 | */ 29 | function camelCaseOpt(name: string): string { 30 | return name.length === 1 ? name : camelCase(name) 31 | } 32 | 33 | export function getCleanNameFromNotation(str: string, camelCased = true): string { 34 | str = str 35 | .replace(/([[\]<>]+)/g, "") 36 | .replace("...", "") 37 | .replace(/^no-/, "") 38 | return camelCased ? camelCaseOpt(str) : str 39 | } 40 | 41 | export function getDashedOpt(name: string): string { 42 | const l = Math.min(name.length, 2) 43 | return "-".repeat(l) + kebabCase(name) 44 | } 45 | 46 | export function isNumeric(n: string): boolean { 47 | return !isNaN(parseFloat(n)) && isFinite(Number(n)) 48 | } 49 | 50 | export function isOptionStr(str?: string): str is string { 51 | return str !== undefined && str !== "--" && REG_OPT.test(str) 52 | } 53 | 54 | export function isConcatenatedOpt(str: string): string[] | false { 55 | if (str.match(/^-([a-z]{2,})/i)) { 56 | return str.substr(1).split("") 57 | } 58 | return false 59 | } 60 | 61 | export function isNegativeOpt(opt: string): boolean { 62 | return opt.substr(0, 5) === "--no-" 63 | } 64 | 65 | export function isOptArray(flag: ParserTypes | ParserTypes[]): flag is ParserTypes[] { 66 | return Array.isArray(flag) 67 | } 68 | 69 | export function formatOptName(name: string): string { 70 | return camelCaseOpt(name.replace(/^--?(no-)?/, "")) 71 | } 72 | 73 | /** 74 | * Parse a option synopsis 75 | * 76 | * @example 77 | * parseSynopsis("-f, --file ") 78 | * // Returns... 79 | * { 80 | * longName: 'file', 81 | * longNotation: '--file', 82 | * shortNotation: '-f', 83 | * shortName: 'f' 84 | * valueType: 0, // 0 = optional, 1 = required, 2 = no value 85 | * variadic: false 86 | * name: 'file' 87 | * notation: '--file' // either the long or short notation 88 | * } 89 | * 90 | * @param synopsis 91 | * @ignore 92 | */ 93 | export function parseOptionSynopsis(synopsis: string): OptionSynopsis { 94 | // synopsis = synopsis.trim() 95 | const analysis: OptionSynopsis = { 96 | variadic: false, 97 | valueType: OptionValueType.None, 98 | valueRequired: false, 99 | allNames: [], 100 | allNotations: [], 101 | name: "", 102 | notation: "", 103 | synopsis, 104 | } 105 | 106 | const infos: Partial = synopsis 107 | .split(/[\s\t,]+/) 108 | .reduce((acc, value) => { 109 | if (isLongOpt(value)) { 110 | acc.longNotation = value 111 | acc.longName = getCleanNameFromNotation(value.substring(2)) 112 | acc.allNames.push(acc.longName) 113 | acc.allNotations.push(value) 114 | } else if (isShortOpt(value)) { 115 | acc.shortNotation = value 116 | acc.shortName = value.substring(1) 117 | acc.allNames.push(acc.shortName) 118 | acc.allNotations.push(value) 119 | } else if (value.substring(0, 1) === "[") { 120 | acc.valueType = OptionValueType.Optional 121 | acc.valueRequired = false 122 | acc.variadic = value.substr(-4, 3) === "..." 123 | } else if (value.substring(0, 1) === "<") { 124 | acc.valueType = OptionValueType.Required 125 | acc.valueRequired = true 126 | acc.variadic = value.substr(-4, 3) === "..." 127 | } 128 | return acc 129 | }, analysis) 130 | 131 | if (infos.longName === undefined && infos.shortName === undefined) { 132 | throw new OptionSynopsisSyntaxError(synopsis) 133 | } 134 | 135 | infos.name = infos.longName || (infos.shortName as string) 136 | infos.notation = infos.longNotation || (infos.shortNotation as string) 137 | 138 | const fullSynopsis = { ...infos } as OptionSynopsis 139 | 140 | return fullSynopsis 141 | } 142 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Continous integration" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | run-tests: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | node: ["10", "12", "14", "16"] 15 | os: ["macos-latest", "ubuntu-latest", "windows-latest"] 16 | 17 | name: "node ${{ matrix.node }} / ${{ matrix.os }}" 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Get npm cache directory 21 | id: npm-cache 22 | run: | 23 | echo "::set-output name=dir::$(npm config get cache)" 24 | 25 | # See https://github.com/actions/cache/blob/master/examples.md#node---npm 26 | - name: Cache node modules 27 | uses: actions/cache@v1 28 | env: 29 | cache-name: cache-node-modules 30 | with: 31 | path: ${{ steps.npm-cache.outputs.dir }} 32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node- 35 | 36 | - name: Setup Node.js v${{ matrix.node }} on ${{ matrix.os }} 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: ${{ matrix.node }} 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | # build before testing as some tests actually test the build 45 | - name: Build 46 | run: npm run build 47 | 48 | - name: Lint 49 | run: npm run lint 50 | 51 | - name: Run tests 52 | run: npm test 53 | 54 | - name: Run dist tests 55 | run: npm run test:dist 56 | 57 | # - name: Jest Annotations & Coverage 58 | # if: ${{ matrix.node == '12' && matrix.os == 'ubuntu-latest' }} 59 | # uses: mattallty/jest-github-action@v1.0.2 60 | # env: 61 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | deploy-preview: 64 | name: "Deploy preview website" 65 | runs-on: ubuntu-latest 66 | needs: run-tests 67 | continue-on-error: true 68 | if: ${{ github.event_name == 'pull_request' || github.ref != 'master' }} 69 | steps: 70 | - uses: actions/checkout@v2 71 | - uses: amondnet/now-deployment@v2 72 | id: now-deploy 73 | with: 74 | github-comment: false 75 | zeit-token: ${{ secrets.ZEIT_TOKEN }} 76 | now-args: "-A now.preview.json" 77 | now-org-id: ${{ secrets.ZEIT_ORG_ID}} 78 | now-project-id: ${{ secrets.ZEIT_PROJECT_ID}} 79 | 80 | - uses: actions/setup-node@v1 81 | with: 82 | node-version: 12 83 | 84 | - name: Install dependencies 85 | run: npm ci 86 | 87 | - name: Alias 88 | id: caporal-preview 89 | env: 90 | NOW_ORG_ID: ${{ secrets.ZEIT_ORG_ID}} 91 | NOW_PROJECT_ID: ${{ secrets.ZEIT_PROJECT_ID}} 92 | run: | 93 | clean_url=$(echo "${{ steps.now-deploy.outputs.preview-url }}" | cut -c9-) 94 | clean_ref=$(basename "${{ github.head_ref }}") 95 | clean_sha=$(echo "${{ github.sha }}" | cut -c1-7) 96 | npm run now -- alias -A now.preview.json --token ${{ secrets.ZEIT_TOKEN }} set $clean_url ${clean_ref}.caporal.run 97 | npm run now -- alias -A now.preview.json --token ${{ secrets.ZEIT_TOKEN }} set $clean_url ${clean_sha}.caporal.run 98 | echo "::set-output name=url_ref::https://${clean_ref}.caporal.run" 99 | echo "::set-output name=url_sha::https://${clean_sha}.caporal.run" 100 | 101 | - uses: chrnorm/deployment-action@releases/v1 102 | name: Create GitHub deployment for commit 103 | id: commit-deployment 104 | if: ${{ success() }} 105 | with: 106 | initial_status: success 107 | token: ${{ secrets.GITHUB_TOKEN }} 108 | target_url: ${{ steps.caporal-preview.outputs.url_sha }} 109 | environment: Commit-preview 110 | 111 | - uses: chrnorm/deployment-action@releases/v1 112 | name: Create GitHub deployment for branch 113 | id: branch-deployment 114 | if: ${{ success() }} 115 | with: 116 | initial_status: success 117 | token: ${{ secrets.GITHUB_TOKEN }} 118 | target_url: ${{ steps.caporal-preview.outputs.url_ref }} 119 | environment: Branch-preview 120 | 121 | - name: comment PR 122 | uses: unsplash/comment-on-pr@master 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | with: 126 | msg: | 127 | :rocket: Caporal website preview available at: ${{ steps.caporal-preview.outputs.url_ref }} 128 | You can also checkout the [coverage report here](${{ steps.caporal-preview.outputs.url_ref }}/coverage/lcov-report/). 129 | check_for_duplicate_msg: true 130 | -------------------------------------------------------------------------------- /src/argument/validate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import reduce from "lodash/reduce.js" 7 | import { TooManyArgumentsError, MissingArgumentError, BaseError } from "../error/index.js" 8 | import type { 9 | Argument, 10 | ArgumentsRange, 11 | ParsedArguments, 12 | ParsedArgumentsObject, 13 | ParsedArgument, 14 | Promisable, 15 | } from "../types.js" 16 | 17 | import type { Command } from "../command/index.js" 18 | import { validate } from "../validator/validate.js" 19 | import { findArgument } from "./find.js" 20 | 21 | /** 22 | * Get the number of required argument for a given command 23 | * 24 | * @param cmd 25 | */ 26 | export function getRequiredArgsCount(cmd: Command): number { 27 | return cmd.args.filter((a) => a.required).length 28 | } 29 | 30 | export function getArgsObjectFromArray( 31 | cmd: Command, 32 | args: ParsedArguments, 33 | ): ParsedArgumentsObject { 34 | const result: ParsedArgumentsObject = {} 35 | return cmd.args.reduce((acc, arg, index) => { 36 | if (args[index] !== undefined) { 37 | acc[arg.name] = args[index] 38 | } else if (arg.default !== undefined) { 39 | acc[arg.name] = arg.default 40 | } 41 | return acc 42 | }, result) 43 | } 44 | 45 | /** 46 | * Check if the given command has at leat one variadic argument 47 | * 48 | * @param cmd 49 | */ 50 | export function hasVariadicArgument(cmd: Command): boolean { 51 | return cmd.args.some((a) => a.variadic) 52 | } 53 | 54 | export function getArgsRange(cmd: Command): ArgumentsRange { 55 | const min = getRequiredArgsCount(cmd) 56 | const max = hasVariadicArgument(cmd) ? Infinity : cmd.args.length 57 | return { min, max } 58 | } 59 | 60 | export function checkRequiredArgs( 61 | cmd: Command, 62 | args: ParsedArgumentsObject, 63 | parsedArgv: ParsedArguments, 64 | ): BaseError[] { 65 | const errors = cmd.args.reduce((acc, arg) => { 66 | if (args[arg.name] === undefined && arg.required) { 67 | acc.push(new MissingArgumentError(arg, cmd)) 68 | } 69 | return acc 70 | }, [] as BaseError[]) 71 | 72 | // Check if there is more args than specified 73 | if (cmd.strictArgsCount) { 74 | const numArgsError = checkNumberOfArgs(cmd, parsedArgv) 75 | if (numArgsError) { 76 | errors.push(numArgsError) 77 | } 78 | } 79 | 80 | return errors 81 | } 82 | 83 | function checkNumberOfArgs( 84 | cmd: Command, 85 | args: ParsedArguments, 86 | ): TooManyArgumentsError | void { 87 | const range = getArgsRange(cmd) 88 | const argsCount = Object.keys(args).length 89 | if (range.max !== Infinity && range.max < Object.keys(args).length) { 90 | return new TooManyArgumentsError(cmd, range, argsCount) 91 | } 92 | } 93 | 94 | export function removeCommandFromArgs( 95 | cmd: Command, 96 | args: ParsedArguments, 97 | ): ParsedArguments { 98 | const words = cmd.name.split(" ").length 99 | return args.slice(words) 100 | } 101 | 102 | function validateArg(arg: Argument, value: ParsedArgument): ReturnType { 103 | return arg.validator ? validate(value, arg.validator, arg) : value 104 | } 105 | 106 | type VariadicArgument = ParsedArgument 107 | type ArgsValidatorAccumulator = Record> 108 | 109 | interface ArgsValidationResult { 110 | args: ParsedArgumentsObject 111 | errors: BaseError[] 112 | } 113 | 114 | /** 115 | * 116 | * @param cmd 117 | * @param parsedArgv 118 | * 119 | * @todo Bugs: 120 | * 121 | * 122 | * ts-node examples/pizza/pizza.ts cancel my-order jhazd hazd 123 | * 124 | * -> result ok, should be too many arguments 125 | * 126 | */ 127 | export async function validateArgs( 128 | cmd: Command, 129 | parsedArgv: ParsedArguments, 130 | ): Promise { 131 | // remove the command from the argv array 132 | const formatedArgs = cmd.isProgramCommand() 133 | ? parsedArgv 134 | : removeCommandFromArgs(cmd, parsedArgv) 135 | 136 | // transfrom args array to object, and set defaults for arguments not passed 137 | const argsObj = getArgsObjectFromArray(cmd, formatedArgs) 138 | const errors: BaseError[] = [] 139 | 140 | const validations = reduce( 141 | argsObj, 142 | (acc, value, key) => { 143 | const arg = findArgument(cmd, key) 144 | try { 145 | /* istanbul ignore if -- should not happen */ 146 | if (!arg) { 147 | throw new BaseError(`Unknown argumment ${key}`) 148 | } 149 | acc[key] = validateArg(arg, value) 150 | } catch (e) { 151 | errors.push(e as BaseError) 152 | } 153 | return acc 154 | }, 155 | {} as ArgsValidatorAccumulator, 156 | ) 157 | 158 | const result = await reduce( 159 | validations, 160 | async (prevPromise, value, key): Promise => { 161 | const collection = await prevPromise 162 | collection[key] = await value 163 | return collection 164 | }, 165 | Promise.resolve({}) as Promise, 166 | ) 167 | 168 | errors.push(...checkRequiredArgs(cmd, result, formatedArgs)) 169 | 170 | return { args: result, errors } 171 | } 172 | -------------------------------------------------------------------------------- /src/option/__tests__/global.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import test from "ava" 3 | import sinon from "sinon" 4 | import { Program } from "../../program/index.js" 5 | import { logger } from "../../logger/index.js" 6 | import { Action } from "../../types.js" 7 | import { disableGlobalOption, findGlobalOption, resetGlobalOptions } from "../index.js" 8 | 9 | const consoleLogSpy = sinon.stub(console, "log") 10 | const loggerInfoSpy = sinon.stub(logger, "info") 11 | 12 | // Winston does not use console.*. 13 | const logStdoutSpy = sinon.stub( 14 | (console as any)._stdout as { write: typeof console.log }, 15 | "write", 16 | ) 17 | 18 | test.beforeEach(() => { 19 | consoleLogSpy.resetHistory() 20 | loggerInfoSpy.resetHistory() 21 | logStdoutSpy.resetHistory() 22 | 23 | logger.level = "info" 24 | logger.colorsEnabled = true 25 | resetGlobalOptions() 26 | }) 27 | 28 | test.afterEach(() => { 29 | logger.colorsEnabled = true 30 | }) 31 | 32 | function createProgram() { 33 | return new Program().name("test-prog").bin("test-prog").version("xyz") 34 | } 35 | 36 | test.serial("-V should show program version and exit", async (t) => { 37 | const prog = createProgram() 38 | await prog.run(["-V"]) 39 | t.true(consoleLogSpy.calledWithExactly("xyz")) 40 | }) 41 | 42 | test.serial("--version should show program version and exit", async (t) => { 43 | const prog = createProgram() 44 | await prog.run(["--version"]) 45 | t.true(consoleLogSpy.calledWithExactly("xyz")) 46 | }) 47 | 48 | test.serial("--no-color should disable colors in output", async (t) => { 49 | const prog = createProgram() 50 | const action: Action = function ({ logger }) { 51 | logger.info("Hey!") 52 | } 53 | prog.argument("[first-arg]", "First argument").action(action) 54 | await prog.run(["--no-color", "foo"]) 55 | t.true(loggerInfoSpy.calledWithExactly("Hey!" as any)) 56 | }) 57 | 58 | test.serial("--color false should disable colors in output", async (t) => { 59 | const prog = createProgram() 60 | const action: Action = function ({ logger }) { 61 | logger.info("Joe!") 62 | } 63 | prog.argument("[first-arg]", "First argument").action(action) 64 | await prog.run(["--color", "false", "foo"]) 65 | console.debug(loggerInfoSpy.lastCall.args[0]) 66 | t.true(loggerInfoSpy.calledWithExactly("Joe!" as any)) 67 | }) 68 | 69 | test.serial("-v should enable verbosity", async (t) => { 70 | const prog = createProgram() 71 | const action: Action = function ({ logger }) { 72 | logger.info("my-info") 73 | logger.debug("my-debug") 74 | } 75 | prog.argument("[first-arg]", "First argument").action(action) 76 | 77 | await prog.run(["foo"]) 78 | t.regex(loggerInfoSpy.lastCall.args[0] as unknown as string, /my-info/) 79 | 80 | await prog.run(["foo", "-v"]) 81 | t.regex(logStdoutSpy.lastCall.args[0], /my-debug/) 82 | }) 83 | 84 | test.serial("--verbose should enable verbosity", async (t) => { 85 | const prog = createProgram() 86 | const action: Action = function ({ logger }) { 87 | logger.info("my-info") 88 | logger.debug("my-debug") 89 | } 90 | prog.argument("[first-arg]", "First argument").action(action) 91 | 92 | await prog.run(["foo"]) 93 | t.regex(loggerInfoSpy.lastCall.args[0] as unknown as string, /my-info/) 94 | 95 | await prog.run(["foo", "--verbose"]) 96 | t.regex(logStdoutSpy.lastCall.args[0], /my-debug/) 97 | }) 98 | 99 | test.serial( 100 | "--quiet should make the program only output warnings and errors", 101 | async (t) => { 102 | const prog = createProgram() 103 | const action: Action = function ({ logger }) { 104 | logger.info("my-info") 105 | logger.debug("my-debug") 106 | logger.warn("my-warn") 107 | logger.error("my-error") 108 | } 109 | prog.argument("[first-arg]", "First argument").action(action) 110 | 111 | await prog.run(["foo", "--quiet"]) 112 | t.is(logStdoutSpy.callCount, 2) 113 | t.regex(logStdoutSpy.getCall(0).args[0], /my-warn/) 114 | t.regex(logStdoutSpy.getCall(1).args[0], /my-error/) 115 | }, 116 | ) 117 | 118 | test.serial("--silent should output nothing", async (t) => { 119 | const prog = createProgram() 120 | const action: Action = function ({ logger }) { 121 | logger.info("my-info") 122 | logger.debug("my-debug") 123 | logger.warn("my-warn") 124 | logger.error("my-error") 125 | } 126 | prog.argument("[first-arg]", "First argument").action(action) 127 | 128 | await prog.run(["foo", "--silent"]) 129 | t.is(logStdoutSpy.callCount, 0) 130 | }) 131 | 132 | test.serial( 133 | "disableGlobalOption() should disable a global option by name or notation", 134 | async (t) => { 135 | // by notation 136 | t.truthy(findGlobalOption("version")) 137 | t.true(disableGlobalOption("-V")) 138 | t.is(findGlobalOption("version"), undefined) 139 | 140 | // by name 141 | t.truthy(findGlobalOption("help")) 142 | t.true(disableGlobalOption("help")) 143 | t.is(findGlobalOption("help"), undefined) 144 | }, 145 | ) 146 | 147 | test.serial( 148 | "disableGlobalOption() should return false for an unknown global option", 149 | async (t) => { 150 | // by notation 151 | t.false(disableGlobalOption("--unknown")) 152 | 153 | // by name 154 | t.false(disableGlobalOption("unknown")) 155 | }, 156 | ) 157 | -------------------------------------------------------------------------------- /src/help/__tests__/help.spec.ts: -------------------------------------------------------------------------------- 1 | import { getHelp, tpl, getContext, registerTemplate } from ".." 2 | import { Program } from "../../program" 3 | import { createCommand } from "../../command" 4 | import { findCommand } from "../../command/find" 5 | import strip from "strip-ansi" 6 | 7 | describe("help", () => { 8 | let prog = new Program() 9 | 10 | beforeEach(() => { 11 | prog = new Program() 12 | prog.name("test-prog") 13 | prog.bin("test-prog") 14 | }) 15 | 16 | describe("tpl()", () => { 17 | it("should compile template with given context", () => { 18 | const cmd = createCommand(prog, "test", "test command") 19 | const ctx = getContext(prog, cmd) 20 | return expect(tpl("header", ctx)).resolves.toMatchSnapshot() 21 | }) 22 | 23 | it("should compile a custom template with given context", async () => { 24 | const cmd = createCommand(prog, "test", "test command") 25 | const ctx = getContext(prog, cmd) 26 | registerTemplate("mine", () => "template-contents") 27 | expect(await tpl("mine", ctx)).toBe("template-contents") 28 | }) 29 | 30 | it("should fail if template does not exist", () => { 31 | const cmd = createCommand(prog, "test", "test command") 32 | const ctx = getContext(prog, cmd) 33 | return expect(tpl("unknown", ctx)).rejects.toBeInstanceOf(Error) 34 | }) 35 | }) 36 | 37 | describe("getHelp()", () => { 38 | it("should display help for a basic program", async () => { 39 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 40 | }) 41 | 42 | it("should display help for a program-command", async () => { 43 | prog 44 | .argument("", "Mandarory foo arg") 45 | .argument("[other]", "Other args") 46 | .option("-f, --file ", " Output file") 47 | 48 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 49 | }) 50 | 51 | it("should display help for a program having at least one command (with args & options)", async () => { 52 | prog 53 | .command("test-command", "Test command") 54 | .argument("", "Mandarory foo arg") 55 | .argument("[other]", "Other args") 56 | .option("-f, --file ", " Output file") 57 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 58 | }) 59 | 60 | it("should display help for a program having at least one command (with args only)", async () => { 61 | prog 62 | .command("test-command", "Test command") 63 | .argument("", "Mandarory foo arg") 64 | .argument("[other]", "Other args") 65 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 66 | }) 67 | 68 | it("should display help for a program having at least one command (with options only)", async () => { 69 | prog 70 | .command("test-command", "Test command") 71 | .option("-f, --file ", " Output file") 72 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 73 | }) 74 | 75 | it("should handle required options", async () => { 76 | prog 77 | .command("test-command", "Test command") 78 | .option("-f, --file ", " Output file", { required: true }) 79 | const cmd = await findCommand(prog, ["test-command"]) 80 | 81 | expect(strip(await getHelp(prog, cmd))).toContain("required") 82 | return expect(strip(await getHelp(prog, cmd))).toMatchSnapshot() 83 | }) 84 | 85 | it("should handle type hints", async () => { 86 | prog 87 | .command("test-command", "Test command") 88 | .argument("", "Desc", { validator: prog.NUMBER }) 89 | .option("-f, --file ", " Output file", { 90 | required: true, 91 | validator: prog.NUMBER, 92 | }) 93 | const cmd = await findCommand(prog, ["test-command"]) 94 | 95 | return expect(strip(await getHelp(prog, cmd))).toMatchSnapshot() 96 | }) 97 | 98 | it("should work with a program without a specified name", async () => { 99 | prog = new Program() 100 | prog.bin("test-prog") 101 | prog 102 | .command("test-command", "Test command") 103 | .option("-f, --file ", " Output file") 104 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 105 | }) 106 | 107 | it("should work with a program without a version", async () => { 108 | prog = new Program() 109 | prog.bin("test-prog") 110 | prog.version("") 111 | prog 112 | .command("test-command", "Test command") 113 | .option("-f, --file ", " Output file") 114 | expect(strip(await getHelp(prog))).toMatchSnapshot() 115 | }) 116 | 117 | it("should display program description", async () => { 118 | prog.description("Description test") 119 | prog 120 | .command("test-command", "Test command") 121 | .option("-f, --file ", " Output file") 122 | expect(strip(await getHelp(prog))).toMatchSnapshot() 123 | }) 124 | 125 | it("should display customized program help", async () => { 126 | prog.description("Description test") 127 | prog 128 | .help("My custom help") 129 | .command("test-command", "Test command") 130 | .option("-f, --file ", " Output file") 131 | expect(strip(await getHelp(prog))).toMatchSnapshot() 132 | }) 133 | 134 | it("should display customized program help on multiple lines", async () => { 135 | prog.description("Description test") 136 | prog 137 | .command("test-command", "Test command") 138 | .option("-f, --file ", " Output file") 139 | .help("My custom help\nAnother line\nOne last line") 140 | expect(strip(await getHelp(prog))).toMatchSnapshot() 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /src/help/__tests__/help.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { getHelp, tpl, getContext, registerTemplate } from "../index.js" 3 | import { Program } from "../../program/index.js" 4 | import { createCommand } from "../../command/index.js" 5 | import { findCommand } from "../../command/find.js" 6 | import strip from "strip-ansi" 7 | 8 | const createProgram = () => { 9 | return new Program().name("test-prog").bin("test-prog") 10 | } 11 | 12 | test("tpl() should compile template with given context", async (t) => { 13 | const prog = createProgram() 14 | const cmd = createCommand(prog, "test", "test command") 15 | const ctx = getContext(prog, cmd) 16 | t.is( 17 | await tpl("header", ctx), 18 | ` 19 | test-prog${" "} 20 | 21 | `, 22 | ) 23 | }) 24 | 25 | test("tpl() should compile a custom template with given context", async (t) => { 26 | const prog = createProgram() 27 | const cmd = createCommand(prog, "test", "test command") 28 | const ctx = getContext(prog, cmd) 29 | registerTemplate("mine", () => "template-contents") 30 | t.is(await tpl("mine", ctx), "template-contents") 31 | }) 32 | 33 | test("tpl() should fail if template does not exist", async (t) => { 34 | const prog = createProgram() 35 | const cmd = createCommand(prog, "test", "test command") 36 | const ctx = getContext(prog, cmd) 37 | await t.throwsAsync(() => tpl("unknown", ctx), { instanceOf: Error }) 38 | }) 39 | 40 | /* 41 | describe("help", () => { 42 | let prog = new Program() 43 | 44 | beforeEach(() => {}) 45 | 46 | describe("getHelp()", () => { 47 | it("should display help for a basic program", async () => { 48 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 49 | }) 50 | 51 | it("should display help for a program-command", async () => { 52 | prog 53 | .argument("", "Mandarory foo arg") 54 | .argument("[other]", "Other args") 55 | .option("-f, --file ", " Output file") 56 | 57 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 58 | }) 59 | 60 | it("should display help for a program having at least one command (with args & options)", async () => { 61 | prog 62 | .command("test-command", "Test command") 63 | .argument("", "Mandarory foo arg") 64 | .argument("[other]", "Other args") 65 | .option("-f, --file ", " Output file") 66 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 67 | }) 68 | 69 | it("should display help for a program having at least one command (with args only)", async () => { 70 | prog 71 | .command("test-command", "Test command") 72 | .argument("", "Mandarory foo arg") 73 | .argument("[other]", "Other args") 74 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 75 | }) 76 | 77 | it("should display help for a program having at least one command (with options only)", async () => { 78 | prog 79 | .command("test-command", "Test command") 80 | .option("-f, --file ", " Output file") 81 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 82 | }) 83 | 84 | it("should handle required options", async () => { 85 | prog 86 | .command("test-command", "Test command") 87 | .option("-f, --file ", " Output file", { required: true }) 88 | const cmd = await findCommand(prog, ["test-command"]) 89 | 90 | expect(strip(await getHelp(prog, cmd))).toContain("required") 91 | return expect(strip(await getHelp(prog, cmd))).toMatchSnapshot() 92 | }) 93 | 94 | it("should handle type hints", async () => { 95 | prog 96 | .command("test-command", "Test command") 97 | .argument("", "Desc", { validator: prog.NUMBER }) 98 | .option("-f, --file ", " Output file", { 99 | required: true, 100 | validator: prog.NUMBER, 101 | }) 102 | const cmd = await findCommand(prog, ["test-command"]) 103 | 104 | return expect(strip(await getHelp(prog, cmd))).toMatchSnapshot() 105 | }) 106 | 107 | it("should work with a program without a specified name", async () => { 108 | prog = new Program() 109 | prog.bin("test-prog") 110 | prog 111 | .command("test-command", "Test command") 112 | .option("-f, --file ", " Output file") 113 | return expect(strip(await getHelp(prog))).toMatchSnapshot() 114 | }) 115 | 116 | it("should work with a program without a version", async () => { 117 | prog = new Program() 118 | prog.bin("test-prog") 119 | prog.version("") 120 | prog 121 | .command("test-command", "Test command") 122 | .option("-f, --file ", " Output file") 123 | expect(strip(await getHelp(prog))).toMatchSnapshot() 124 | }) 125 | 126 | it("should display program description", async () => { 127 | prog.description("Description test") 128 | prog 129 | .command("test-command", "Test command") 130 | .option("-f, --file ", " Output file") 131 | expect(strip(await getHelp(prog))).toMatchSnapshot() 132 | }) 133 | 134 | it("should display customized program help", async () => { 135 | prog.description("Description test") 136 | prog 137 | .help("My custom help") 138 | .command("test-command", "Test command") 139 | .option("-f, --file ", " Output file") 140 | expect(strip(await getHelp(prog))).toMatchSnapshot() 141 | }) 142 | 143 | it("should display customized program help on multiple lines", async () => { 144 | prog.description("Description test") 145 | prog 146 | .command("test-command", "Test command") 147 | .option("-f, --file ", " Output file") 148 | .help("My custom help\nAnother line\nOne last line") 149 | expect(strip(await getHelp(prog))).toMatchSnapshot() 150 | }) 151 | }) 152 | }) 153 | */ 154 | -------------------------------------------------------------------------------- /src/validator/__tests__/caporal.spec.ts: -------------------------------------------------------------------------------- 1 | import { createArgument } from "../../argument" 2 | import { createOption } from "../../option" 3 | import { ValidationError } from "../../error" 4 | import { validateWithCaporal, CaporalValidator } from "../caporal" 5 | 6 | describe("validateWithCaporal()", () => { 7 | const arg = createArgument("", "Fake arg") 8 | const opt = createOption("--file ", "File") 9 | 10 | it("should work with an array validator", () => { 11 | expect(validateWithCaporal(CaporalValidator.ARRAY, ["val2"], arg)).toEqual(["val2"]) 12 | expect(validateWithCaporal(CaporalValidator.ARRAY, ["val2"], opt)).toEqual(["val2"]) 13 | expect(validateWithCaporal(CaporalValidator.ARRAY, "hey", arg)).toEqual(["hey"]) 14 | expect(validateWithCaporal(CaporalValidator.ARRAY, "hey", opt)).toEqual(["hey"]) 15 | expect(validateWithCaporal(CaporalValidator.ARRAY, "hey,ho", arg)).toEqual([ 16 | "hey", 17 | "ho", 18 | ]) 19 | 20 | expect(validateWithCaporal(CaporalValidator.ARRAY, "hey,ho", opt)).toEqual([ 21 | "hey", 22 | "ho", 23 | ]) 24 | 25 | expect(validateWithCaporal(CaporalValidator.ARRAY, ["hey", "ho"], arg)).toEqual([ 26 | "hey", 27 | "ho", 28 | ]) 29 | 30 | expect(validateWithCaporal(CaporalValidator.ARRAY, ["hey", "ho"], opt)).toEqual([ 31 | "hey", 32 | "ho", 33 | ]) 34 | 35 | expect(validateWithCaporal(CaporalValidator.ARRAY, 123, arg)).toEqual([123]) 36 | expect(validateWithCaporal(CaporalValidator.ARRAY, 123, opt)).toEqual([123]) 37 | }) 38 | it("should work with an numeric validator", () => { 39 | expect(validateWithCaporal(CaporalValidator.NUMBER, 123, arg)).toEqual(123) 40 | expect(validateWithCaporal(CaporalValidator.NUMBER, 123, opt)).toEqual(123) 41 | expect(validateWithCaporal(CaporalValidator.NUMBER, "123", arg)).toEqual(123) 42 | expect(validateWithCaporal(CaporalValidator.NUMBER, "123", opt)).toEqual(123) 43 | expect(validateWithCaporal(CaporalValidator.NUMBER, "3.14", arg)).toEqual(3.14) 44 | expect(validateWithCaporal(CaporalValidator.NUMBER, "3.14", opt)).toEqual(3.14) 45 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, "hello", arg)).toThrowError( 46 | ValidationError, 47 | ) 48 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, "hello", opt)).toThrowError( 49 | ValidationError, 50 | ) 51 | expect(() => 52 | validateWithCaporal(CaporalValidator.NUMBER, ["array"], arg), 53 | ).toThrowError(ValidationError) 54 | 55 | expect(() => 56 | validateWithCaporal(CaporalValidator.NUMBER, ["array"], opt), 57 | ).toThrowError(ValidationError) 58 | 59 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, true, arg)).toThrowError( 60 | ValidationError, 61 | ) 62 | 63 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, true, opt)).toThrowError( 64 | ValidationError, 65 | ) 66 | 67 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, false, arg)).toThrowError( 68 | ValidationError, 69 | ) 70 | expect(() => validateWithCaporal(CaporalValidator.NUMBER, false, opt)).toThrowError( 71 | ValidationError, 72 | ) 73 | }) 74 | 75 | it("should work with an string validator", () => { 76 | expect(validateWithCaporal(CaporalValidator.STRING, "hey", arg)).toEqual("hey") 77 | expect(validateWithCaporal(CaporalValidator.STRING, "hey", opt)).toEqual("hey") 78 | expect(validateWithCaporal(CaporalValidator.STRING, 1, opt)).toEqual("1") 79 | expect(validateWithCaporal(CaporalValidator.STRING, 3.14, opt)).toEqual("3.14") 80 | expect(validateWithCaporal(CaporalValidator.STRING, true, opt)).toEqual("true") 81 | expect(validateWithCaporal(CaporalValidator.STRING, false, opt)).toEqual("false") 82 | expect(() => 83 | validateWithCaporal(CaporalValidator.STRING, ["hello"], arg), 84 | ).toThrowError(ValidationError) 85 | expect(() => 86 | validateWithCaporal(CaporalValidator.STRING, ["hello"], opt), 87 | ).toThrowError(ValidationError) 88 | }) 89 | 90 | it("should work with an boolean validator", () => { 91 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, true, arg)).toEqual(true) 92 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, true, opt)).toEqual(true) 93 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, false, arg)).toEqual(false) 94 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, false, opt)).toEqual(false) 95 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, 1, arg)).toEqual(true) 96 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, 1, opt)).toEqual(true) 97 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, 0, arg)).toEqual(false) 98 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, 0, opt)).toEqual(false) 99 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "true", arg)).toEqual(true) 100 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "true", opt)).toEqual(true) 101 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "false", arg)).toEqual(false) 102 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "false", opt)).toEqual(false) 103 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "yes", arg)).toEqual(true) 104 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "yes", opt)).toEqual(true) 105 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "no", arg)).toEqual(false) 106 | expect(validateWithCaporal(CaporalValidator.BOOLEAN, "no", opt)).toEqual(false) 107 | expect(() => 108 | validateWithCaporal(CaporalValidator.BOOLEAN, ["hello"], arg), 109 | ).toThrowError(ValidationError) 110 | 111 | expect(() => 112 | validateWithCaporal(CaporalValidator.BOOLEAN, ["hello"], opt), 113 | ).toThrowError(ValidationError) 114 | 115 | expect(() => 116 | validateWithCaporal(CaporalValidator.BOOLEAN, "unknown", arg), 117 | ).toThrowError(ValidationError) 118 | 119 | expect(() => 120 | validateWithCaporal(CaporalValidator.BOOLEAN, "unknown", opt), 121 | ).toThrowError(ValidationError) 122 | 123 | expect(() => validateWithCaporal(CaporalValidator.BOOLEAN, 2, arg)).toThrowError( 124 | ValidationError, 125 | ) 126 | 127 | expect(() => validateWithCaporal(CaporalValidator.BOOLEAN, 2, opt)).toThrowError( 128 | ValidationError, 129 | ) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /src/option/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @module caporal/option 4 | */ 5 | 6 | import { 7 | Option, 8 | OptionValueType, 9 | CreateOptionProgramOpts, 10 | CreateOptionCommandOpts, 11 | Action, 12 | ActionParameters, 13 | GlobalOptions, 14 | ParserProcessedResult, 15 | } from "../types.js" 16 | import { checkValidator, isBoolValidator, getTypeHint } from "../validator/utils.js" 17 | import { parseOptionSynopsis } from "./utils.js" 18 | import { logger } from "../logger/index.js" 19 | import type { Command } from "../command/index.js" 20 | import type { Program } from "../program/index.js" 21 | import { getHelp } from "../help/index.js" 22 | 23 | /** 24 | * Create an Option object 25 | * 26 | * @internal 27 | * @param synopsis 28 | * @param description 29 | * @param options 30 | */ 31 | export function createOption( 32 | synopsis: string, 33 | description: string, 34 | options: CreateOptionProgramOpts | CreateOptionCommandOpts = {}, 35 | ): Option { 36 | // eslint-disable-next-line prefer-const 37 | let { validator, required, hidden } = options 38 | 39 | // force casting 40 | required = Boolean(required) 41 | 42 | checkValidator(validator) 43 | const syno = parseOptionSynopsis(synopsis) 44 | let boolean = syno.valueType === OptionValueType.None || isBoolValidator(validator) 45 | if (validator && !isBoolValidator(validator)) { 46 | boolean = false 47 | } 48 | 49 | const opt: Option = { 50 | kind: "option", 51 | default: boolean == true ? Boolean(options.default) : options.default, 52 | description, 53 | choices: Array.isArray(validator) ? validator : [], 54 | ...syno, 55 | required, 56 | visible: !hidden, 57 | boolean, 58 | validator, 59 | } 60 | 61 | opt.typeHint = getTypeHint(opt) 62 | 63 | return opt 64 | } 65 | 66 | export { showHelp } 67 | 68 | /** 69 | * Display help. Return false to prevent further processing. 70 | * 71 | * @internal 72 | */ 73 | const showHelp: Action = async ({ program, command }: ActionParameters) => { 74 | const help = await getHelp(program, command) 75 | // eslint-disable-next-line no-console 76 | console.log(help) 77 | program.emit("help", help) 78 | return false 79 | }, 80 | /** 81 | * Display program version. Return false to prevent further processing. 82 | * 83 | * @internal 84 | */ 85 | showVersion: Action = ({ program }: ActionParameters) => { 86 | // eslint-disable-next-line no-console 87 | console.log(program.getVersion()) 88 | program.emit("version", program.getVersion()) 89 | return false 90 | }, 91 | /** 92 | * Disable colors in output 93 | * 94 | * @internal 95 | */ 96 | disableColors: Action = ({ logger }: ActionParameters) => { 97 | logger.disableColors() 98 | }, 99 | /** 100 | * Set verbosity to the maximum 101 | * 102 | * @internal 103 | */ 104 | setVerbose: Action = ({ logger }: ActionParameters) => { 105 | logger.level = "silly" 106 | }, 107 | /** 108 | * Makes the program quiet, eg displaying logs with level >= warning 109 | */ 110 | setQuiet: Action = ({ logger }: ActionParameters) => { 111 | logger.level = "warn" 112 | }, 113 | /** 114 | * Makes the program totally silent 115 | */ 116 | setSilent: Action = ({ logger }: ActionParameters) => { 117 | logger.silent = true 118 | }, 119 | /** 120 | * Install completion 121 | */ 122 | installComp: Action = ({ program }: ActionParameters) => { 123 | throw new Error("Completion not supported.") 124 | }, 125 | /** 126 | * Uninstall completion 127 | */ 128 | uninstallComp: Action = ({ program }: ActionParameters) => { 129 | throw new Error("Completion not supported.") 130 | } 131 | 132 | /** 133 | * Global options container 134 | * 135 | * @internal 136 | */ 137 | let globalOptions: undefined | GlobalOptions 138 | 139 | /** 140 | * Get the list of registered global flags 141 | * 142 | * @internal 143 | */ 144 | export function getGlobalOptions(): GlobalOptions { 145 | if (globalOptions === undefined) { 146 | globalOptions = setupGlobalOptions() 147 | } 148 | return globalOptions 149 | } 150 | 151 | /** 152 | * Set up the global flags 153 | * 154 | * @internal 155 | */ 156 | function setupGlobalOptions(): GlobalOptions { 157 | const help = createOption("-h, --help", "Display global help or command-related help."), 158 | verbose = createOption( 159 | "-v, --verbose", 160 | "Verbose mode: will also output debug messages.", 161 | ), 162 | quiet = createOption( 163 | "--quiet", 164 | "Quiet mode - only displays warn and error messages.", 165 | ), 166 | silent = createOption( 167 | "--silent", 168 | "Silent mode: does not output anything, giving no indication of success or failure other than the exit code.", 169 | ), 170 | version = createOption("-V, --version", "Display version."), 171 | color = createOption("--no-color", "Disable use of colors in output."), 172 | installCompOpt = createOption( 173 | "--install-completion", 174 | "Install completion for your shell.", 175 | { hidden: true }, 176 | ), 177 | uninstallCompOpt = createOption( 178 | "--uninstall-completion", 179 | "Uninstall completion for your shell.", 180 | { hidden: true }, 181 | ) 182 | 183 | return new Map([ 184 | [help, showHelp], 185 | [version, showVersion], 186 | [color, disableColors], 187 | [verbose, setVerbose], 188 | [quiet, setQuiet], 189 | [silent, setSilent], 190 | [installCompOpt, installComp], 191 | [uninstallCompOpt, uninstallComp], 192 | ]) 193 | } 194 | 195 | export function resetGlobalOptions(): GlobalOptions { 196 | return (globalOptions = setupGlobalOptions()) 197 | } 198 | 199 | /** 200 | * Disable a global option 201 | * 202 | * @param name Can be the option short/long name or notation 203 | */ 204 | export function disableGlobalOption(name: string): boolean { 205 | const opts = getGlobalOptions() 206 | for (const [opt] of opts) { 207 | if (opt.allNames.includes(name) || opt.allNotations.includes(name)) { 208 | return opts.delete(opt) 209 | } 210 | } 211 | return false 212 | } 213 | 214 | /** 215 | * Add a global option to the program. 216 | * A global option is available at the program level, 217 | * and associated with one given {@link Action}. 218 | * 219 | * @param a {@link Option} instance, for example created using {@link createOption()} 220 | */ 221 | export function addGlobalOption(opt: Option, action?: Action): GlobalOptions { 222 | return getGlobalOptions().set(opt, action) 223 | } 224 | 225 | /** 226 | * Process global options, if any 227 | * @internal 228 | */ 229 | export async function processGlobalOptions( 230 | parsed: ParserProcessedResult, 231 | program: Program, 232 | command?: Command, 233 | ): Promise { 234 | const { options } = parsed 235 | const actionsParams = { ...parsed, logger, program, command } 236 | const promises = Object.entries(options).map(([opt]) => { 237 | const action = findGlobalOptAction(opt) 238 | if (action) { 239 | return action(actionsParams) 240 | } 241 | }) 242 | const results = await Promise.all(promises) 243 | return results.some((r) => r === false) 244 | } 245 | 246 | /** 247 | * Find a global Option action from the option name (short or long) 248 | * 249 | * @param name Short or long name 250 | * @internal 251 | */ 252 | export function findGlobalOptAction(name: string): Action | undefined { 253 | for (const [opt, action] of getGlobalOptions()) { 254 | if (opt.allNames.includes(name)) { 255 | return action 256 | } 257 | } 258 | } 259 | 260 | /** 261 | * Find a global Option by it's name (short or long) 262 | * 263 | * @param name Short or long name 264 | * @internal 265 | */ 266 | export function findGlobalOption(name: string): Option | undefined { 267 | for (const [opt] of getGlobalOptions()) { 268 | if (opt.allNames.includes(name)) { 269 | return opt 270 | } 271 | } 272 | } 273 | 274 | export function isOptionObject(obj: unknown): obj is Option { 275 | return typeof obj == "object" && obj !== null && (obj as Option).kind == "option" 276 | } 277 | -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @module parser 4 | */ 5 | import invert from "lodash/invert.js" 6 | import findIndex from "lodash/findIndex.js" 7 | import type { 8 | ParserOptions, 9 | ParserResult, 10 | ParserTypes, 11 | ParsedOptions, 12 | ParsedArguments, 13 | } from "../types.js" 14 | import { 15 | isNumeric, 16 | isConcatenatedOpt, 17 | isNegativeOpt, 18 | isOptionStr, 19 | formatOptName, 20 | isOptArray, 21 | } from "../option/utils.js" 22 | 23 | const DDASH = "--" 24 | 25 | function isDdash(str?: string): str is string { 26 | return str === DDASH 27 | } 28 | 29 | function castAsBool(value: string | boolean): boolean { 30 | if (typeof value === "boolean") { 31 | return value 32 | } 33 | return /^true|1|yes|on$/.test(value) 34 | } 35 | 36 | function castAsString(val: string | boolean): string { 37 | return val + "" 38 | } 39 | 40 | function autoCast(val: string): ParserTypes { 41 | // auto-casting "true" & "false" 42 | if (/^true|false$/.test(val)) { 43 | return val === "true" 44 | } 45 | // auto-casting numbers 46 | return isNumeric(val) ? parseFloat(val) : val 47 | } 48 | 49 | function cast(name: string, val: string | true, options: ParserOptions): ParserTypes { 50 | const cleanName = formatOptName(name) 51 | 52 | // Force casting to string 53 | if (options.string.includes(cleanName)) { 54 | return castAsString(val) 55 | } 56 | 57 | // Force casting to bool 58 | if (options.boolean.includes(cleanName) || typeof val === "boolean") { 59 | return castAsBool(val) 60 | } 61 | 62 | return options.autoCast ? autoCast(val) : val 63 | } 64 | 65 | /** 66 | * Parse a line 67 | * 68 | * @param line Line to be parsed 69 | * @param options Parser options 70 | * @internal 71 | */ 72 | export function parseLine( 73 | line: string, 74 | options: Partial = {}, 75 | ): ParserResult { 76 | return parseArgv(options, line.split(" ")) 77 | } 78 | /** 79 | * 80 | * @param args Return the next option position unless there is some ddash before 81 | */ 82 | function getNextOptPosition(args: string[]): number { 83 | const ddash = args.indexOf("--") 84 | const opt = findIndex(args, isOptionStr) 85 | return ddash < opt && ddash !== -1 ? -1 : opt 86 | } 87 | 88 | class Tree { 89 | cursor: number 90 | private ddashHandled = false 91 | 92 | constructor(private argv: string[]) { 93 | this.cursor = 0 94 | } 95 | 96 | /* istanbul ignore next */ 97 | toJSON(): { 98 | cursor: number 99 | ddashHandled: boolean 100 | argv: string[] 101 | current?: string 102 | } { 103 | return { 104 | cursor: this.cursor, 105 | ddashHandled: this.ddashHandled, 106 | current: this.current, 107 | argv: this.argv, 108 | } 109 | } 110 | 111 | markDdashHandled(): Tree { 112 | this.ddashHandled = true 113 | return this 114 | } 115 | 116 | hasDdashHandled(): boolean { 117 | return this.ddashHandled 118 | } 119 | 120 | next(): string | undefined { 121 | return this.argv[this.cursor + 1] 122 | } 123 | 124 | slice(start?: number, end?: number): string[] { 125 | return this.argv.slice(start, end) 126 | } 127 | 128 | sliceFromHere(end?: number): string[] { 129 | return this.slice(this.cursor, end) 130 | } 131 | 132 | forward(by = 1): true { 133 | if (by === -1) { 134 | return this.end() 135 | } 136 | this.cursor += by 137 | return true 138 | } 139 | 140 | end(): true { 141 | this.cursor = this.length 142 | return true 143 | } 144 | 145 | get current(): string | undefined { 146 | return this.argv[this.cursor] 147 | } 148 | 149 | get length(): number { 150 | return this.argv.length 151 | } 152 | } 153 | 154 | class ArgumentParser { 155 | public readonly args: ParsedArguments = [] 156 | public readonly ddash: ParsedArguments = [] 157 | public readonly rawArgv: string[] 158 | public readonly line: string 159 | private variadicId?: number 160 | private key: "args" | "ddash" = "args" 161 | 162 | constructor(private config: ParserOptions, argv: string[]) { 163 | this.line = argv.join(" ") 164 | this.rawArgv = argv 165 | } 166 | 167 | toJSON(): { 168 | args: ParsedArguments 169 | ddash: ParsedArguments 170 | rawArgv: string[] 171 | line: string 172 | } { 173 | return { 174 | args: this.args, 175 | ddash: this.ddash, 176 | rawArgv: this.rawArgv, 177 | line: this.line, 178 | } 179 | } 180 | 181 | inVariadicContext(): boolean | undefined { 182 | const argsLen = this[this.key].length 183 | if (this.config.variadic.includes(argsLen)) { 184 | this.variadicId = argsLen 185 | } 186 | if (this.variadicId !== undefined) { 187 | return true 188 | } 189 | } 190 | 191 | markDdashHandled(tree: Tree): true { 192 | if (this.config.ddash) { 193 | // if ddash enabled, update the key 194 | this.key = "ddash" 195 | } 196 | return tree.markDdashHandled().forward() 197 | } 198 | 199 | push(...values: string[]): true { 200 | this[this.key].push(...values.map(this.config.autoCast ? autoCast : String)) 201 | return true 202 | } 203 | 204 | pushVariadic(tree: Tree): true { 205 | const args = tree.sliceFromHere() 206 | const until = getNextOptPosition(args) 207 | this.variadicId = this.variadicId || 0 208 | const variadic = (this[this.key][this.variadicId] = 209 | (this[this.key][this.variadicId] as ParserTypes[]) || []) 210 | 211 | variadic.push( 212 | ...args 213 | .slice(0, until === -1 ? undefined : until) 214 | .filter((s: string) => !isDdash(s)) 215 | .map(this.config.autoCast ? autoCast : String), 216 | ) 217 | 218 | return tree.forward(until) 219 | } 220 | 221 | visit(tree: Tree): unknown { 222 | if (!tree.current || (isOptionStr(tree.current) && !tree.hasDdashHandled())) { 223 | return false 224 | } 225 | if (isDdash(tree.current)) { 226 | return this.markDdashHandled(tree) 227 | } else if (!this.inVariadicContext()) { 228 | this.push(tree.current) 229 | return tree.forward() 230 | } 231 | return this.pushVariadic(tree) 232 | } 233 | } 234 | 235 | class OptionParser { 236 | public readonly options: ParsedOptions = {} 237 | public readonly rawOptions: ParsedOptions = {} 238 | 239 | constructor(private config: ParserOptions) {} 240 | 241 | toJSON(): { 242 | options: ParsedOptions 243 | rawOptions: ParsedOptions 244 | } { 245 | return { 246 | options: this.options, 247 | rawOptions: this.rawOptions, 248 | } 249 | } 250 | 251 | handleOptWithoutValue(name: string, tree: Tree): void { 252 | const next = tree.next() 253 | const nextIsOptOrUndef = isOptionStr(next) || isDdash(next) || next === undefined 254 | this.compute( 255 | name, 256 | cast(name, nextIsOptOrUndef ? true : (next as string), this.config), 257 | ) 258 | if (!nextIsOptOrUndef) { 259 | tree.forward() 260 | } 261 | } 262 | 263 | handleConcatenatedOpts(tree: Tree, names: string[], val?: ParserTypes): void { 264 | if (val === undefined) { 265 | val = true 266 | const next = tree.next() 267 | const last = names[names.length - 1] 268 | const alias = this.config.alias[last] 269 | const shouldTakeNextAsVal = 270 | next && !isOptionStr(next) && !isDdash(next) && !this.isBoolean(last, alias) 271 | if (shouldTakeNextAsVal) { 272 | tree.forward() 273 | val = next as string 274 | } 275 | } 276 | this.computeMulti(names, val) 277 | } 278 | 279 | visit(tree: Tree): boolean { 280 | // only handle options 281 | /* istanbul ignore if */ 282 | if (!tree.current || !isOptionStr(tree.current) || tree.hasDdashHandled()) { 283 | // this is never reached because the scan stops if 284 | // a visior returns true, and as the Argument visitor is the first in the 285 | // list, arguments objects never reach the Options visitor 286 | // keeping it here in case we change the order of visitors 287 | return false 288 | } 289 | 290 | const [name, rawval] = tree.current.split("=", 2) 291 | const concatOpts = isConcatenatedOpt(name) 292 | 293 | if (concatOpts) { 294 | this.handleConcatenatedOpts(tree, concatOpts, rawval) 295 | } else if (rawval) { 296 | this.compute(name, cast(name, rawval, this.config)) 297 | } else { 298 | this.handleOptWithoutValue(name, tree) 299 | } 300 | 301 | return tree.forward() 302 | } 303 | 304 | compute(name: string, val: ParserTypes): void { 305 | const no = isNegativeOpt(name) 306 | const cleanName = formatOptName(name) 307 | const alias = this.config.alias[cleanName] 308 | 309 | if (this.isVariadic(cleanName, alias)) { 310 | const prop = this.options[cleanName] 311 | this.rawOptions[name] = this.options[cleanName] = ( 312 | isOptArray(prop) ? prop : [prop] 313 | ).concat(val) 314 | } else { 315 | this.rawOptions[name] = this.options[cleanName] = no ? !val : val 316 | } 317 | if (alias) { 318 | this.options[alias] = this.options[cleanName] 319 | } 320 | } 321 | 322 | // todo: handle variadic, even for compute multi 323 | // TIP: (maybe just split and redirect the last char to compute()) 324 | computeMulti(multi: string[], val: ParserTypes): void { 325 | const n = multi.length 326 | multi.forEach((o, index) => { 327 | const alias = this.config.alias[o] 328 | this.options[o] = index + 1 === n ? cast(o, val as string, this.config) : true 329 | this.rawOptions["-" + o] = this.options[o] 330 | if (alias) { 331 | this.options[alias] = this.options[o] 332 | } 333 | }) 334 | } 335 | 336 | isVariadic(name: string, alias: string): boolean { 337 | return ( 338 | name in this.options && 339 | (this.config.variadic.includes(name) || this.config.variadic.includes(alias)) 340 | ) 341 | } 342 | 343 | isBoolean(name: string, alias: string): boolean { 344 | return this.config.boolean.includes(name) || this.config.boolean.includes(alias) 345 | } 346 | } 347 | 348 | /** 349 | * Parse command line arguments 350 | * 351 | * @param options Parser options 352 | * @param argv command line arguments array (a.k.a. "argv") 353 | */ 354 | export function parseArgv( 355 | options: Partial = {}, 356 | argv: string[] = process.argv.slice(2), 357 | ): ParserResult { 358 | const parseOpts: ParserOptions = { 359 | autoCast: true, 360 | ddash: false, 361 | alias: {}, 362 | boolean: [], 363 | string: [], 364 | variadic: [], 365 | ...options, 366 | } 367 | parseOpts.alias = { ...parseOpts.alias, ...invert(parseOpts.alias) } 368 | 369 | const tree = new Tree(argv) 370 | const flagParser = new OptionParser(parseOpts) 371 | const argParser = new ArgumentParser(parseOpts, argv) 372 | const visitors = [argParser, flagParser] 373 | 374 | while (tree.current) { 375 | visitors.some((v) => v.visit(tree)) 376 | } 377 | 378 | return { ...flagParser.toJSON(), ...argParser.toJSON() } 379 | } 380 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of Caporal type aliases. 3 | * 4 | * @packageDocumentation 5 | * @module caporal/types 6 | */ 7 | // 8 | import { Logger as WinstonLogger } from "winston" 9 | import { Program } from "./program/index.js" 10 | import { Command } from "./command/index.js" 11 | import { BaseError } from "./error/index.js" 12 | 13 | /** 14 | * The Caporal logger interface. It extends the [Winston](https://github.com/winstonjs/winston) Logger interface 15 | * and adds the following properties & methods. 16 | * @noInheritDoc 17 | */ 18 | export interface Logger extends WinstonLogger { 19 | /** 20 | * Allow to force disabling colors. 21 | */ 22 | disableColors(): void 23 | /** 24 | * Tells Caporal if colors are enabled or not. 25 | */ 26 | colorsEnabled: boolean 27 | } 28 | 29 | export type GlobalOptions = Map 30 | 31 | /** 32 | * Caporal-provided validator flags. 33 | */ 34 | export enum CaporalValidator { 35 | /** 36 | * Number validator. Check that the value looks like a numeric one 37 | * and cast the provided value to a javascript `Number`. 38 | */ 39 | NUMBER = 1, 40 | /** 41 | * Boolean validator. Check that the value looks like a boolean. 42 | * It accepts values like `true`, `false`, `yes`, `no`, `0`, and `1` 43 | * and will auto-cast those values to `true` or `false`. 44 | */ 45 | BOOLEAN = 2, 46 | /** 47 | * String validator. Mainly used to make sure the value is a string, 48 | * and prevent Caporal auto-casting of numerics values and boolean 49 | * strings like `true` or `false`. 50 | */ 51 | STRING = 4, 52 | /** 53 | * Array validator. Convert any provided value to an array. If a string is provided, 54 | * this validator will try to split it by commas. 55 | */ 56 | ARRAY = 8, 57 | } 58 | 59 | type FunctionValidatorArgument = ParsedArgument | ParsedOption 60 | 61 | // export type FunctionValidator = ( 62 | // value: T, 63 | // ) => FunctionValidatorArgument | Promise 64 | 65 | export interface FunctionValidator { 66 | (value: T): Promisable 67 | } 68 | 69 | // export type FunctionValidator = (value: T) => Promisable 70 | 71 | export type Validator = RegExp | FunctionValidator | CaporalValidator | ParserTypes[] 72 | 73 | /** 74 | * @internal 75 | */ 76 | export interface ValidatorWrapper { 77 | validate( 78 | value: ParsedArgument | ParsedOption, 79 | ): ParserTypes | ParserTypes[] | Promise 80 | getChoices(): ParserTypes[] 81 | } 82 | 83 | export interface OptionSynopsis { 84 | name: string 85 | notation: string 86 | shortName?: string 87 | shortNotation?: string 88 | longName?: string 89 | longNotation?: string 90 | allNames: string[] 91 | allNotations: string[] 92 | synopsis: string 93 | valueRequired: boolean 94 | valueType?: OptionValueType 95 | variadic: boolean 96 | } 97 | 98 | /** 99 | * Option possible value. 100 | * 101 | */ 102 | export enum OptionValueType { 103 | /** 104 | * Value is optional. 105 | */ 106 | Optional, 107 | /** 108 | * Value is required. 109 | */ 110 | Required, 111 | /** 112 | * Option does not have any possible value 113 | */ 114 | None, 115 | } 116 | 117 | /** 118 | * Option properties 119 | */ 120 | export interface CreateOptionCommandOpts { 121 | /** 122 | * Optional validator 123 | */ 124 | validator?: Validator 125 | /** 126 | * Default value for the Option 127 | */ 128 | default?: ParsedOption 129 | /** 130 | * Set the Option as itself required 131 | */ 132 | required?: boolean 133 | /** 134 | * Hide the option from help 135 | */ 136 | hidden?: boolean 137 | } 138 | 139 | /** 140 | * Option properties 141 | */ 142 | export interface CreateOptionProgramOpts extends CreateOptionCommandOpts { 143 | /** 144 | * Set to `true` for a global option. 145 | */ 146 | global?: boolean 147 | /** 148 | * Action to call when a global-option is passed. 149 | * Only available for global options, e.g. when `global` is set to `true`. 150 | */ 151 | action?: Action 152 | } 153 | 154 | export interface CreateArgumentOpts { 155 | /** 156 | * Argument validator. 157 | */ 158 | validator?: Validator 159 | /** 160 | * Argument default value. 161 | */ 162 | default?: ParsedArgument 163 | } 164 | 165 | export interface ArgumentSynopsis { 166 | /** 167 | * Argument name. 168 | */ 169 | readonly name: string 170 | /** 171 | * Boolean indicating if the argument is required. 172 | */ 173 | readonly required: boolean 174 | /** 175 | * Synopsis string. 176 | */ 177 | readonly synopsis: string 178 | /** 179 | * Boolean indicating if the argument is valiadic, 180 | * e.g. can be repeated to contain an array of values. 181 | */ 182 | readonly variadic: boolean 183 | } 184 | 185 | export interface Argument extends ArgumentSynopsis { 186 | readonly default?: ParsedArgument 187 | readonly description: string 188 | readonly choices: ParsedArgument[] 189 | readonly validator?: Validator 190 | typeHint?: string 191 | kind: "argument" 192 | } 193 | 194 | export interface Option extends OptionSynopsis { 195 | readonly boolean: boolean 196 | readonly default?: ParsedOption 197 | readonly description: string 198 | readonly choices: ParsedOption[] 199 | readonly validator?: Validator 200 | readonly required: boolean 201 | readonly visible: boolean 202 | typeHint?: string 203 | kind: "option" 204 | } 205 | 206 | /** 207 | * A type that could be wrapped in a Promise, or not 208 | */ 209 | export type Promisable = T | Promise 210 | 211 | /** 212 | * Parameters object passed to an {@link Action} function 213 | */ 214 | export interface ActionParameters { 215 | /** 216 | * Parsed command line arguments 217 | */ 218 | args: ParsedArgumentsObject 219 | /** 220 | * If the `dash` (double dash) config property is enabled, 221 | * this *array* will contain all arguments present 222 | * after '--'. 223 | */ 224 | ddash: ParsedArguments 225 | /** 226 | * Parsed command line options 227 | */ 228 | options: ParsedOptions 229 | /** 230 | * Program instance 231 | */ 232 | program: Program 233 | /** 234 | * Contextual command, if any 235 | */ 236 | command?: Command 237 | /** 238 | * Logger instance 239 | */ 240 | logger: Logger 241 | } 242 | 243 | /** 244 | * An action is a function that will be executed upon a command call. 245 | */ 246 | export interface Action { 247 | (params: ActionParameters): unknown 248 | } 249 | 250 | export interface ErrorMetadata { 251 | [meta: string]: unknown 252 | } 253 | 254 | export type ParserTypes = string | number | boolean 255 | 256 | /** 257 | * Available options for the Caporal internal parser. 258 | * Arguments must be referenced by their position (0-based) and options by their name (short or long) 259 | * in {@link ParserOptions.boolean boolean}, {@link ParserOptions.string string} 260 | * and {@link ParserOptions.variadic variadic} parser options. 261 | * 262 | */ 263 | export interface ParserOptions { 264 | /** 265 | * List of {@link Argument Arguments} and {@link Options Options} to be casted as *booleans*. 266 | * Arguments must be referenced by their position (0-based) and options by their name (short or long). 267 | * 268 | * **Example** 269 | * 270 | * ```ts 271 | * import { parseArgv } from "caporal/parser" 272 | * 273 | * parseArgv({ 274 | * boolean: [2, 'sendEmail'] 275 | * }) 276 | * 277 | * // ./my-cli-app first-arg second-arg 3rd-arg --sendEmail=1 278 | * // -> "3rd-arg" will be casted to boolean as well as "--sendEmail" 279 | * ``` 280 | */ 281 | boolean: (string | number)[] 282 | /** 283 | * List of {@link Argument Arguments} and {@link Options Options} to be casted as *strings*. 284 | * Arguments must be referenced by their position (0-based) and options by their name (short or long). 285 | * 286 | * **Example** 287 | * 288 | * ```ts 289 | * import { parseArgv } from "caporal/parser" 290 | * 291 | * parseArgv({ 292 | * string: [1] 293 | * }) 294 | * 295 | * // ./my-cli-app first-arg 2 296 | * // -> second arg "2" will be casted to string instead of number 297 | * ``` 298 | */ 299 | string: (string | number)[] 300 | /** 301 | * List of variadic {@link Argument Arguments} and {@link Options Options}, meaning 302 | * that there value is an `Array`. 303 | * 304 | * Arguments must be referenced by their position (0-based) and options by their name (short or long). 305 | * 306 | * **Example** 307 | * 308 | * ```ts 309 | * import { parseArgv } from "caporal/parser" 310 | * 311 | * parseArgv({ 312 | * variadic: [1] 313 | * }) 314 | * 315 | * // ./pizza order margherita regina --add sausages --add basil 316 | * { 317 | * args: ['order', ['margherita', 'regina']] 318 | * options: { 319 | * add: ['sausages', 'basil'] 320 | * } 321 | * } 322 | * ``` 323 | */ 324 | variadic: (string | number)[] 325 | /** 326 | * Double-dash (--) handling mode. If `true`, the parser will populate the 327 | * {@link ParserResult.ddash} property, otherwise, arguments will be added 328 | * to {@link ParserResult.args}. 329 | */ 330 | ddash: boolean 331 | /** 332 | * Option aliases map. 333 | */ 334 | alias: Record 335 | /** 336 | * Enable or disable autocasting of arguments and options. Default to `true`. 337 | */ 338 | autoCast: boolean 339 | } 340 | 341 | export type ParsedArgument = ParserTypes | ParserTypes[] 342 | export type ParsedArguments = ParsedArgument[] 343 | export interface ParsedArgumentsObject { 344 | [arg: string]: ParsedArgument 345 | } 346 | 347 | export type ParsedOption = ParserTypes | ParserTypes[] 348 | export interface ParsedOptions { 349 | [opt: string]: ParsedOption 350 | } 351 | /** 352 | * @internal 353 | */ 354 | export interface ArgumentsRange { 355 | min: number 356 | max: number 357 | } 358 | 359 | export interface ParserResult { 360 | args: ParsedArguments 361 | options: ParsedOptions 362 | rawOptions: ParsedOptions 363 | line: string 364 | rawArgv: string[] 365 | ddash: ParsedArguments 366 | } 367 | 368 | export interface ParserProcessedResult extends Omit { 369 | args: ParsedArgumentsObject 370 | errors: BaseError[] 371 | } 372 | 373 | export interface CreateCommandParameters { 374 | program: Program 375 | createCommand(description?: string): Command 376 | } 377 | export interface CommandCreator { 378 | (options: CreateCommandParameters): Command 379 | } 380 | 381 | /** 382 | * Available configuration properties for the program. 383 | */ 384 | export interface ProgramConfig { 385 | /** 386 | * Strict checking of arguments count. If enabled, any additional argument willl trigger an error. 387 | * Default to `true`. 388 | */ 389 | strictArgsCount: boolean 390 | /** 391 | * Strict checking of options provided. If enabled, any unknown option will trigger an error. 392 | * Default to `true`. 393 | */ 394 | strictOptions: boolean 395 | /** 396 | * Auto-casting of arguments and options. 397 | * Default to `true`. 398 | */ 399 | autoCast: boolean 400 | /** 401 | * Environment variable to check for log level override. 402 | * Default to "CAPORAL_LOG_LEVEL". 403 | */ 404 | logLevelEnvVar: string 405 | } 406 | export interface CommandConfig { 407 | /** 408 | * Strict checking of arguments count. If enabled, any additional argument willl trigger an error. 409 | */ 410 | strictArgsCount?: boolean 411 | /** 412 | * Strict checking of options provided. If enabled, any unknown option will trigger an error. 413 | */ 414 | strictOptions?: boolean 415 | /** 416 | * Auto-casting of arguments and options. 417 | */ 418 | autoCast?: boolean 419 | /** 420 | * Visibility of the command in help. 421 | */ 422 | visible: boolean 423 | } 424 | 425 | export interface Configurator { 426 | get(key: K): T[K] 427 | getAll(): T 428 | set(props: Partial): T 429 | reset(): T 430 | } 431 | -------------------------------------------------------------------------------- /src/program/__tests__/program.test.ts: -------------------------------------------------------------------------------- 1 | // jest.mock("../../error/fatal") 2 | // jest.useFakeTimers() 3 | 4 | import test from "ava" 5 | import sinon from "sinon" 6 | import { Program } from "../index.js" 7 | import { 8 | fatalError, 9 | UnknownOrUnspecifiedCommandError, 10 | ValidationSummaryError, 11 | NoActionError, 12 | } from "../../error/index.js" 13 | import { Logger } from "../../types.js" 14 | import { logger } from "../../logger/index.js" 15 | import { resetGlobalOptions } from "../../option/index.js" 16 | 17 | import { fileURLToPath } from "url" 18 | import { dirname } from "path" 19 | 20 | const __dirname = dirname(fileURLToPath(import.meta.url)) 21 | 22 | // Silence MaxListenersExceededWarning. 23 | process.setMaxListeners(100) 24 | 25 | // const fataErrorMock = fatalError as unknown as jest.Mock 26 | 27 | const consoleLogSpy = sinon.stub(console, "log") 28 | const loggerWarnSpy = sinon.stub(logger, "warn") 29 | 30 | test.beforeEach(() => { 31 | // fataErrorMock.mockClear() 32 | consoleLogSpy.resetHistory() 33 | loggerWarnSpy.resetHistory() 34 | resetGlobalOptions() 35 | }) 36 | 37 | const createProgram = () => { 38 | return new Program().name("test-prog").bin("test-prog") 39 | } 40 | 41 | test(".version() should set the version", (t) => { 42 | const prog = createProgram() 43 | prog.version("beta-2") 44 | t.is(prog.getVersion(), "beta-2") 45 | }) 46 | 47 | test(".description() should set the description", (t) => { 48 | const prog = createProgram() 49 | prog.description("fake-desc") 50 | t.is(prog.getDescription(), "fake-desc") 51 | }) 52 | 53 | test(".hasCommands() should return false by default", async (t) => { 54 | const prog = createProgram() 55 | t.is(await prog.hasCommands(), false) 56 | }) 57 | 58 | test(".getSynopsis should return the correct synopsis if program has commands", async (t) => { 59 | const prog = createProgram() 60 | prog.command("my-command", "my command").action(() => "ok") 61 | t.regex(await prog.getSynopsis(), //) 62 | }) 63 | 64 | test(".synospis should return the correct synopsis if program does not have commands", async (t) => { 65 | const prog = createProgram() 66 | prog.action(() => "ok") 67 | t.notRegex(await prog.getSynopsis(), //) 68 | }) 69 | 70 | test(".parse(undefined) should work", async (t) => { 71 | const prog = createProgram() 72 | prog.argument("[first-arg]", "First argument").action(() => "ok") 73 | t.is(await prog.run([]), "ok") 74 | }) 75 | 76 | test("should be able to create a 'program-command' just by calling .argument()", async (t) => { 77 | const prog = createProgram() 78 | prog.argument("", "First argument").action(() => "ok") 79 | t.is(await prog.run(["first-arg"]), "ok") 80 | }) 81 | 82 | test("should be able to create a 'program-command' just by calling .option()", async (t) => { 83 | const prog = createProgram() 84 | prog.option("--foo", "Foo option").action(() => "ok") 85 | t.is(await prog.run(["--foo"]), "ok") 86 | }) 87 | 88 | test(".globalOption() should create a global option without associated action", async (t) => { 89 | const prog = createProgram() 90 | prog.option("--my-global ", "my global var", { global: true }) 91 | prog.argument("", "First argument").action(() => "ok") 92 | t.is(await prog.run(["first-arg", "--my-global", "secret"]), "ok") 93 | }) 94 | 95 | test.serial( 96 | ".globalOption() should create a global option with associated action", 97 | async (t) => { 98 | const prog = createProgram() 99 | 100 | const actionFn = sinon.fake() 101 | prog.option("--my-global ", "my global var", { 102 | global: true, 103 | action: actionFn, 104 | }) 105 | prog.argument("", "First argument").action(() => "ok") 106 | 107 | t.is(await prog.run(["first-arg", "--my-global", "secret"]), "ok") 108 | t.true(actionFn.calledOnce) 109 | }, 110 | ) 111 | 112 | test.serial("disableGlobalOption() should disable a global option", async (t) => { 113 | const prog = createProgram() 114 | 115 | const actionFn = sinon.fake() 116 | prog.option("--my-global ", "my global var", { 117 | global: true, 118 | action: actionFn, 119 | }) 120 | prog.argument("", "First argument").action(() => "ok") 121 | prog.strict(false) 122 | prog.disableGlobalOption("myGlobal") 123 | 124 | // second call should call console.warn 125 | prog.disableGlobalOption("myGlobal") 126 | 127 | t.is(await prog.run(["first-arg", "--my-global", "secret"]), "ok") 128 | t.true(actionFn.notCalled) 129 | t.true(loggerWarnSpy.calledOnce) 130 | }) 131 | 132 | test(".discover() should set discovery path if it exists", (t) => { 133 | const prog = createProgram() 134 | prog.discover(".") 135 | t.is(prog.discoveryPath, ".") 136 | }) 137 | 138 | test(".discover() should throw if provided path does not exist", (t) => { 139 | t.throws(() => createProgram().discover("/unknown/path")) 140 | }) 141 | 142 | test(".discover() should throw if provided path is not a directory", (t) => { 143 | t.throws(() => createProgram().discover(__filename)) 144 | }) 145 | 146 | // TODO(test): Unsure why this is failing. 147 | // test("should be able to call discovered commands", async (t) => { 148 | // const prog = createProgram() 149 | // prog.discover(__dirname + "/../../command/__fixtures__") 150 | // console.log({ path: __dirname + "/../../command/__fixtures__" }) 151 | // t.is(await prog.run(["example-cmd"]), "hey") 152 | // }) 153 | 154 | test.serial("should be able to call .argument() multiple times", async (t) => { 155 | const action = sinon.stub().returns("ok") 156 | const prog = createProgram() 157 | prog.argument("", "First argument").action(action) 158 | prog.argument("", "Second argument").action(action) 159 | t.is(await prog.run(["first-arg", "sec-arg"]), "ok") 160 | t.is(action.callCount, 1) 161 | }) 162 | 163 | test(".run() should work without arguments", async (t) => { 164 | const prog = createProgram() 165 | prog.strict(false) 166 | prog.action(() => "ok") 167 | t.is(await prog.run(), "ok") 168 | }) 169 | 170 | test(".run() should throw an Error when no action is defined for the program-command", async (t) => { 171 | const prog = createProgram() 172 | prog.option("-t, --type ", "Pizza type") 173 | await t.throwsAsync(() => prog.run([]), { instanceOf: NoActionError }) 174 | }) 175 | 176 | test("should be able to create a 'program-command' just by calling .action()", async (t) => { 177 | const prog = createProgram() 178 | const action = sinon.stub().returns("ok") 179 | prog.action(action) 180 | t.is(await prog.run([]), "ok") 181 | t.true(action.calledOnce) 182 | }) 183 | 184 | test(".exec() should work as expected", async (t) => { 185 | const prog = createProgram() 186 | const action = sinon.stub().returns("ok") 187 | prog.argument("", "First argument").action(action) 188 | t.is(await prog.exec(["1"]), "ok") 189 | t.deepEqual(action.args[0][0].args, { firstArg: "1" }) 190 | }) 191 | 192 | test(".cast(true) should enable auto casting", async (t) => { 193 | const prog = createProgram() 194 | const action = sinon.stub().returns("ok") 195 | prog.argument("", "First argument").action(action) 196 | prog.cast(true) 197 | t.is(await prog.run(["1"]), "ok") 198 | t.deepEqual(action.args[0][0].args, { firstArg: 1 }) 199 | }) 200 | 201 | test(".cast(false) should disable auto casting", async (t) => { 202 | const prog = createProgram() 203 | const action = sinon.stub().returns("ok") 204 | prog.argument("", "First argument").action(action) 205 | prog.cast(false) 206 | t.is(await prog.run(["1"]), "ok") 207 | t.deepEqual(action.args[0][0].args, { firstArg: "1" }) 208 | }) 209 | 210 | test("program should create help command and accept executing 'program help'", async (t) => { 211 | const prog = createProgram() 212 | const action = sinon.stub().returns("ok") 213 | prog 214 | .command("test", "test command") 215 | .argument("", "First argument") 216 | .action(action) 217 | t.is(await prog.run(["help"]), -1) 218 | t.is(await prog.run(["help", "test"]), -1) 219 | t.true(action.notCalled) 220 | 221 | // TODO(test) expect(fataErrorMock).not.toHaveBeenCalled() 222 | }) 223 | 224 | test("program should create help command and accept executing 'program help command-name'", async (t) => { 225 | const prog = createProgram() 226 | const action = sinon.stub().returns("ok") 227 | prog 228 | .command("test", "test command") 229 | .argument("", "First argument") 230 | .action(action) 231 | t.is(await prog.run(["help", "unknown"]), -1) 232 | t.true(action.notCalled) 233 | // TODO(test) expect(fataErrorMock).not.toHaveBeenCalled() 234 | }) 235 | 236 | test("program should create help command and accept executing 'program help unknown-command'", async (t) => { 237 | const prog = createProgram() 238 | const action = sinon.stub().returns("ok") 239 | prog 240 | .command("test", "test command") 241 | .argument("", "First argument") 242 | .action(action) 243 | t.is(await prog.run(["help", "unknown"]), -1) 244 | t.true(action.notCalled) 245 | // TODO(test) expect(fataErrorMock).not.toHaveBeenCalled() 246 | }) 247 | 248 | test("'program help' should work for a program without any command", async (t) => { 249 | const prog = createProgram() 250 | const action = sinon.stub().returns("ok") 251 | prog.bin("test").argument("", "First argument").action(action) 252 | t.is(await prog.run(["help"]), -1) 253 | t.true(action.notCalled) 254 | // TODO(test) expect(fataErrorMock).not.toHaveBeenCalled() 255 | }) 256 | 257 | test("'program' should throw for a program without any command but a required arg", async (t) => { 258 | const prog = createProgram() 259 | const action = sinon.stub().returns("ok") 260 | prog.bin("test").argument("", "First argument").action(action) 261 | await t.throwsAsync(() => prog.run([]), { instanceOf: ValidationSummaryError }) 262 | t.true(action.notCalled) 263 | }) 264 | 265 | test("program should fail when trying to run an unknown command", async (t) => { 266 | const prog = createProgram() 267 | const action = sinon.stub().returns("ok") 268 | prog 269 | .command("test", "test command") 270 | .argument("", "First argument") 271 | .action(action) 272 | await t.throwsAsync(() => prog.run(["unknown-cmd"]), { 273 | instanceOf: UnknownOrUnspecifiedCommandError, 274 | }) 275 | }) 276 | 277 | test("program should fail when trying to run an unknown command and suggest some commands", async (t) => { 278 | const prog = createProgram() 279 | const action = sinon.stub().returns("ok") 280 | prog 281 | .command("test", "test command") 282 | .argument("", "First argument") 283 | .action(action) 284 | await t.throwsAsync(() => prog.run(["unknown-cmd"]), { 285 | instanceOf: UnknownOrUnspecifiedCommandError, 286 | }) 287 | }) 288 | 289 | test("program should fail when trying to run without a specified command", async (t) => { 290 | const prog = createProgram() 291 | const action = sinon.stub().returns("ok") 292 | prog 293 | .command("test", "test command") 294 | .argument("", "First argument") 295 | .action(action) 296 | await t.throwsAsync(() => prog.run([]), { 297 | instanceOf: UnknownOrUnspecifiedCommandError, 298 | }) 299 | }) 300 | 301 | test.serial(".setLogLevelEnvVar() should set the log level ENV var", async (t) => { 302 | const prog = createProgram() 303 | process.env.MY_ENV_VAR = "warn" 304 | prog.configure({ logLevelEnvVar: "MY_ENV_VAR" }) 305 | const action = sinon.stub().returns("ok") 306 | prog 307 | .command("test", "test command") 308 | .argument("", "First argument") 309 | .action(action) 310 | t.is(prog.getLogLevelOverride(), "warn") 311 | await prog.run(["test", "my-arg"]) 312 | t.is(logger.level, "warn") 313 | }) 314 | 315 | test(".logger() should override the default logger", async (t) => { 316 | const prog = createProgram() 317 | const logger = { log: sinon.fake() } 318 | prog 319 | .logger(logger as unknown as Logger) 320 | .command("test", "test command") 321 | .argument("", "First argument") 322 | .action(({ logger }) => { 323 | logger.log("foo" as any) 324 | return true 325 | }) 326 | t.is(await prog.run(["test", "my-arg"]), true) 327 | t.true(logger.log.calledWith("foo")) 328 | }) 329 | --------------------------------------------------------------------------------