diff --git a/README.md b/README.md index dcbb008..02d92c7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

- NPM version + NPM version build @@ -99,10 +99,10 @@ This allows us to organize and structure the logic nicely. You can check the full [docker-based example](./examples/docker) for a more in-depth demo. - ## Features - [**Intl support**](./docs/features.md#intl-support): support for internationalized messages. - [**Routing**](./docs/features.md#routing): routes are generated where command handlers are expected to be found. +- [**Bash completion**](./docs/features.md#bash-completion): a command is created to generate the `bash-completions` script for the cli. - [**Debug mode**](./docs/features.md#debug-mode): validate the definition and options, especially when upgrading to a new version. - [**Typescript support**](./docs/features.md#typescript-support): build the cli with typescript. diff --git a/docs/cli-options.md b/docs/cli-options.md index fa74c9c..8875605 100644 --- a/docs/cli-options.md +++ b/docs/cli-options.md @@ -90,6 +90,15 @@ $ CLIER_DEBUG=1 node cli.js **Default**: `process.env.CLIER_DEBUG` +#### `completion` +Configure bash-completion functionality +##### `completion.enabled` +Whether to create bash-completion command
+**Default**: `true` +##### `completion.command` +Name of the completion command +**Default**: `generate-completions` + #### `messages` Object containing the messages to be used in the Cli, to override the default ones defined by this library. This enables internationalization and customization of cli-native messages. You can see a use-case in this [intl-cli example](/examples/intl-cli)
**Default**: defined in [/src/cli-messages](/src/cli-messages.ts) and [/src/cli-errors](/src/cli-errors.ts) diff --git a/docs/features.md b/docs/features.md index 8931b87..38063b0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -38,6 +38,11 @@ If the location is `["nms", "cmd"]` for an entryfile `cli.js`, the list of candi 5. `/index.js` - named import (`cmd`) 6. `/cli.js` - named import (`cmd`) +## Bash completion +`cli-er` includes a command to generate bash-completions for the cli. This can be configured through [`CliOptions.completion`](/docs/cli-options.md#completion), to change the name of such command, or to disable this behaviour. +You can check [here](/examples/docker/completions.sh) the generated script for docker example. +> The script was tested with `bash` version 3.2.57 and `zsh` version 5.9. + ## Debug mode When active, the library will generate debug logs warning about problems, deprecations or suggestions. Two types exist: - `WARN`: to indicated misused or deprecated options. diff --git a/examples/docker/README.md b/examples/docker/README.md index 3f59781..daf050d 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -30,4 +30,7 @@ node docker.js builder prune --all --filter until=24 -f true --keep-storage 100 # Print error when unknown option is provided (since v0.5.0) node docker.js builder prune --test + +# Generate bash-completion script (since v0.14.0) +node docker.js generate-completions > completions.sh ``` diff --git a/examples/docker/completions.sh b/examples/docker/completions.sh new file mode 100644 index 0000000..1e01bf8 --- /dev/null +++ b/examples/docker/completions.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Bash completion script for docker +# This file is automatically generated by running `docker generate-completions`. +# Created with cli-er@0.13.0 on 2023-11-25 09:40 + +function indirect(){ + if [[ -z $ZSH_VERSION ]]; then + echo ${!1} + else + echo ${(P)1} + fi +} +_docker() { + # declare nestings + local nestings=( + "builder=build,prune" + ) + # declare options by location ("o_" represents root) + local opts_by_location=( + "o_=--debug,--help,--version,--nodebug,--no-debug" + "o_builder=" + "o_build=--source,--add-host,--build-arg,--cache-from,--disable-content-trust,--file,--iidfile,--isolation,--label,--network,--no-cache,--output,--platform,--progress,--pull,--quiet,--secret,--ssh,--tag,--target" + "o_prune=--all,--filter,--force,--keep-storage" + ) + # Calculate keys for all available commands/namespaces + local all_locations=($(echo "${opts_by_location[@]:1}" | sed 's/o_\([^=]*\)=[^ ]*/\1/g')) + # initialize top-level definitions + local top_defs=("${nestings[@]%%=*}") + + for d in "${nestings[@]}";do declare "$d";done + for o in "${opts_by_location[@]}";do declare "$o";done + + # Obtain the location by removing the cli-name from the list of words + local location=("${COMP_WORDS[@]:1:$COMP_CWORD-1}") + # Initialize options with global values + local opts=($(echo "${o_}" | tr "," "\n")) + local initialized=false includeopts=false + while [ ${#location[@]} -gt 0 ]; do + local curr="${location[@]:0:1}" + local ocurr="o_$curr" + # Check for valid command/namespace + if [[ " ${top_defs[@]} " =~ " ${curr} " ]] && [[ " ${all_locations[@]} " =~ " ${curr} " ]]; then + top_defs=($(echo "$(indirect $curr)" | tr "," "\n")) + location=(${location[@]:1}) + opts+=($(echo "$(indirect $ocurr)" | tr "," "\n")) + initialized=true + # Check if element is a command to include options + [[ ! " ${nestings[@]%%=*} " =~ " ${curr} " ]] && includeopts=true || includeopts=false + else + # if not valid location was found, empty the list + [[ $initialized != true ]] && top_defs=() + break + fi + done + + # Include options into top_defs + [[ $includeopts == true ]] && top_defs+=("${opts[@]}") + COMPREPLY=($(compgen -W "${top_defs[*]}" -- $2)) +} + +complete -F _docker docker diff --git a/examples/docker/package.json b/examples/docker/package.json index c6cef1f..b86b129 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -5,6 +5,6 @@ "main": "docker.js", "author": "carloscortonc", "dependencies": { - "cli-er": "0.12.0" + "cli-er": "0.14.0" } } diff --git a/src/bash-completion.ts b/src/bash-completion.ts new file mode 100644 index 0000000..a7431ac --- /dev/null +++ b/src/bash-completion.ts @@ -0,0 +1,119 @@ +import { DefinitionElement, isShortAlias } from "./cli-utils"; +import { CliOptions, Definition, Kind } from "./types"; +import { getClierVersion } from "./utils"; + +export function generateCompletions({ + definition, + cliOptions, +}: { + definition: Definition; + cliOptions: CliOptions; +}) { + const tab = (n: number = 1) => " ".repeat(2 * n); + const getOptionsAliases = (e: DefinitionElement, filterFn?: (e: DefinitionElement) => boolean) => + Object.values(e.options || {}) + .filter(filterFn || (() => true)) + .reduce((acc, curr) => acc.concat(...curr.aliases!.filter((a) => !isShortAlias(a))), [] as string[]) + .join(","); + // prettier-ignore + const nestings: string[] = [], optionsByLocation: string[] = []; + const populateLists = (def?: Definition, loc = "") => { + if (!def) return; + const _currOptions = getOptionsAliases({ options: def }, (e) => e.kind === Kind.OPTION); + optionsByLocation.push(`"o_${loc}=${_currOptions}"`); + for (const el of Object.values(def)) { + const currNesting = getOptionsAliases(el, (e) => [Kind.NAMESPACE, Kind.COMMAND].includes(e.kind as Kind)); + if (el.kind === Kind.NAMESPACE) { + nestings.push(`"${el.key}=${currNesting}"`); + } + populateLists(el.options, el.key); + } + }; + populateLists(definition); + const toFormattedList = (els: string[]) => + [""] + .concat(...els) + .join(`\n${tab(2)}`) + .concat(`\n${tab(1)}`); + const str = Object.entries({ + cliName: cliOptions.cliName, + cliVersion: cliOptions.cliVersion, + clierVersion: getClierVersion(), + date: getLocalDate("YYYY-MM-DD HH:mm"), + command: cliOptions.completion.command, + nestings: toFormattedList(nestings), + optionsByLocation: toFormattedList(optionsByLocation), + }).reduce((acc, [k, v]) => acc.replace(new RegExp(`{{${k}}}`, "g"), v), defaultTemplate); + + process.stdout.write(str); +} + +const getLocalDate = (template: string) => { + const d = new Date(); + return [ + { m: "getFullYear", n: "YYYY", p: 4 }, + { m: "getMonth", n: "MM", o: 1 }, + { m: "getDate", n: "DD" }, + { m: "getHours", n: "HH" }, + { m: "getMinutes", n: "mm" }, + ] + .map((m) => ({ p: 2, o: 0, ...m })) + .map(({ m, p, n, o }) => ({ v: ((d as any)[m]() + o).toString().padStart(p, "0"), n })) + .reduce((acc, { v, n }) => acc.replace(new RegExp(n), v), template); +}; + +const defaultTemplate = `#!/usr/bin/env bash +# Bash completion script for {{cliName}} +# This file is automatically generated by running \`{{cliName}} {{command}}\`. +# Created with cli-er@{{clierVersion}} on {{date}} + +function indirect(){ + if [[ -z $ZSH_VERSION ]]; then + echo \${!1} + else + echo \${(P)1} + fi +} +_{{cliName}}() { + # declare nestings + local nestings=({{nestings}}) + # declare options by location ("o_" represents root) + local opts_by_location=({{optionsByLocation}}) + # Calculate keys for all available commands/namespaces + local all_locations=($(echo "\${opts_by_location[@]:1}" | sed 's/o_\\([^=]*\\)=[^ ]*/\\1/g')) + # initialize top-level definitions + local top_defs=("\${nestings[@]%%=*}") + + for d in "\${nestings[@]}";do declare "$d";done + for o in "\${opts_by_location[@]}";do declare "$o";done + + # Obtain the location by removing the cli-name from the list of words + local location=("\${COMP_WORDS[@]:1:$COMP_CWORD-1}") + # Initialize options with global values + local opts=($(echo "\${o_}" | tr "," "\\n")) + local initialized=false includeopts=false + while [ \${#location[@]} -gt 0 ]; do + local curr="\${location[@]:0:1}" + local ocurr="o_$curr" + # Check for valid command/namespace + if [[ " \${top_defs[@]} " =~ " \${curr} " ]] && [[ " \${all_locations[@]} " =~ " \${curr} " ]]; then + top_defs=($(echo "$(indirect $curr)" | tr "," "\\n")) + location=(\${location[@]:1}) + opts+=($(echo "$(indirect $ocurr)" | tr "," "\\n")) + initialized=true + # Check if element is a command to include options + [[ ! " \${nestings[@]%%=*} " =~ " \${curr} " ]] && includeopts=true || includeopts=false + else + # if not valid location was found, empty the list + [[ $initialized != true ]] && top_defs=() + break + fi + done + + # Include options into top_defs + [[ $includeopts == true ]] && top_defs+=("\${opts[@]}") + COMPREPLY=($(compgen -W "\${top_defs[*]}" -- $2)) +} + +complete -F _{{cliName}} {{cliName}} +`; diff --git a/src/cli-utils.ts b/src/cli-utils.ts index 5db2c13..45f77aa 100644 --- a/src/cli-utils.ts +++ b/src/cli-utils.ts @@ -1,12 +1,13 @@ import path from "path"; import fs from "fs"; import url from "url"; -import { addLineBreaks, ColumnFormatter, debug, DEBUG_TYPE, deprecationWarning, logErrorAndExit } from "./utils"; +import { addLineBreaks, clone, ColumnFormatter, debug, DEBUG_TYPE, deprecationWarning, logErrorAndExit } from "./utils"; import { Kind, ParsingOutput, Definition, Type, CliOptions, Option, Namespace, Command } from "./types"; import parseOptionValue from "./cli-option-parser"; import { validatePositional } from "./definition-validations"; import flattenArguments from "./option-syntax"; import { closest } from "./edit-distance"; +import { generateCompletions } from "./bash-completion"; import Cli from "."; /** Create a type containing all elements for better readability, as here is not necessary type-checking due to all methods being internal */ @@ -64,6 +65,13 @@ export const isShortAlias = (alias: string) => /^-.$/.test(alias); /** Process definition and complete any missing fields */ export function completeDefinition(definition: Definition, cliOptions: CliOptions) { + // Include completion command + if (cliOptions.completion.enabled) { + definition[cliOptions.completion.command] = { + action: () => generateCompletions({ definition, cliOptions }), + hidden: true, + }; + } const { autoInclude: helpAutoInclude, template: _, ...helpOption } = cliOptions.help; // Auto-include help option if (helpAutoInclude) { @@ -473,21 +481,23 @@ export function generateScopedHelp( sections[HELP_SECTIONS.DESCRIPTION] = cliOptions.cliDescription.concat("\n"); } // Add usage section - const { existingKinds, hasOptions, positionalOptions } = Object.values(definitionRef || {}).reduce( - (acc, curr) => { - const { kind, positional, required, key } = curr; - if (kind === Kind.OPTION) { - acc.hasOptions = true; - if (positional === true || typeof positional === "number") { - acc.positionalOptions.push({ index: positional, key, required }); + const { existingKinds, hasOptions, positionalOptions } = Object.values(definitionRef || {}) + .filter((e) => !e.hidden) + .reduce( + (acc, curr) => { + const { kind, positional, required, key } = curr; + if (kind === Kind.OPTION) { + acc.hasOptions = true; + if (positional === true || typeof positional === "number") { + acc.positionalOptions.push({ index: positional, key, required }); + } + } else if (acc.existingKinds.indexOf(kind as string) < 0) { + acc.existingKinds.push(kind as string); } - } else if (acc.existingKinds.indexOf(kind as string) < 0) { - acc.existingKinds.push(kind as string); - } - return acc; - }, - { existingKinds: [] as string[], hasOptions: false, positionalOptions: [] as any[] }, - ); + return acc; + }, + { existingKinds: [] as string[], hasOptions: false, positionalOptions: [] as any[] }, + ); const formatKinds = (kinds: string[]) => kinds @@ -610,7 +620,7 @@ export function getDefinitionElement( rawLocation: string[], cliOptions: CliOptions, ): DefinitionElement | undefined { - let definitionRef = definition; + let definitionRef = clone(definition); let inheritedOptions: Definition = {}; const getOptions = (d: Definition) => Object.entries(d) diff --git a/src/index.ts b/src/index.ts index f19677e..3ecabbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,10 @@ export default class Cli { cliVersion: packagejson.version || "-", cliDescription: packagejson.description || "", debug: false, + completion: { + enabled: true, + command: "generate-completions", + }, }; // Environment variables should have the highest priority @@ -87,7 +91,10 @@ export default class Cli { } // Store back at process.env.CLIER_DEBUG the final value of CliOptions.debug, to be accesible without requiring CliOptions process.env[CLIER_DEBUG_KEY] = this.options.debug ? "1" : ""; - this.definition = completeDefinition(clone(definition), this.options) as Definition; + // Do this so we can provide "completeDefinition" with a reference to "this.definition" + this.definition = clone(definition); + this.definition = completeDefinition(this.definition, this.options) as Definition; + return this; } /** diff --git a/src/types.ts b/src/types.ts index 73a6666..e37ff70 100644 --- a/src/types.ts +++ b/src/types.ts @@ -187,6 +187,17 @@ export type CliOptions = { * @default `process.env.CLIER_DEBUG` */ debug: boolean; + /** bash-completion related config */ + completion: { + /** Whether to generate the completion-command + * @default true + */ + enabled: boolean; + /** The name of the completion command + * @default "generate-completions" + */ + command: string; + }; /** Messages to be used, overriding the ones defined by this library * This allows to include new translations, or tweak the current ones */ diff --git a/src/utils.ts b/src/utils.ts index 5acafbd..279d655 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -130,6 +130,16 @@ export function findPackageJson(baseLocation: string) { return undefined; } +/** Find the version of this library */ +export function getClierVersion() { + const location = path.join(__dirname, "..", "package.json"); + try { + return JSON.parse(fs.readFileSync(location, "utf-8")).version; + } catch { + return undefined; + } +} + export const isDebugActive = () => process.env[CLIER_DEBUG_KEY]; export enum DEBUG_TYPE { diff --git a/test/__snapshots__/bash-completion.test.ts.snap b/test/__snapshots__/bash-completion.test.ts.snap new file mode 100644 index 0000000..628d42f --- /dev/null +++ b/test/__snapshots__/bash-completion.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateCompletions bash-script gets printed via process.stdout.write 1`] = ` +"#!/usr/bin/env bash +# Bash completion script for cliName +# This file is automatically generated by running \`cliName complete\`. +# Created with cli-er@0.13.0 on 2022-08-04 00:00 + +function indirect(){ + if [[ -z $ZSH_VERSION ]]; then + echo \${!1} + else + echo \${(P)1} + fi +} +_cliName() { + # declare nestings + local nestings=( + \\"nms=cmd\\" + \\"nmsi=nested-nms\\" + \\"nested-nms=cmd\\" + ) + # declare options by location (\\"o_\\" represents root) + local opts_by_location=( + \\"o_=--global,--help,--version\\" + \\"o_nms=\\" + \\"o_cmd=--opt\\" + \\"o_nmsi=--nmsi-o\\" + \\"o_nested-nms=--nested-nms-o\\" + ) + # Calculate keys for all available commands/namespaces + local all_locations=($(echo \\"\${opts_by_location[@]:1}\\" | sed 's/o_\\\\([^=]*\\\\)=[^ ]*/\\\\1/g')) + # initialize top-level definitions + local top_defs=(\\"\${nestings[@]%%=*}\\") + + for d in \\"\${nestings[@]}\\";do declare \\"$d\\";done + for o in \\"\${opts_by_location[@]}\\";do declare \\"$o\\";done + + # Obtain the location by removing the cli-name from the list of words + local location=(\\"\${COMP_WORDS[@]:1:$COMP_CWORD-1}\\") + # Initialize options with global values + local opts=($(echo \\"\${o_}\\" | tr \\",\\" \\"\\\\n\\")) + local initialized=false includeopts=false + while [ \${#location[@]} -gt 0 ]; do + local curr=\\"\${location[@]:0:1}\\" + local ocurr=\\"o_$curr\\" + # Check for valid command/namespace + if [[ \\" \${top_defs[@]} \\" =~ \\" \${curr} \\" ]] && [[ \\" \${all_locations[@]} \\" =~ \\" \${curr} \\" ]]; then + top_defs=($(echo \\"$(indirect $curr)\\" | tr \\",\\" \\"\\\\n\\")) + location=(\${location[@]:1}) + opts+=($(echo \\"$(indirect $ocurr)\\" | tr \\",\\" \\"\\\\n\\")) + initialized=true + # Check if element is a command to include options + [[ ! \\" \${nestings[@]%%=*} \\" =~ \\" \${curr} \\" ]] && includeopts=true || includeopts=false + else + # if not valid location was found, empty the list + [[ $initialized != true ]] && top_defs=() + break + fi + done + + # Include options into top_defs + [[ $includeopts == true ]] && top_defs+=(\\"\${opts[@]}\\") + COMPREPLY=($(compgen -W \\"\${top_defs[*]}\\" -- $2)) +} + +complete -F _cliName cliName +" +`; diff --git a/test/bash-completion.test.ts b/test/bash-completion.test.ts new file mode 100644 index 0000000..7d9451e --- /dev/null +++ b/test/bash-completion.test.ts @@ -0,0 +1,22 @@ +import { generateCompletions } from "../src/bash-completion"; +import d from "./data/definition.json"; +import Cli from "../src"; + +describe("generateCompletions", () => { + beforeAll(() => { + const mockDate = new Date(2022, 7, 4); + jest.spyOn(global, "Date").mockImplementation((() => mockDate) as any); + }); + const cliOptions: any = { + cliName: "cliName", + cliVersion: "cliVersion", + completion: { command: "complete" }, + }; + const definition = new Cli(d as any).definition; + it("bash-script gets printed via process.stdout.write", () => { + const writeSpy = jest.spyOn(process.stdout, "write").mockImplementationOnce((() => {}) as any); + generateCompletions({ definition, cliOptions }); + expect(writeSpy.mock.lastCall[0]).toMatchSnapshot(); + writeSpy.mockRestore(); + }); +}); diff --git a/test/cli-utils.test.ts b/test/cli-utils.test.ts index 781d3c8..bbd9904 100644 --- a/test/cli-utils.test.ts +++ b/test/cli-utils.test.ts @@ -65,6 +65,10 @@ describe("completeDefinition", () => { cliVersion: "", cliDescription: "", debug: false, + completion: { + enabled: false, + command: "generate-completions", + }, }; it("Completes missing fields in definition with nested content ", () => { const completedDefinition = completeDefinition(d, cliOptions); @@ -705,7 +709,7 @@ Usage: cli-name NAMESPACE|COMMAND [OPTIONS]`), }, }); expect(output).toStrictEqual(` -Usage: cli-name NAMESPACE [OPTIONS] +Usage: cli-name NAMESPACE Namespaces: nms - @@ -720,7 +724,7 @@ This is a custom footer bool: { type: "boolean", default: true, description: "boolean option" }, arg1: { positional: 0, required: true, description: "first positional mandatory option" }, arg2: { positional: 1, description: "second positional option" }, - arg3: { positional: true, hidden: true }, + arg3: { positional: true, description: "catch-all positional option" }, }); generateScopedHelp(def, [], cliOptions); expect(output).toStrictEqual(` @@ -732,6 +736,7 @@ Options: --bool boolean option (default: true) --arg1 first positional mandatory option --arg2 second positional option + --arg3 catch-all positional option -h, --help Display global help, or scoped to a namespace/command `); diff --git a/test/index.test.ts b/test/index.test.ts index eb3a2db..5379bad 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { }); describe("Cli.constructor", () => { - it("Resulting definition contains only auto-included options when provided with empty definition", () => { + it("Resulting definition contains only auto-included options/commands when provided with empty definition", () => { const c = new Cli({}); expect(c.definition).toStrictEqual({ help: expect.objectContaining({ @@ -34,6 +34,10 @@ describe("Cli.constructor", () => { aliases: ["-v", "--version"], description: "Display version", }), + "generate-completions": expect.objectContaining({ + aliases: ["generate-completions"], + hidden: true, + }), }); }); it("Resulting definition calculates aliases dashes when not present", () => { @@ -58,8 +62,11 @@ describe("Cli.constructor", () => { }), ); }); - it("Resulting definition is an empty object when provided with empty definition and all auto-included options are disabled", () => { - const c = new Cli({}, { help: { autoInclude: false }, version: { autoInclude: false } }); + it("Resulting definition is an empty object when provided with empty definition and all auto-included options/commands are disabled", () => { + const c = new Cli( + {}, + { help: { autoInclude: false }, version: { autoInclude: false }, completion: { enabled: false } }, + ); expect(c.definition).toStrictEqual({}); }); it("CliOptions are default when instantiating with no options", () => { @@ -97,6 +104,10 @@ describe("Cli.constructor", () => { cliVersion: "1.0.0", cliDescription: "cli-description", debug: false, + completion: { + enabled: true, + command: "generate-completions", + }, }); }); it("CliOptions are the result of merging default and provided options when instantiating with options", () => { @@ -138,6 +149,10 @@ describe("Cli.constructor", () => { cliVersion: "2.0.0", cliDescription: "custom-description", debug: false, + completion: { + enabled: true, + command: "generate-completions", + }, }); }); it("Overwrite default logger", () => {