Skip to content

Commit

Permalink
feat: implement bash-completion script generation
Browse files Browse the repository at this point in the history
  • Loading branch information
carloscortonc committed Nov 25, 2023
1 parent 09ed999 commit fb935b5
Show file tree
Hide file tree
Showing 15 changed files with 372 additions and 25 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p align="center">
<a href="https://www.npmjs.com/package/cli-er" target="_blank">
<img src="https://img.shields.io/npm/v/cli-er.svg" alt="NPM version">
<img src="https://badgen.net/npm/v/cli-er" alt="NPM version">
</a>
<a href="https://github.com/carloscortonc/cli-er/actions/workflows/build.yml" target="_blank">
<img src="https://github.com/carloscortonc/cli-er/actions/workflows/build.yml/badge.svg" alt="build">
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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</br>
**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)</br>
**Default**: defined in [/src/cli-messages](/src/cli-messages.ts) and [/src/cli-errors](/src/cli-errors.ts)
Expand Down
6 changes: 6 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,11 @@ When active, the library will generate debug logs warning about problems, deprec
If any `WARN` log is generated, the `exitCode` will be set to `1`, so a simple validation-workflow can be built with this.
To see how to enable it check [`CliOptions.debug`](/docs/cli-options.md#debug).

## 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.
The script was tested with `bash` version 3.2.57 and `zsh` version 5.9.
You can check [here](/examples/docker/completions.sh) the generated script for docker example.


## Typescript support
You can check [this example](/examples/ts-cli) on how to write a full typescript cli application.
3 changes: 3 additions & 0 deletions examples/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
61 changes: 61 additions & 0 deletions examples/docker/completions.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion examples/docker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"main": "docker.js",
"author": "carloscortonc",
"dependencies": {
"cli-er": "0.12.0"
"cli-er": "0.14.0"
}
}
119 changes: 119 additions & 0 deletions src/bash-completion.ts
Original file line number Diff line number Diff line change
@@ -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<DefinitionElement>;
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<DefinitionElement>, 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}}
`;
42 changes: 26 additions & 16 deletions src/cli-utils.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -64,6 +65,13 @@ export const isShortAlias = (alias: string) => /^-.$/.test(alias);

/** Process definition and complete any missing fields */
export function completeDefinition(definition: Definition<DefinitionElement>, 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DefinitionElement>) =>
Object.entries(d)
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
/**
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
10 changes: 10 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit fb935b5

Please sign in to comment.