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.
Files changed (97) hide show
  1. package/.eslintrc.json +35 -0
  2. package/LICENSE.md +5 -0
  3. package/cli.js +9 -0
  4. package/{bin → dist/bin}/cli.d.ts +1 -1
  5. package/{bin → dist/bin}/cli.js +2 -2
  6. package/dist/bin/cli.js.map +1 -0
  7. package/{bin → dist/bin}/commands/generate-documentation.d.ts +1 -1
  8. package/{bin → dist/bin}/commands/generate-documentation.js +58 -11
  9. package/dist/bin/commands/generate-documentation.js.map +1 -0
  10. package/{bin → dist/bin}/commands/init.d.ts +1 -1
  11. package/{bin → dist/bin}/commands/init.js +11 -6
  12. package/dist/bin/commands/init.js.map +1 -0
  13. package/dist/bin/utils/fs.js.map +1 -0
  14. package/{src → dist}/index.d.ts +1 -1
  15. package/dist/index.js.map +1 -0
  16. package/dist/lib/cli-option-groups.js.map +1 -0
  17. package/dist/lib/composable-builder.js.map +1 -0
  18. package/dist/lib/configuration-providers.js.map +1 -0
  19. package/{src → dist}/lib/documentation.d.ts +3 -3
  20. package/dist/lib/documentation.js.map +1 -0
  21. package/dist/lib/format-help.js.map +1 -0
  22. package/{src → dist}/lib/interactive-shell.d.ts +1 -1
  23. package/{src → dist}/lib/interactive-shell.js +6 -3
  24. package/dist/lib/interactive-shell.js.map +1 -0
  25. package/{src → dist}/lib/internal-cli.d.ts +3 -3
  26. package/{src → dist}/lib/internal-cli.js +8 -1
  27. package/dist/lib/internal-cli.js.map +1 -0
  28. package/{src → dist}/lib/public-api.d.ts +41 -6
  29. package/dist/lib/public-api.js.map +1 -0
  30. package/dist/lib/test-harness.js.map +1 -0
  31. package/dist/lib/utils.js.map +1 -0
  32. package/dist/middleware/zod.d.ts +4 -0
  33. package/dist/middleware/zod.js +18 -0
  34. package/dist/middleware/zod.js.map +1 -0
  35. package/dist/middleware.d.ts +1 -0
  36. package/dist/middleware.js +5 -0
  37. package/dist/middleware.js.map +1 -0
  38. package/package.json +28 -10
  39. package/project.json +7 -0
  40. package/src/bin/cli.ts +17 -0
  41. package/src/bin/commands/generate-documentation.ts +403 -0
  42. package/src/bin/commands/init.ts +320 -0
  43. package/src/bin/utils/fs.ts +11 -0
  44. package/src/index.ts +7 -0
  45. package/src/lib/cli-option-groups.ts +69 -0
  46. package/src/lib/composable-builder.ts +9 -0
  47. package/src/lib/configuration-providers.ts +36 -0
  48. package/src/lib/documentation.spec.ts +156 -0
  49. package/src/lib/documentation.ts +107 -0
  50. package/src/lib/format-help.ts +149 -0
  51. package/src/lib/interactive-shell.ts +115 -0
  52. package/src/lib/internal-cli.spec.ts +345 -0
  53. package/src/lib/internal-cli.ts +529 -0
  54. package/src/lib/public-api.ts +449 -0
  55. package/src/lib/test-harness.spec.ts +29 -0
  56. package/src/lib/test-harness.ts +69 -0
  57. package/src/lib/utils.spec.ts +25 -0
  58. package/src/lib/utils.ts +144 -0
  59. package/src/middleware/zod.ts +21 -0
  60. package/src/middleware.ts +1 -0
  61. package/tsconfig.json +23 -0
  62. package/tsconfig.lib.json +20 -0
  63. package/tsconfig.lib.json.tsbuildinfo +1 -0
  64. package/tsconfig.spec.json +26 -0
  65. package/vitest.config.mts +18 -0
  66. package/bin/cli.js.map +0 -1
  67. package/bin/commands/generate-documentation.js.map +0 -1
  68. package/bin/commands/init.js.map +0 -1
  69. package/bin/utils/fs.js.map +0 -1
  70. package/src/index.js.map +0 -1
  71. package/src/lib/cli-option-groups.js.map +0 -1
  72. package/src/lib/composable-builder.js.map +0 -1
  73. package/src/lib/configuration-providers.js.map +0 -1
  74. package/src/lib/documentation.js.map +0 -1
  75. package/src/lib/format-help.js.map +0 -1
  76. package/src/lib/interactive-shell.js.map +0 -1
  77. package/src/lib/internal-cli.js.map +0 -1
  78. package/src/lib/public-api.js.map +0 -1
  79. package/src/lib/test-harness.js.map +0 -1
  80. package/src/lib/utils.js.map +0 -1
  81. /package/{bin → dist/bin}/utils/fs.d.ts +0 -0
  82. /package/{bin → dist/bin}/utils/fs.js +0 -0
  83. /package/{src → dist}/index.js +0 -0
  84. /package/{src → dist}/lib/cli-option-groups.d.ts +0 -0
  85. /package/{src → dist}/lib/cli-option-groups.js +0 -0
  86. /package/{src → dist}/lib/composable-builder.d.ts +0 -0
  87. /package/{src → dist}/lib/composable-builder.js +0 -0
  88. /package/{src → dist}/lib/configuration-providers.d.ts +0 -0
  89. /package/{src → dist}/lib/configuration-providers.js +0 -0
  90. /package/{src → dist}/lib/documentation.js +0 -0
  91. /package/{src → dist}/lib/format-help.d.ts +0 -0
  92. /package/{src → dist}/lib/format-help.js +0 -0
  93. /package/{src → dist}/lib/public-api.js +0 -0
  94. /package/{src → dist}/lib/test-harness.d.ts +0 -0
  95. /package/{src → dist}/lib/test-harness.js +0 -0
  96. /package/{src → dist}/lib/utils.d.ts +0 -0
  97. /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
+ });