Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: integrate bash completion #74

Merged
merged 8 commits into from
Dec 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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