cli-forge 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +35 -0
- package/LICENSE.md +5 -0
- package/cli.js +9 -0
- package/{bin → dist/bin}/cli.d.ts +1 -1
- package/{bin → dist/bin}/cli.js +2 -2
- package/dist/bin/cli.js.map +1 -0
- package/{bin → dist/bin}/commands/generate-documentation.d.ts +1 -1
- package/{bin → dist/bin}/commands/generate-documentation.js +58 -11
- package/dist/bin/commands/generate-documentation.js.map +1 -0
- package/{bin → dist/bin}/commands/init.d.ts +1 -1
- package/{bin → dist/bin}/commands/init.js +11 -6
- package/dist/bin/commands/init.js.map +1 -0
- package/dist/bin/utils/fs.js.map +1 -0
- package/{src → dist}/index.d.ts +1 -1
- package/dist/index.js.map +1 -0
- package/dist/lib/cli-option-groups.js.map +1 -0
- package/dist/lib/composable-builder.js.map +1 -0
- package/dist/lib/configuration-providers.js.map +1 -0
- package/{src → dist}/lib/documentation.d.ts +3 -3
- package/dist/lib/documentation.js.map +1 -0
- package/dist/lib/format-help.js.map +1 -0
- package/{src → dist}/lib/interactive-shell.d.ts +1 -1
- package/{src → dist}/lib/interactive-shell.js +6 -3
- package/dist/lib/interactive-shell.js.map +1 -0
- package/{src → dist}/lib/internal-cli.d.ts +3 -3
- package/{src → dist}/lib/internal-cli.js +8 -1
- package/dist/lib/internal-cli.js.map +1 -0
- package/{src → dist}/lib/public-api.d.ts +41 -6
- package/dist/lib/public-api.js.map +1 -0
- package/dist/lib/test-harness.js.map +1 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/middleware/zod.d.ts +4 -0
- package/dist/middleware/zod.js +18 -0
- package/dist/middleware/zod.js.map +1 -0
- package/dist/middleware.d.ts +1 -0
- package/dist/middleware.js +5 -0
- package/dist/middleware.js.map +1 -0
- package/package.json +28 -10
- package/project.json +7 -0
- package/src/bin/cli.ts +17 -0
- package/src/bin/commands/generate-documentation.ts +403 -0
- package/src/bin/commands/init.ts +320 -0
- package/src/bin/utils/fs.ts +11 -0
- package/src/index.ts +7 -0
- package/src/lib/cli-option-groups.ts +69 -0
- package/src/lib/composable-builder.ts +9 -0
- package/src/lib/configuration-providers.ts +36 -0
- package/src/lib/documentation.spec.ts +156 -0
- package/src/lib/documentation.ts +107 -0
- package/src/lib/format-help.ts +149 -0
- package/src/lib/interactive-shell.ts +115 -0
- package/src/lib/internal-cli.spec.ts +345 -0
- package/src/lib/internal-cli.ts +529 -0
- package/src/lib/public-api.ts +449 -0
- package/src/lib/test-harness.spec.ts +29 -0
- package/src/lib/test-harness.ts +69 -0
- package/src/lib/utils.spec.ts +25 -0
- package/src/lib/utils.ts +144 -0
- package/src/middleware/zod.ts +21 -0
- package/src/middleware.ts +1 -0
- package/tsconfig.json +23 -0
- package/tsconfig.lib.json +20 -0
- package/tsconfig.lib.json.tsbuildinfo +1 -0
- package/tsconfig.spec.json +26 -0
- package/vitest.config.mts +18 -0
- package/bin/cli.js.map +0 -1
- package/bin/commands/generate-documentation.js.map +0 -1
- package/bin/commands/init.js.map +0 -1
- package/bin/utils/fs.js.map +0 -1
- package/src/index.js.map +0 -1
- package/src/lib/cli-option-groups.js.map +0 -1
- package/src/lib/composable-builder.js.map +0 -1
- package/src/lib/configuration-providers.js.map +0 -1
- package/src/lib/documentation.js.map +0 -1
- package/src/lib/format-help.js.map +0 -1
- package/src/lib/interactive-shell.js.map +0 -1
- package/src/lib/internal-cli.js.map +0 -1
- package/src/lib/public-api.js.map +0 -1
- package/src/lib/test-harness.js.map +0 -1
- package/src/lib/utils.js.map +0 -1
- /package/{bin → dist/bin}/utils/fs.d.ts +0 -0
- /package/{bin → dist/bin}/utils/fs.js +0 -0
- /package/{src → dist}/index.js +0 -0
- /package/{src → dist}/lib/cli-option-groups.d.ts +0 -0
- /package/{src → dist}/lib/cli-option-groups.js +0 -0
- /package/{src → dist}/lib/composable-builder.d.ts +0 -0
- /package/{src → dist}/lib/composable-builder.js +0 -0
- /package/{src → dist}/lib/configuration-providers.d.ts +0 -0
- /package/{src → dist}/lib/configuration-providers.js +0 -0
- /package/{src → dist}/lib/documentation.js +0 -0
- /package/{src → dist}/lib/format-help.d.ts +0 -0
- /package/{src → dist}/lib/format-help.js +0 -0
- /package/{src → dist}/lib/public-api.js +0 -0
- /package/{src → dist}/lib/test-harness.d.ts +0 -0
- /package/{src → dist}/lib/test-harness.js +0 -0
- /package/{src → dist}/lib/utils.d.ts +0 -0
- /package/{src → dist}/lib/utils.js +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnknownOptionConfig,
|
|
3
|
+
OptionConfigToType,
|
|
4
|
+
readDefaultValue,
|
|
5
|
+
} from '@cli-forge/parser';
|
|
6
|
+
import { InternalCLI } from './internal-cli';
|
|
7
|
+
import { CLI } from './public-api';
|
|
8
|
+
|
|
9
|
+
export type Documentation = {
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
epilogue?: string;
|
|
13
|
+
usage: string;
|
|
14
|
+
examples: string[];
|
|
15
|
+
options: Readonly<Record<string, NormalizedOptionConfig>>;
|
|
16
|
+
positionals: readonly Readonly<NormalizedOptionConfig>[];
|
|
17
|
+
groupedOptions: Array<{
|
|
18
|
+
label: string;
|
|
19
|
+
keys: Array<NormalizedOptionConfig>;
|
|
20
|
+
}>;
|
|
21
|
+
subcommands: Documentation[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function normalizeOptionConfigForDocumentation<T extends UnknownOptionConfig>(
|
|
25
|
+
option: T,
|
|
26
|
+
key: string
|
|
27
|
+
) {
|
|
28
|
+
const { default: declaredDefault, ...rest } = option;
|
|
29
|
+
let resolvedDefault: OptionConfigToType<T> | string | undefined;
|
|
30
|
+
if (declaredDefault !== undefined) {
|
|
31
|
+
const [defaultValue, description] = readDefaultValue(option);
|
|
32
|
+
resolvedDefault = description ?? defaultValue;
|
|
33
|
+
}
|
|
34
|
+
const result: typeof rest & {
|
|
35
|
+
key: string;
|
|
36
|
+
default?: OptionConfigToType<T> | string | undefined;
|
|
37
|
+
} = { ...rest, key };
|
|
38
|
+
if (resolvedDefault !== undefined) {
|
|
39
|
+
result.default = resolvedDefault;
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type NormalizedOptionConfig<
|
|
45
|
+
T extends UnknownOptionConfig = UnknownOptionConfig
|
|
46
|
+
> = ReturnType<typeof normalizeOptionConfigForDocumentation<T>>;
|
|
47
|
+
|
|
48
|
+
export function generateDocumentation(
|
|
49
|
+
cli: InternalCLI,
|
|
50
|
+
commandChain: string[] = []
|
|
51
|
+
) {
|
|
52
|
+
// Ensure current command's options are built.
|
|
53
|
+
if (cli.configuration?.builder) {
|
|
54
|
+
// The cli instance here is typed a bit too well
|
|
55
|
+
// for the builder function, so we need to cast it to
|
|
56
|
+
// a more generic form.
|
|
57
|
+
cli.configuration.builder(cli as CLI);
|
|
58
|
+
}
|
|
59
|
+
const parser = cli.getParser();
|
|
60
|
+
|
|
61
|
+
const groupedOptions = cli.getGroupedOptions();
|
|
62
|
+
const options: Record<string, NormalizedOptionConfig> = Object.fromEntries(
|
|
63
|
+
Object.entries(parser.configuredOptions)
|
|
64
|
+
.filter(([, c]) => !c.hidden)
|
|
65
|
+
.map(([k, v]) => [k, normalizeOptionConfigForDocumentation(v, k)])
|
|
66
|
+
);
|
|
67
|
+
const positionals = parser.configuredPositionals;
|
|
68
|
+
for (const positional of positionals) {
|
|
69
|
+
delete options[positional.key];
|
|
70
|
+
}
|
|
71
|
+
const subcommands: Documentation[] = [];
|
|
72
|
+
for (const subcommand of Object.values(cli.getSubcommands())) {
|
|
73
|
+
if (subcommand.configuration?.hidden !== true) {
|
|
74
|
+
const clone = subcommand.clone();
|
|
75
|
+
if (clone.configuration) {
|
|
76
|
+
clone.configuration.epilogue ??= cli.configuration?.epilogue;
|
|
77
|
+
}
|
|
78
|
+
subcommands.push(
|
|
79
|
+
generateDocumentation(clone, [...commandChain, cli.name])
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Object.values(cli.getSubcommands()).map((cmd) =>
|
|
85
|
+
generateDocumentation(cmd.clone(), [...commandChain, cli.name])
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name: cli.name,
|
|
90
|
+
description: cli.configuration?.description,
|
|
91
|
+
usage: cli.configuration?.usage
|
|
92
|
+
? commandChain.length
|
|
93
|
+
? [...commandChain, cli.configuration.usage].join(' ')
|
|
94
|
+
: cli.configuration?.usage
|
|
95
|
+
: [
|
|
96
|
+
...commandChain,
|
|
97
|
+
cli.name,
|
|
98
|
+
...positionals.map((p) => (p.required ? `<${p.key}>` : `[${p.key}]`)),
|
|
99
|
+
].join(' '),
|
|
100
|
+
epilogue: cli.configuration?.epilogue,
|
|
101
|
+
examples: cli.configuration?.examples ?? [],
|
|
102
|
+
groupedOptions,
|
|
103
|
+
options,
|
|
104
|
+
positionals,
|
|
105
|
+
subcommands,
|
|
106
|
+
} as Documentation;
|
|
107
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalOptionConfig,
|
|
3
|
+
UnknownOptionConfig,
|
|
4
|
+
readDefaultValue,
|
|
5
|
+
} from '@cli-forge/parser';
|
|
6
|
+
import { InternalCLI } from './internal-cli';
|
|
7
|
+
|
|
8
|
+
export function formatHelp(parentCLI: InternalCLI<any>): string {
|
|
9
|
+
const help: string[] = [];
|
|
10
|
+
let command = parentCLI;
|
|
11
|
+
let epilogue = parentCLI.configuration?.epilogue;
|
|
12
|
+
for (const key of parentCLI.commandChain) {
|
|
13
|
+
command = command.registeredCommands[key] as typeof parentCLI;
|
|
14
|
+
|
|
15
|
+
// Properties that are ineherited from the parent command should be copied over
|
|
16
|
+
if (command.configuration?.epilogue) {
|
|
17
|
+
epilogue = command.configuration.epilogue;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
help.push(
|
|
21
|
+
`Usage: ${
|
|
22
|
+
command.configuration?.usage
|
|
23
|
+
? command.configuration.usage
|
|
24
|
+
: [
|
|
25
|
+
parentCLI.name,
|
|
26
|
+
...parentCLI.commandChain,
|
|
27
|
+
...command.parser.configuredPositionals.map((p) =>
|
|
28
|
+
p.required ? `<${p.key}>` : `[${p.key}]`
|
|
29
|
+
),
|
|
30
|
+
].join(' ')
|
|
31
|
+
}`
|
|
32
|
+
);
|
|
33
|
+
if (command.configuration?.description) {
|
|
34
|
+
help.push(command.configuration.description);
|
|
35
|
+
}
|
|
36
|
+
if (Object.keys(command.registeredCommands).length > 0) {
|
|
37
|
+
help.push('');
|
|
38
|
+
help.push('Commands:');
|
|
39
|
+
}
|
|
40
|
+
for (const key in command.registeredCommands) {
|
|
41
|
+
const subcommand = command.registeredCommands[key];
|
|
42
|
+
help.push(
|
|
43
|
+
` ${key}${
|
|
44
|
+
subcommand.configuration?.description
|
|
45
|
+
? ' - ' + subcommand.configuration.description
|
|
46
|
+
: ''
|
|
47
|
+
}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const groupedOptions = parentCLI.getGroupedOptions();
|
|
51
|
+
const nonpositionalOptions = Object.values(
|
|
52
|
+
command.parser.configuredOptions
|
|
53
|
+
).filter((c) => !c.positional);
|
|
54
|
+
|
|
55
|
+
help.push(...getOptionBlock('Options', nonpositionalOptions));
|
|
56
|
+
|
|
57
|
+
for (const { label, keys } of groupedOptions) {
|
|
58
|
+
help.push(...getOptionBlock(label, keys));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (command.configuration?.examples?.length) {
|
|
62
|
+
help.push('');
|
|
63
|
+
help.push('Examples:');
|
|
64
|
+
for (const example of command.configuration.examples) {
|
|
65
|
+
help.push(` \`${example}\``);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Object.keys(command.registeredCommands).length > 0) {
|
|
70
|
+
help.push(' ');
|
|
71
|
+
help.push(
|
|
72
|
+
`Run \`${[parentCLI.name, ...parentCLI.commandChain].join(
|
|
73
|
+
' '
|
|
74
|
+
)} [command] --help\` for more information on a command`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (epilogue) {
|
|
79
|
+
help.push('');
|
|
80
|
+
help.push(epilogue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return help.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getOptionParts(option: UnknownOptionConfig) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (option.description) {
|
|
89
|
+
parts.push(option.description);
|
|
90
|
+
}
|
|
91
|
+
if ('choices' in option && option.choices) {
|
|
92
|
+
const choices =
|
|
93
|
+
typeof option.choices === 'function' ? option.choices() : option.choices;
|
|
94
|
+
parts.push(`(${choices.join(', ')})`);
|
|
95
|
+
}
|
|
96
|
+
if (option.default) {
|
|
97
|
+
parts.push(
|
|
98
|
+
'[default: ' + formatDefaultValue(readDefaultValue(option)) + ']'
|
|
99
|
+
);
|
|
100
|
+
} else if (option.required) {
|
|
101
|
+
parts.push('[required]');
|
|
102
|
+
}
|
|
103
|
+
if (option.deprecated) {
|
|
104
|
+
parts.push('[deprecated: ' + option.deprecated + ']');
|
|
105
|
+
}
|
|
106
|
+
return parts;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatDefaultValue([value, description]: [any, string | undefined]) {
|
|
110
|
+
if (description) {
|
|
111
|
+
return description;
|
|
112
|
+
}
|
|
113
|
+
return removeTrailingAndLeadingQuotes(JSON.stringify(value));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeTrailingAndLeadingQuotes(str: string) {
|
|
117
|
+
return str.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getOptionBlock(label: string, options: InternalOptionConfig[]) {
|
|
121
|
+
const lines: string[] = [];
|
|
122
|
+
|
|
123
|
+
if (options.length > 0) {
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(label + ':');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const allParts: Array<[key: string, ...parts: string[]]> = [];
|
|
129
|
+
for (const option of options) {
|
|
130
|
+
allParts.push([option.key, ...getOptionParts(option)]);
|
|
131
|
+
}
|
|
132
|
+
const paddingValues: number[] = [];
|
|
133
|
+
for (let i = 0; i < allParts.length; i++) {
|
|
134
|
+
for (let j = 0; j < allParts[i].length; j++) {
|
|
135
|
+
if (!paddingValues[j]) {
|
|
136
|
+
paddingValues[j] = 0;
|
|
137
|
+
}
|
|
138
|
+
paddingValues[j] = Math.max(paddingValues[j], allParts[i][j].length);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const [key, ...parts] of allParts) {
|
|
142
|
+
lines.push(
|
|
143
|
+
` --${key.padEnd(paddingValues[0])}${parts.length ? ' - ' : ''}${parts
|
|
144
|
+
.map((part, i) => part.padEnd(paddingValues[i + 1]))
|
|
145
|
+
.join(' ')}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import { stringToArgs } from './utils';
|
|
3
|
+
import { InternalCLI } from './internal-cli';
|
|
4
|
+
import { execSync, spawnSync } from 'child_process';
|
|
5
|
+
import { getBin } from '@cli-forge/parser';
|
|
6
|
+
|
|
7
|
+
export interface InteractiveShellOptions {
|
|
8
|
+
prompt?: string;
|
|
9
|
+
prependArgs?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type NormalizedInteractiveShellOptions = Required<InteractiveShellOptions>;
|
|
13
|
+
|
|
14
|
+
function normalizeShellOptions(
|
|
15
|
+
cli: InternalCLI,
|
|
16
|
+
options?: InteractiveShellOptions
|
|
17
|
+
): NormalizedInteractiveShellOptions {
|
|
18
|
+
return {
|
|
19
|
+
prompt:
|
|
20
|
+
options?.prompt ??
|
|
21
|
+
(() => {
|
|
22
|
+
const chain = [cli.name];
|
|
23
|
+
if (cli.commandChain.length > 2) {
|
|
24
|
+
chain.push('...', ...cli.commandChain[cli.command.length - 1]);
|
|
25
|
+
} else {
|
|
26
|
+
chain.push(...cli.commandChain);
|
|
27
|
+
}
|
|
28
|
+
return chain.join(' ') + ' > ';
|
|
29
|
+
})(),
|
|
30
|
+
prependArgs: options?.prependArgs ?? [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export let INTERACTIVE_SHELL: InteractiveShell | undefined;
|
|
35
|
+
|
|
36
|
+
export class InteractiveShell {
|
|
37
|
+
private readonly rl: readline.Interface;
|
|
38
|
+
private listeners: any[] = [];
|
|
39
|
+
|
|
40
|
+
constructor(cli: InternalCLI<any>, opts?: InteractiveShellOptions) {
|
|
41
|
+
if (INTERACTIVE_SHELL) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'Only one interactive shell can be created at a time. Make sure the other instance is closed.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
47
|
+
INTERACTIVE_SHELL = this;
|
|
48
|
+
|
|
49
|
+
const { prompt, prependArgs } = normalizeShellOptions(cli, opts);
|
|
50
|
+
|
|
51
|
+
this.rl = readline
|
|
52
|
+
.createInterface({
|
|
53
|
+
input: process.stdin,
|
|
54
|
+
output: process.stdout,
|
|
55
|
+
prompt: prompt,
|
|
56
|
+
})
|
|
57
|
+
.once('SIGINT', () => {
|
|
58
|
+
process.emit('SIGINT');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.rl.prompt();
|
|
62
|
+
|
|
63
|
+
this.registerLineListener(async (line) => {
|
|
64
|
+
const nextArgs = stringToArgs(line);
|
|
65
|
+
let currentCommand = cli;
|
|
66
|
+
for (const subcommand of cli.commandChain) {
|
|
67
|
+
currentCommand = currentCommand.registeredCommands[subcommand];
|
|
68
|
+
}
|
|
69
|
+
if (currentCommand.registeredCommands[nextArgs[0]]) {
|
|
70
|
+
spawnSync(
|
|
71
|
+
process.execPath,
|
|
72
|
+
[
|
|
73
|
+
...process.execArgv,
|
|
74
|
+
getBin(process.argv),
|
|
75
|
+
...prependArgs,
|
|
76
|
+
...nextArgs,
|
|
77
|
+
],
|
|
78
|
+
{ stdio: 'inherit' }
|
|
79
|
+
);
|
|
80
|
+
} else if (nextArgs[0] === 'help') {
|
|
81
|
+
currentCommand.clone().printHelp();
|
|
82
|
+
} else if (nextArgs[0] === 'exit') {
|
|
83
|
+
this.close();
|
|
84
|
+
return true;
|
|
85
|
+
} else if (line.trim()) {
|
|
86
|
+
try {
|
|
87
|
+
execSync(line, { stdio: 'inherit' });
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
registerLineListener(callback: (line: string) => Promise<boolean | void>) {
|
|
97
|
+
const wrapped = async (line: string) => {
|
|
98
|
+
this.rl.pause();
|
|
99
|
+
const shouldHalt = await callback(line);
|
|
100
|
+
if (!shouldHalt) this.rl.prompt();
|
|
101
|
+
};
|
|
102
|
+
this.listeners.push(wrapped);
|
|
103
|
+
this.rl.on('line', wrapped);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
close() {
|
|
107
|
+
this.listeners.forEach((listener) => this.rl.off('line', listener));
|
|
108
|
+
this.rl.close();
|
|
109
|
+
readline.moveCursor(process.stdout, -1 * this.rl.getCursorPos().cols, 0);
|
|
110
|
+
readline.clearScreenDown(process.stdout);
|
|
111
|
+
if (INTERACTIVE_SHELL === this) {
|
|
112
|
+
INTERACTIVE_SHELL = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { InternalCLI } from './internal-cli';
|
|
2
|
+
import { cli } from './public-api';
|
|
3
|
+
|
|
4
|
+
const ORIGINAL_CONSOLE_LOG = console.log;
|
|
5
|
+
|
|
6
|
+
function mockConsoleLog() {
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
console.log = (...contents) =>
|
|
9
|
+
lines.push(
|
|
10
|
+
contents
|
|
11
|
+
.map((s) => (typeof s === 'string' ? s : JSON.stringify(s)))
|
|
12
|
+
.join(' ')
|
|
13
|
+
);
|
|
14
|
+
return {
|
|
15
|
+
getOutput: () => lines.join('\n'),
|
|
16
|
+
restore: () => {
|
|
17
|
+
console.log = ORIGINAL_CONSOLE_LOG;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('cliForge', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
// Tests that contain handlers which fail
|
|
25
|
+
// set process.exitCode to 1
|
|
26
|
+
process.exitCode = undefined;
|
|
27
|
+
|
|
28
|
+
// Restore console.log
|
|
29
|
+
console.log = ORIGINAL_CONSOLE_LOG;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('typings should work', async () => {
|
|
33
|
+
await cli('test cli')
|
|
34
|
+
.option('foo', { type: 'string', required: true })
|
|
35
|
+
.command('bar', {
|
|
36
|
+
builder: (argv) => argv.option('baz', { type: 'number' }),
|
|
37
|
+
handler: (args) => {
|
|
38
|
+
// baz should be a number
|
|
39
|
+
args.baz?.toFixed();
|
|
40
|
+
|
|
41
|
+
// foo should be a string
|
|
42
|
+
args.foo.concat('bar');
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
.forge(['--foo', 'hello', 'bar', '--baz', '42']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should run commands', async () => {
|
|
49
|
+
let ran = false;
|
|
50
|
+
let bar;
|
|
51
|
+
await cli('test')
|
|
52
|
+
.command('foo', {
|
|
53
|
+
builder: (argv) => argv.option('bar', { type: 'string' }),
|
|
54
|
+
handler: (args) => {
|
|
55
|
+
ran = true;
|
|
56
|
+
bar = args.bar;
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
.forge(['foo', '--bar', 'baz']);
|
|
60
|
+
expect(ran).toBe(true);
|
|
61
|
+
expect(bar).toBe('baz');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should run commands by alias', async () => {
|
|
65
|
+
const ran: Record<string, number> = {};
|
|
66
|
+
const makeHandler = (name: string) => () => {
|
|
67
|
+
ran[name] = (ran[name] || 0) + 1;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const test = cli('test')
|
|
71
|
+
.command('foo', {
|
|
72
|
+
alias: ['f'],
|
|
73
|
+
builder: (argv) => argv,
|
|
74
|
+
handler: makeHandler('foo'),
|
|
75
|
+
})
|
|
76
|
+
.command('bar', {
|
|
77
|
+
alias: ['$0'],
|
|
78
|
+
builder: (argv) => argv,
|
|
79
|
+
handler: makeHandler('bar'),
|
|
80
|
+
}) as InternalCLI;
|
|
81
|
+
await test.clone().forge(['f']);
|
|
82
|
+
await test.clone().forge(['foo']);
|
|
83
|
+
await test.clone().forge(['bar']);
|
|
84
|
+
await test.clone().forge([]);
|
|
85
|
+
expect(ran).toMatchInlineSnapshot(`
|
|
86
|
+
{
|
|
87
|
+
"bar": 2,
|
|
88
|
+
"foo": 2,
|
|
89
|
+
}
|
|
90
|
+
`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should run parent command if no subcommand is given', () => {
|
|
94
|
+
const ran = { foo: false, bar: false };
|
|
95
|
+
cli('test')
|
|
96
|
+
.command('$0', {
|
|
97
|
+
builder: (argv) => argv.option('bar', { type: 'string' }),
|
|
98
|
+
handler: () => {
|
|
99
|
+
ran.foo = true;
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.command('bar', {
|
|
103
|
+
builder: (argv) => argv.option('baz', { type: 'string' }),
|
|
104
|
+
handler: () => {
|
|
105
|
+
ran.bar = true;
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
.forge(['something']);
|
|
109
|
+
expect(ran.foo).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should be able to run subcommands', () => {
|
|
113
|
+
const ran = { format: false, formatCheck: false };
|
|
114
|
+
cli('test')
|
|
115
|
+
.option('baz', { type: 'string' })
|
|
116
|
+
.command('format', {
|
|
117
|
+
builder: (argv) =>
|
|
118
|
+
argv.option('bar', { type: 'string' }).command('check', {
|
|
119
|
+
builder: (argv) => argv.option('foo', { type: 'string' }),
|
|
120
|
+
handler: (argv) => {
|
|
121
|
+
// Checks that all parent command options are available on
|
|
122
|
+
// subcommands.
|
|
123
|
+
argv.bar;
|
|
124
|
+
argv.foo;
|
|
125
|
+
argv.baz;
|
|
126
|
+
ran.formatCheck = true;
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
handler: () => {
|
|
130
|
+
ran.format = true;
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
.forge(['format', 'check']);
|
|
134
|
+
expect(ran).toMatchInlineSnapshot(`
|
|
135
|
+
{
|
|
136
|
+
"format": false,
|
|
137
|
+
"formatCheck": true,
|
|
138
|
+
}
|
|
139
|
+
`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should generate help text', async () => {
|
|
143
|
+
const { getOutput } = mockConsoleLog();
|
|
144
|
+
await cli('test')
|
|
145
|
+
.option('baz', { type: 'string', choices: ['a', 'b'] })
|
|
146
|
+
.option('qux', {
|
|
147
|
+
type: 'string',
|
|
148
|
+
required: true,
|
|
149
|
+
})
|
|
150
|
+
.option('quux', {
|
|
151
|
+
type: 'string',
|
|
152
|
+
default: 'a',
|
|
153
|
+
})
|
|
154
|
+
.command('format', {
|
|
155
|
+
builder: (argv) =>
|
|
156
|
+
argv.option('bar', { type: 'string' }).command('check', {
|
|
157
|
+
builder: (argv) => argv.option('foo', { type: 'string' }),
|
|
158
|
+
handler: () => {
|
|
159
|
+
// No side effect needed.
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
handler: () => {
|
|
163
|
+
// Not invoked.
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
.forge(['--help']);
|
|
167
|
+
expect(getOutput()).toMatchInlineSnapshot(`
|
|
168
|
+
"Usage: test
|
|
169
|
+
|
|
170
|
+
Commands:
|
|
171
|
+
format
|
|
172
|
+
|
|
173
|
+
Options:
|
|
174
|
+
--help - Show help for the current command
|
|
175
|
+
--version - Show the version number for the CLI
|
|
176
|
+
--baz - (a, b)
|
|
177
|
+
--qux - [required]
|
|
178
|
+
--quux - [default: a]
|
|
179
|
+
|
|
180
|
+
Run \`test [command] --help\` for more information on a command"
|
|
181
|
+
`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should generate help text for subcommands', async () => {
|
|
185
|
+
const { getOutput } = mockConsoleLog();
|
|
186
|
+
await cli('test')
|
|
187
|
+
.option('baz', { type: 'string' })
|
|
188
|
+
.command('format', {
|
|
189
|
+
builder: (argv) =>
|
|
190
|
+
argv.option('bar', { type: 'string' }).command('check', {
|
|
191
|
+
builder: (argv) => {
|
|
192
|
+
return argv.option('foo', { type: 'string' });
|
|
193
|
+
},
|
|
194
|
+
handler: () => {
|
|
195
|
+
// No side effect needed.
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
handler: () => {
|
|
199
|
+
// Not invoked.
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
.forge(['format', 'check', '--help']);
|
|
203
|
+
expect(getOutput()).toMatchInlineSnapshot(`
|
|
204
|
+
"Usage: test format check
|
|
205
|
+
|
|
206
|
+
Options:
|
|
207
|
+
--help - Show help for the current command
|
|
208
|
+
--version - Show the version number for the CLI
|
|
209
|
+
--baz
|
|
210
|
+
--bar
|
|
211
|
+
--foo "
|
|
212
|
+
`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should print help if command throws', async () => {
|
|
216
|
+
const { getOutput } = mockConsoleLog();
|
|
217
|
+
await cli('test')
|
|
218
|
+
.command('foo', {
|
|
219
|
+
builder: (argv) => argv.option('bar', { type: 'string' }),
|
|
220
|
+
handler: () => {
|
|
221
|
+
throw new Error('test');
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
.forge(['foo']);
|
|
225
|
+
expect(getOutput()).toMatchInlineSnapshot(`
|
|
226
|
+
"Usage: test foo
|
|
227
|
+
|
|
228
|
+
Options:
|
|
229
|
+
--help - Show help for the current command
|
|
230
|
+
--version - Show the version number for the CLI
|
|
231
|
+
--bar "
|
|
232
|
+
`);
|
|
233
|
+
expect(process.exitCode).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should support async handlers', async () => {
|
|
237
|
+
let ran = false;
|
|
238
|
+
await cli('test')
|
|
239
|
+
.command('foo', {
|
|
240
|
+
builder: (argv) => argv.option('bar', { type: 'string' }),
|
|
241
|
+
handler: async () => {
|
|
242
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
243
|
+
ran = true;
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
.forge(['foo']);
|
|
247
|
+
expect(ran).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should support requiring subcommands', async () => {
|
|
251
|
+
let ran = false;
|
|
252
|
+
await cli('test')
|
|
253
|
+
.command('foo', {
|
|
254
|
+
builder: (argv) => argv.option('bar', { type: 'string' }),
|
|
255
|
+
handler: () => {
|
|
256
|
+
ran = true;
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
.command('$0', {
|
|
260
|
+
handler: () => {
|
|
261
|
+
ran = true;
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
.demandCommand()
|
|
265
|
+
.forge([]);
|
|
266
|
+
|
|
267
|
+
// With `demandCommand`, no command should be ran. Instead, the help text should be printed.
|
|
268
|
+
expect(ran).toBe(false);
|
|
269
|
+
expect(process.exitCode).toBe(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should support displaying grouped options in help', async () => {
|
|
273
|
+
const { getOutput } = mockConsoleLog();
|
|
274
|
+
await cli('test')
|
|
275
|
+
.option('foo', { type: 'string', group: 'Basic' })
|
|
276
|
+
.option('baz', { type: 'string' })
|
|
277
|
+
.option('qux', { type: 'string' })
|
|
278
|
+
.option('quux', { type: 'string' })
|
|
279
|
+
.group('Advanced', ['baz', 'qux'])
|
|
280
|
+
.forge(['--help']);
|
|
281
|
+
|
|
282
|
+
expect(getOutput()).toMatchInlineSnapshot(`
|
|
283
|
+
"Usage: test
|
|
284
|
+
|
|
285
|
+
Options:
|
|
286
|
+
--help - Show help for the current command
|
|
287
|
+
--version - Show the version number for the CLI
|
|
288
|
+
--quux
|
|
289
|
+
|
|
290
|
+
Advanced:
|
|
291
|
+
--baz
|
|
292
|
+
--qux
|
|
293
|
+
|
|
294
|
+
Basic:
|
|
295
|
+
--foo"
|
|
296
|
+
`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should run middlewares before command handlers', async () => {
|
|
300
|
+
const executionOrder: string[] = [];
|
|
301
|
+
await cli('test')
|
|
302
|
+
.middleware((args) => {
|
|
303
|
+
executionOrder.push('middleware1');
|
|
304
|
+
return args;
|
|
305
|
+
})
|
|
306
|
+
.middleware((args) => {
|
|
307
|
+
executionOrder.push('middleware2');
|
|
308
|
+
return args;
|
|
309
|
+
})
|
|
310
|
+
.command('foo', {
|
|
311
|
+
builder: (argv) =>
|
|
312
|
+
argv.middleware((args) => {
|
|
313
|
+
executionOrder.push('middleware3');
|
|
314
|
+
return args;
|
|
315
|
+
}),
|
|
316
|
+
handler: () => {
|
|
317
|
+
executionOrder.push('foo handler');
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
.command('bar', {
|
|
321
|
+
builder: (argv) =>
|
|
322
|
+
argv.middleware((args) => {
|
|
323
|
+
executionOrder.push('middleware4');
|
|
324
|
+
return args;
|
|
325
|
+
}),
|
|
326
|
+
handler: () => {
|
|
327
|
+
executionOrder.push('bar handler');
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
.forge(['foo']);
|
|
331
|
+
|
|
332
|
+
expect(executionOrder).toEqual([
|
|
333
|
+
// middlewares first, only for the command being executed
|
|
334
|
+
'middleware1',
|
|
335
|
+
'middleware2',
|
|
336
|
+
'middleware3',
|
|
337
|
+
// then the handler
|
|
338
|
+
'foo handler',
|
|
339
|
+
|
|
340
|
+
// NO:
|
|
341
|
+
// - middlewares for the 'bar' command
|
|
342
|
+
// - 'bar' handler
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
345
|
+
});
|