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,529 @@
1
+ import {
2
+ ArgvParser,
3
+ EnvOptionConfig,
4
+ OptionConfig,
5
+ ParsedArgs,
6
+ ValidationFailedError,
7
+ fromCamelOrDashedCaseToConstCase,
8
+ hideBin,
9
+ type ConfigurationFiles,
10
+ } from '@cli-forge/parser';
11
+ import { getCallingFile, getParentPackageJson } from './utils';
12
+ import { INTERACTIVE_SHELL, InteractiveShell } from './interactive-shell';
13
+ import { CLI, CLICommandOptions, Command, ErrorHandler } from './public-api';
14
+ import { readOptionGroupsForCLI } from './cli-option-groups';
15
+ import { formatHelp } from './format-help';
16
+
17
+ /**
18
+ * The base class for a CLI application. This class is used to define the structure of the CLI.
19
+ *
20
+ * {@link cli} is provided as a small helper function to create a new CLI instance.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { cli } from 'cli-forge';
25
+ *
26
+ * cli('basic-cli').command('hello', {
27
+ * builder: (args) =>
28
+ * args.option('name', {
29
+ * type: 'string',
30
+ * }),
31
+ * handler: (args) => {
32
+ * console.log(`Hello, ${args.name}!`);
33
+ * }).forge();
34
+ * ```
35
+ */
36
+ export class InternalCLI<TArgs extends ParsedArgs = ParsedArgs>
37
+ implements CLI<TArgs>
38
+ {
39
+ /**
40
+ * For internal use only. Stick to properties available on {@link CLI}.
41
+ */
42
+ registeredCommands: Record<string, InternalCLI<any>> = {};
43
+
44
+ /**
45
+ * For internal use only. Stick to properties available on {@link CLI}.
46
+ */
47
+ commandChain: string[] = [];
48
+
49
+ private requiresCommand: 'IMPLICIT' | 'EXPLICIT' | false = 'IMPLICIT';
50
+
51
+ private _configuration?: CLICommandOptions<any, any>;
52
+
53
+ private _versionOverride?: string;
54
+
55
+ private registeredErrorHandlers: Array<ErrorHandler> = [
56
+ (e: unknown, actions) => {
57
+ if (e instanceof ValidationFailedError) {
58
+ this.printHelp();
59
+ console.log();
60
+ console.log(e.message);
61
+ console.log(e.errors.map((e) => ` - ${e.message}`).join('\n'));
62
+ actions.exit(1);
63
+ }
64
+ },
65
+ ];
66
+
67
+ private registeredMiddleware: Array<(args: TArgs) => void> = [];
68
+
69
+ /**
70
+ * A list of option groups that have been registered with the CLI. Grouped Options are displayed together in the help text.
71
+ *
72
+ * For internal use only. Stick to properties available on {@link CLI}.
73
+ */
74
+ registeredOptionGroups: Array<{
75
+ label: string;
76
+ sortOrder: number;
77
+ keys: Array<keyof TArgs>;
78
+ }> = [];
79
+
80
+ getGroupedOptions() {
81
+ return readOptionGroupsForCLI(this);
82
+ }
83
+
84
+ get configuration() {
85
+ return this._configuration;
86
+ }
87
+
88
+ private set configuration(value: CLICommandOptions<any, any> | undefined) {
89
+ this._configuration = value;
90
+ }
91
+
92
+ /**
93
+ * The parser used to parse the arguments for the current command.
94
+ *
95
+ * Meant for internal use only. Stick to properties available on {@link CLI}.
96
+ *
97
+ * If you need this kind of info, please open an issue on the GitHub repo with
98
+ * your use case.
99
+ */
100
+ parser = new ArgvParser<TArgs>({
101
+ unmatchedParser: (arg) => {
102
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
103
+ let currentCommand: InternalCLI<any> = this;
104
+ for (const command of this.commandChain) {
105
+ currentCommand = currentCommand.registeredCommands[command];
106
+ }
107
+ const command = currentCommand.registeredCommands[arg];
108
+ if (command && command.configuration) {
109
+ command.parser = this.parser;
110
+ command.configuration.builder?.(command);
111
+ this.commandChain.push(arg);
112
+ return true;
113
+ }
114
+ return false;
115
+ },
116
+ })
117
+ .option('help', {
118
+ type: 'boolean',
119
+ alias: ['h'],
120
+ description: 'Show help for the current command',
121
+ })
122
+ .option('version', {
123
+ type: 'boolean',
124
+ description: 'Show the version number for the CLI',
125
+ });
126
+
127
+ /**
128
+ * @param name What should the name of the cli command be?
129
+ * @param configuration Configuration for the current CLI command.
130
+ */
131
+ constructor(
132
+ public name: string,
133
+ rootCommandConfiguration?: CLICommandOptions<TArgs>
134
+ ) {
135
+ if (rootCommandConfiguration) {
136
+ this.withRootCommandConfiguration(rootCommandConfiguration);
137
+ } else {
138
+ this.requiresCommand = 'IMPLICIT';
139
+ }
140
+ }
141
+
142
+ withRootCommandConfiguration<TRootCommandArgs extends TArgs>(
143
+ configuration: CLICommandOptions<TArgs, TRootCommandArgs>
144
+ ): InternalCLI<TArgs> {
145
+ this.configuration = configuration;
146
+ this.requiresCommand = false;
147
+ return this;
148
+ }
149
+
150
+ command<TCommandArgs extends TArgs>(
151
+ keyOrCommand: string | Command<TArgs, TCommandArgs>,
152
+ options?: CLICommandOptions<TArgs, TCommandArgs>
153
+ ): CLI<TArgs> {
154
+ if (typeof keyOrCommand === 'string') {
155
+ const key = keyOrCommand;
156
+ if (!options) {
157
+ throw new Error(
158
+ 'options must be provided when calling `command` with a string'
159
+ );
160
+ }
161
+ if (key === '$0' || options.alias?.includes('$0')) {
162
+ this.withRootCommandConfiguration({
163
+ ...this._configuration,
164
+ builder: options.builder as any,
165
+ handler: options.handler as any,
166
+ description: options.description,
167
+ });
168
+ }
169
+ const cmd = new InternalCLI<TArgs>(key).withRootCommandConfiguration(
170
+ options
171
+ );
172
+ this.registeredCommands[key] = cmd;
173
+ if (options.alias) {
174
+ for (const alias of options.alias) {
175
+ this.registeredCommands[alias] = cmd;
176
+ }
177
+ }
178
+ } else if (keyOrCommand instanceof InternalCLI) {
179
+ const cmd = keyOrCommand;
180
+ this.registeredCommands[cmd.name] = cmd;
181
+ if (cmd.configuration?.alias) {
182
+ for (const alias of cmd.configuration.alias) {
183
+ this.registeredCommands[alias] = cmd;
184
+ }
185
+ }
186
+ } else {
187
+ const { name, ...configuration } = keyOrCommand as {
188
+ name: string;
189
+ } & CLICommandOptions<TArgs, TCommandArgs>;
190
+ this.command<TCommandArgs>(name, configuration);
191
+ }
192
+ return this;
193
+ }
194
+
195
+ commands(...a0: Command[] | Command[][]): CLI<TArgs> {
196
+ const commands = a0.flat();
197
+ for (const val of commands) {
198
+ if (val instanceof InternalCLI) {
199
+ this.registeredCommands[val.name] = val;
200
+ // Include any options that were defined via cli(...).option() instead of via builder
201
+ this.parser.augment(val.parser);
202
+ } else {
203
+ const { name, ...configuration } = val as {
204
+ name: string;
205
+ } & CLICommandOptions<any, any>;
206
+ this.command(name, configuration);
207
+ }
208
+ }
209
+ return this;
210
+ }
211
+
212
+ option<
213
+ TOption extends string,
214
+ const TOptionConfig extends OptionConfig<any, any, any, any>
215
+ >(name: TOption, config: TOptionConfig) {
216
+ this.parser.option(name, config);
217
+ // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
218
+ return this as any;
219
+ }
220
+
221
+ positional<
222
+ TOption extends string,
223
+ const TOptionConfig extends OptionConfig<any, any, any, any>
224
+ >(name: TOption, config: TOptionConfig) {
225
+ this.parser.positional(name, config);
226
+ // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
227
+ return this as any;
228
+ }
229
+
230
+ conflicts(...args: [string, string, ...string[]]): CLI<TArgs> {
231
+ this.parser.conflicts(...args);
232
+ return this;
233
+ }
234
+
235
+ implies(option: string, ...impliedOptions: string[]): CLI<TArgs> {
236
+ this.parser.implies(option, ...impliedOptions);
237
+ return this;
238
+ }
239
+
240
+ env(
241
+ a0: string | EnvOptionConfig | undefined = fromCamelOrDashedCaseToConstCase(
242
+ this.name
243
+ )
244
+ ) {
245
+ if (typeof a0 === 'string') {
246
+ this.parser.env(a0);
247
+ } else {
248
+ a0.prefix ??= fromCamelOrDashedCaseToConstCase(this.name);
249
+ this.parser.env(a0);
250
+ }
251
+ return this;
252
+ }
253
+
254
+ demandCommand() {
255
+ this.requiresCommand = 'EXPLICIT';
256
+ return this;
257
+ }
258
+
259
+ usage(usageText: string) {
260
+ this.configuration ??= {};
261
+ this.configuration.usage = usageText;
262
+ return this;
263
+ }
264
+
265
+ examples(...examples: string[]) {
266
+ this.configuration ??= {};
267
+ this.configuration.examples ??= [];
268
+ this.configuration.examples.push(...examples);
269
+ return this;
270
+ }
271
+
272
+ version(version?: string) {
273
+ this._versionOverride = version;
274
+ return this;
275
+ }
276
+
277
+ /**
278
+ * Gets help text for the current command as a string.
279
+ * @returns Help text for the current command.
280
+ */
281
+ formatHelp() {
282
+ return formatHelp(this);
283
+ }
284
+
285
+ /**
286
+ * Prints help text for the current command to the console.
287
+ */
288
+ printHelp() {
289
+ console.log(this.formatHelp());
290
+ }
291
+
292
+ middleware<TArgs2>(
293
+ callback: (args: TArgs) => TArgs2 | Promise<TArgs2>
294
+ ): CLI<TArgs2 extends void ? TArgs : TArgs & TArgs2> {
295
+ this.registeredMiddleware.push(callback);
296
+ // If middleware returns void, TArgs doesn't change...
297
+ // If it returns something, we need to merge it into TArgs...
298
+ // that's not here though, its where we apply the middleware results.
299
+ return this as any;
300
+ }
301
+
302
+ /**
303
+ * Runs the current command.
304
+ * @param cmd The command to run.
305
+ * @param args The arguments to pass to the command.
306
+ */
307
+ async runCommand<T extends ParsedArgs>(args: T, originalArgV: string[]) {
308
+ const middlewares: Array<(args: any) => void> = [
309
+ ...this.registeredMiddleware,
310
+ ];
311
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
312
+ let cmd: InternalCLI<any> = this;
313
+ for (const command of this.commandChain) {
314
+ cmd = cmd.registeredCommands[command];
315
+ middlewares.push(...cmd.registeredMiddleware);
316
+ }
317
+ try {
318
+ if (cmd.requiresCommand) {
319
+ throw new Error(
320
+ `${[this.name, ...this.commandChain].join(' ')} requires a command`
321
+ );
322
+ }
323
+ if (cmd.configuration?.handler) {
324
+ for (const middleware of middlewares) {
325
+ const middlewareResult = await middleware(args);
326
+ if (
327
+ middlewareResult !== void 0 &&
328
+ typeof middlewareResult === 'object'
329
+ ) {
330
+ args = middlewareResult as T;
331
+ }
332
+ }
333
+ await cmd.configuration.handler(args, {
334
+ command: cmd,
335
+ });
336
+ } else {
337
+ // We can treat a command as a subshell if it has subcommands
338
+ if (Object.keys(cmd.registeredCommands).length > 0) {
339
+ if (!process.stdout.isTTY) {
340
+ // If we're not in a TTY, we can't run an interactive shell...
341
+ // Maybe we should warn here?
342
+ } else if (!INTERACTIVE_SHELL) {
343
+ const tui = new InteractiveShell(this, {
344
+ prependArgs: originalArgV,
345
+ });
346
+ await new Promise<void>((res) => {
347
+ ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((s) =>
348
+ process.on(s, () => {
349
+ tui.close();
350
+ res();
351
+ })
352
+ );
353
+ });
354
+ }
355
+ }
356
+ // No subcommands so subshell doesn't make sense
357
+ // No handler, so nothing to run
358
+ else {
359
+ throw new Error(
360
+ `${[this.name, ...this.commandChain].join(' ')} is not implemented.`
361
+ );
362
+ }
363
+ }
364
+ } catch (e) {
365
+ process.exitCode = 1;
366
+ console.error(e);
367
+ this.printHelp();
368
+ }
369
+ }
370
+
371
+ enableInteractiveShell() {
372
+ if (this.requiresCommand === 'EXPLICIT') {
373
+ throw new Error(
374
+ 'Interactive shell is not supported for commands that require a command.'
375
+ );
376
+ } else if (process.stdout.isTTY) {
377
+ this.requiresCommand = false;
378
+ }
379
+ return this;
380
+ }
381
+
382
+ private versionHandler() {
383
+ if (this._versionOverride) {
384
+ console.log(this._versionOverride);
385
+ return;
386
+ }
387
+ let mainFile = require?.main?.filename;
388
+ mainFile ??= getCallingFile();
389
+ if (!mainFile) {
390
+ console.log('unknown');
391
+ return;
392
+ }
393
+ const packageJson = getParentPackageJson(mainFile);
394
+ console.log(packageJson.version ?? 'unknown');
395
+ }
396
+
397
+ private async withErrorHandlers<T>(cb: () => T): Promise<Awaited<T>> {
398
+ try {
399
+ return await cb();
400
+ } catch (e) {
401
+ for (const handler of this.registeredErrorHandlers) {
402
+ try {
403
+ handler(e, {
404
+ exit: (c) => {
405
+ process.exit(c);
406
+ },
407
+ });
408
+ // Error was handled, no need to continue
409
+ break;
410
+ } catch {
411
+ // Error was not handled, continue to the next handler
412
+ }
413
+ }
414
+ throw e;
415
+ }
416
+ }
417
+
418
+ errorHandler(handler: ErrorHandler) {
419
+ this.registeredErrorHandlers.unshift(handler);
420
+ return this;
421
+ }
422
+
423
+ group(
424
+ labelOrConfigObject:
425
+ | string
426
+ | { label: string; keys: (keyof TArgs)[]; sortOrder: number },
427
+ keys?: (keyof TArgs)[]
428
+ ): CLI<TArgs> {
429
+ const config =
430
+ typeof labelOrConfigObject === 'object'
431
+ ? labelOrConfigObject
432
+ : {
433
+ label: labelOrConfigObject,
434
+ keys: keys as (keyof TArgs)[],
435
+ sortOrder: Object.keys(this.registeredOptionGroups).length,
436
+ };
437
+
438
+ if (!config.keys) {
439
+ throw new Error('keys must be provided when calling `group`.');
440
+ }
441
+
442
+ this.registeredOptionGroups.push(config);
443
+ return this;
444
+ }
445
+
446
+ config(
447
+ provider: ConfigurationFiles.ConfigurationProvider<TArgs>
448
+ ): CLI<TArgs> {
449
+ this.parser.config(
450
+ provider as ConfigurationFiles.ConfigurationProvider<any>
451
+ );
452
+ return this;
453
+ }
454
+
455
+ /**
456
+ * Parses argv and executes the CLI
457
+ * @param args argv. Defaults to process.argv.slice(2)
458
+ * @returns Promise that resolves when the handler completes.
459
+ */
460
+ forge = (args: string[] = hideBin(process.argv)) =>
461
+ this.withErrorHandlers(async () => {
462
+ // Parsing the args does two things:
463
+ // - builds argv to pass to handler
464
+ // - fills the command chain + registers commands
465
+ let argv: TArgs & { help?: boolean; version?: boolean };
466
+ let validationFailedError: ValidationFailedError<TArgs> | undefined;
467
+ try {
468
+ argv = this.parser.parse(args);
469
+ } catch (e) {
470
+ if (e instanceof ValidationFailedError) {
471
+ argv = e.partialArgV as TArgs;
472
+ validationFailedError = e;
473
+ } else {
474
+ throw e;
475
+ }
476
+ }
477
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
478
+ let currentCommand: InternalCLI<any> = this;
479
+ for (const command of this.commandChain) {
480
+ currentCommand = currentCommand.registeredCommands[command];
481
+ }
482
+
483
+ if (argv.version) {
484
+ this.versionHandler();
485
+ return argv;
486
+ }
487
+
488
+ if (argv.help) {
489
+ this.printHelp();
490
+ return argv;
491
+ } else if (validationFailedError) {
492
+ throw validationFailedError;
493
+ }
494
+
495
+ const finalArgV =
496
+ this.commandChain.length === 0 && this.configuration?.builder
497
+ ? (
498
+ this.configuration.builder?.(this as any) as InternalCLI<TArgs>
499
+ ).parser.parse(args)
500
+ : argv;
501
+
502
+ await this.runCommand(finalArgV, args);
503
+ return finalArgV as TArgs;
504
+ });
505
+
506
+ getParser() {
507
+ return this.parser.asReadonly();
508
+ }
509
+
510
+ getSubcommands() {
511
+ return this.registeredCommands as Readonly<Record<string, InternalCLI>>;
512
+ }
513
+
514
+ clone() {
515
+ const clone = new InternalCLI<TArgs>(this.name);
516
+ clone.parser = this.parser.clone(clone.parser.options) as any;
517
+ if (this.configuration) {
518
+ clone.withRootCommandConfiguration(this.configuration);
519
+ }
520
+ clone.registeredCommands = {};
521
+ for (const command in this.registeredCommands ?? {}) {
522
+ clone.command(this.registeredCommands[command].clone());
523
+ // this.registeredCommands[command].clone();
524
+ }
525
+ clone.commandChain = [...this.commandChain];
526
+ clone.requiresCommand = this.requiresCommand;
527
+ return clone;
528
+ }
529
+ }