@travetto/cli 3.4.5 → 3.4.7

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/README.md CHANGED
@@ -59,7 +59,7 @@ This module also has a tight integration with the [VSCode plugin](https://market
59
59
  At it's heart, a cli command is the contract defined by what flags, and what arguments the command supports. Within the framework this requires three criteria to be met:
60
60
  * The file must be located in the `support/` folder, and have a name that matches `cli.*.ts`
61
61
  * The file must be a class that has a main method
62
- * The class must use the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L15) decorator
62
+ * The class must use the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L16) decorator
63
63
 
64
64
  **Code: Basic Command**
65
65
  ```typescript
@@ -93,7 +93,7 @@ Examples of mappings:
93
93
  The pattern is that underscores(_) translate to colons (:), and the `cli.` prefix, and `.ts` suffix are dropped.
94
94
 
95
95
  ## Binding Flags
96
- [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L15) is a wrapper for [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14), and so every class that uses the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L15) decorator is now a full [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14) class. The fields of the class represent the flags that are available to the command.
96
+ [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L16) is a wrapper for [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14), and so every class that uses the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L16) decorator is now a full [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14) class. The fields of the class represent the flags that are available to the command.
97
97
 
98
98
  **Code: Basic Command with Flag**
99
99
  ```typescript
@@ -130,7 +130,7 @@ $ trv basic:flag --loud
130
130
  HELLO
131
131
  ```
132
132
 
133
- The [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L15) supports the following data types for flags:
133
+ The [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L16) supports the following data types for flags:
134
134
  * Boolean values
135
135
  * Number values. The [@Integer](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L172), [@Float](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L178), [@Precision](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L166), [@Min](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L107) and [@Max](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L117) decorators help provide additional validation.
136
136
  * String values. [@MinLength](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L107), [@MaxLength](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L117), [@Match](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L99) and [@Enum](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L78) provide additional constraints
@@ -359,8 +359,36 @@ $ MESSAGE=CuStOm trv custom:env-arg 7
359
359
  CuStOm
360
360
  ```
361
361
 
362
+ ## Flag File Support
363
+ Sometimes its also convenient, especially with commands that support a variety of flags, to provide easy access to pre-defined sets of flags. Flag files represent a snapshot of command line arguments and flags, as defined in a file. When referenced, these inputs are essentially injected into the command line as if the user had typed them manually.
364
+
365
+ **Code: Example Flag File**
366
+ ```bash
367
+ --host localhost
368
+ --port 3306
369
+ --username app
370
+ ```
371
+
372
+ As you can see in this file, it provides easy access to predefine the host, port, and user flags.
373
+
374
+ **Code: Using a Flag File**
375
+ ```bash
376
+ npx trv call:db +=base --password <custom>
377
+ ```
378
+
379
+ The flag files can be included in one of a few ways:
380
+ * `+=<name>` - This translates into $`<mod>/support/<name>.flags`, which is a convenient shorthand.
381
+ * `+=<mod>/path/file.flags` - This is a path-related file that will be resolved from the module's location.
382
+ * `+=/path/file.flags` - This is an absolute path that will be read from the root of the file system.
383
+ Ultimately, after resolution, the content of these files will be injected inline within the location.
384
+
385
+ **Code: Final arguments after Flag File resolution**
386
+ ```bash
387
+ npx trv call:db --host localhost --port 3306 --username app --password <custom>
388
+ ```
389
+
362
390
  ## VSCode Integration
363
- By default, cli commands do not expose themselves to the VSCode extension, as the majority of them are not intended for that sort of operation. [RESTful API](https://github.com/travetto/travetto/tree/main/module/rest#readme "Declarative api for RESTful APIs with support for the dependency injection module.") does expose a cli target `run:rest` that will show up, to help run/debug a rest application. Any command can mark itself as being a run target, and will be eligible for running from within the [VSCode plugin](https://marketplace.visualstudio.com/items?itemName=arcsine.travetto-plugin). This is achieved by setting the `runTarget` field on the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L15) decorator. This means the target will be visible within the editor tooling.
391
+ By default, cli commands do not expose themselves to the VSCode extension, as the majority of them are not intended for that sort of operation. [RESTful API](https://github.com/travetto/travetto/tree/main/module/rest#readme "Declarative api for RESTful APIs with support for the dependency injection module.") does expose a cli target `run:rest` that will show up, to help run/debug a rest application. Any command can mark itself as being a run target, and will be eligible for running from within the [VSCode plugin](https://marketplace.visualstudio.com/items?itemName=arcsine.travetto-plugin). This is achieved by setting the `runTarget` field on the [@CliCommand](https://github.com/travetto/travetto/tree/main/module/cli/src/decorators.ts#L16) decorator. This means the target will be visible within the editor tooling.
364
392
 
365
393
  **Code: Simple Run Target**
366
394
  ```typescript
@@ -382,19 +410,23 @@ export class RunCommand {
382
410
 
383
411
  **Code: Anatomy of a Command**
384
412
  ```typescript
385
- export interface CliCommandShape {
413
+ export interface CliCommandShape<T extends unknown[] = unknown[]> {
386
414
  /**
387
415
  * Action target of the command
388
416
  */
389
- main(...args: unknown[]): OrProm<RunResponse>;
417
+ main(...args: T): OrProm<RunResponse>;
390
418
  /**
391
- * Setup environment before command runs
419
+ * Run before main runs
392
420
  */
393
- envInit?(): OrProm<EnvInit>;
421
+ preMain?(): OrProm<void>;
394
422
  /**
395
423
  * Extra help
396
424
  */
397
425
  help?(): OrProm<string[]>;
426
+ /**
427
+ * Run before help is displayed
428
+ */
429
+ preHelp?(): OrProm<void>;
398
430
  /**
399
431
  * Is the command active/eligible for usage
400
432
  */
@@ -402,15 +434,15 @@ export interface CliCommandShape {
402
434
  /**
403
435
  * Run before binding occurs
404
436
  */
405
- initialize?(): OrProm<void>;
437
+ preBind?(): OrProm<void>;
406
438
  /**
407
439
  * Run before validation occurs
408
440
  */
409
- finalize?(unknownArgs: string[]): OrProm<void>;
441
+ preValidate?(): OrProm<void>;
410
442
  /**
411
443
  * Validation method
412
444
  */
413
- validate?(...unknownArgs: unknown[]): OrProm<CliValidationError | CliValidationError[] | undefined>;
445
+ validate?(...args: T): OrProm<CliValidationError | CliValidationError[] | undefined>;
414
446
  }
415
447
  ```
416
448
 
@@ -420,7 +452,7 @@ If the goal is to run a more complex application, which may include depending on
420
452
  **Code: Simple Run Target**
421
453
  ```typescript
422
454
  import { DependencyRegistry } from '@travetto/di';
423
- import { CliCommand, CliUtil } from '@travetto/cli';
455
+ import { CliCommand, CliCommandShape, CliUtil } from '@travetto/cli';
424
456
 
425
457
  import { ServerHandle } from '../src/types';
426
458
 
@@ -428,7 +460,7 @@ import { ServerHandle } from '../src/types';
428
460
  * Run a rest server as an application
429
461
  */
430
462
  @CliCommand({ runTarget: true, addModule: true, addEnv: true })
431
- export class RunRestCommand {
463
+ export class RunRestCommand implements CliCommandShape {
432
464
 
433
465
  /** IPC debug is enabled */
434
466
  debugIpc?: boolean;
@@ -439,8 +471,10 @@ export class RunRestCommand {
439
471
  /** Port to run on */
440
472
  port?: number;
441
473
 
442
- envInit(): Record<string, string | number | boolean> {
443
- return this.port ? { REST_PORT: `${this.port}` } : {};
474
+ preMain(): void {
475
+ if (this.port) {
476
+ process.env.REST_PORT = `${this.port}`;
477
+ }
444
478
  }
445
479
 
446
480
  async main(): Promise<ServerHandle | void> {
@@ -479,7 +513,7 @@ A simple example of the validation can be found in the `doc` command:
479
513
 
480
514
  **Code: Simple Validation Example**
481
515
  ```typescript
482
- async validate(...args: unknown[]): Promise<CliValidationError | undefined> {
516
+ async validate(): Promise<CliValidationError | undefined> {
483
517
  const docFile = path.resolve(this.input);
484
518
  if (!(await fs.stat(docFile).catch(() => false))) {
485
519
  return { message: `input: ${this.input} does not exist`, source: 'flag' };
package/__index__.ts CHANGED
@@ -8,4 +8,5 @@ export * from './src/help';
8
8
  export * from './src/color';
9
9
  export * from './src/module';
10
10
  export * from './src/scm';
11
+ export * from './src/parse';
11
12
  export * from './src/util';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/cli",
3
- "version": "3.4.5",
3
+ "version": "3.4.7",
4
4
  "description": "CLI infrastructure for Travetto framework",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,7 +29,7 @@
29
29
  "directory": "module/cli"
30
30
  },
31
31
  "dependencies": {
32
- "@travetto/schema": "^3.4.3",
32
+ "@travetto/schema": "^3.4.4",
33
33
  "@travetto/terminal": "^3.4.0"
34
34
  },
35
35
  "travetto": {
package/src/decorators.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Class, ClassInstance, ConsoleManager, GlobalEnv, defineEnv } from '@travetto/base';
1
+ import { Class, ClassInstance, defineEnv } from '@travetto/base';
2
2
  import { RootIndex } from '@travetto/manifest';
3
3
  import { SchemaRegistry } from '@travetto/schema';
4
4
 
@@ -6,6 +6,7 @@ import { CliCommandShape, CliCommandShapeFields } from './types';
6
6
  import { CliCommandRegistry, CliCommandConfigOptions } from './registry';
7
7
  import { CliModuleUtil } from './module';
8
8
  import { CliUtil } from './util';
9
+ import { CliParseUtil } from './parse';
9
10
 
10
11
  /**
11
12
  * Decorator to register a CLI command
@@ -27,7 +28,6 @@ export function CliCommand(cfg: CliCommandConfigOptions = {}) {
27
28
  preMain: async (cmd) => {
28
29
  if (addEnv && 'env' in cmd && typeof cmd.env === 'string') {
29
30
  defineEnv({ envName: cmd.env });
30
- ConsoleManager.setDebug(GlobalEnv.debug, GlobalEnv.devMode);
31
31
  }
32
32
  }
33
33
  });
@@ -38,13 +38,14 @@ export function CliCommand(cfg: CliCommandConfigOptions = {}) {
38
38
  SchemaRegistry.registerPendingFieldConfig(target, 'env', String, {
39
39
  aliases: ['e'],
40
40
  description: 'Application environment',
41
+ default: 'dev',
41
42
  required: { active: false }
42
43
  });
43
44
  }
44
45
 
45
46
  if (addModule) {
46
47
  SchemaRegistry.registerPendingFieldConfig(target, 'module', String, {
47
- aliases: ['m', 'env.TRV_MODULE'],
48
+ aliases: ['m', CliParseUtil.toEnvField('TRV_MODULE')],
48
49
  description: 'Module to run for',
49
50
  required: { active: CliUtil.monoRoot }
50
51
  });
@@ -86,7 +87,7 @@ export function CliFlag(cfg: { name?: string, short?: string, desc?: string, fil
86
87
  aliases.push(cfg.short.startsWith('-') ? cfg.short : `-${cfg.short}`);
87
88
  }
88
89
  if (cfg.envVars) {
89
- aliases.push(...cfg.envVars.map(v => `env.${v}`));
90
+ aliases.push(...cfg.envVars.map(CliParseUtil.toEnvField));
90
91
  }
91
92
  if (typeof prop === 'string') {
92
93
  SchemaRegistry.registerPendingFieldFacet(target.constructor, prop, {
package/src/execute.ts CHANGED
@@ -1,53 +1,67 @@
1
1
  import { GlobalTerminal } from '@travetto/terminal';
2
- import { ConsoleManager, defineEnv, ShutdownManager, GlobalEnv } from '@travetto/base';
2
+ import { ConsoleManager, GlobalEnv } from '@travetto/base';
3
3
 
4
4
  import { HelpUtil } from './help';
5
- import { CliCommandShape } from './types';
5
+ import { CliCommandShape, RunResponse } from './types';
6
6
  import { CliCommandRegistry } from './registry';
7
7
  import { CliCommandSchemaUtil } from './schema';
8
8
  import { CliUnknownCommandError, CliValidationResultError } from './error';
9
+ import { CliParseUtil } from './parse';
10
+ import { CliUtil } from './util';
9
11
 
10
12
  /**
11
13
  * Execution manager
12
14
  */
13
15
  export class ExecutionManager {
14
16
 
15
- static async #envInit(cmd: CliCommandShape): Promise<void> {
16
- if (cmd.envInit) {
17
- defineEnv(await cmd.envInit());
18
- ConsoleManager.setDebug(GlobalEnv.debug, GlobalEnv.devMode);
19
- }
20
- }
21
-
22
- /**
23
- * Run help
24
- */
25
- static async help(cmd: CliCommandShape, args: string[]): Promise<void> {
26
- await cmd.initialize?.();
27
- await this.#envInit(cmd);
28
- console.log!(await HelpUtil.renderHelp(cmd));
29
- }
30
-
31
17
  /**
32
18
  * Run the given command object with the given arguments
33
19
  */
34
- static async command(cmd: CliCommandShape, args: string[]): Promise<void> {
35
- const known = await CliCommandSchemaUtil.bindAndValidateArgs(cmd, args);
36
- await this.#envInit(cmd);
20
+ static async #runCommand(cmd: CliCommandShape, args: string[]): Promise<RunResponse> {
21
+ const schema = await CliCommandSchemaUtil.getSchema(cmd);
22
+ const state = await CliParseUtil.parse(schema, args);
37
23
  const cfg = CliCommandRegistry.getConfig(cmd);
38
- await cfg?.preMain?.(cmd);
39
24
 
40
- const result = await cmd.main(...known);
25
+ CliParseUtil.setState(cmd, state);
26
+
27
+ await cmd.preBind?.();
28
+ const known = await CliCommandSchemaUtil.bindInput(cmd, state);
29
+
30
+ await cmd.preValidate?.();
31
+ await CliCommandSchemaUtil.validate(cmd, known);
41
32
 
42
- // Listen to result if non-empty
43
- if (result !== undefined && result !== null) {
44
- if ('close' in result) {
45
- ShutdownManager.onShutdown(result, result); // Tie shutdown into app close
33
+ await cfg.preMain?.(cmd);
34
+ await cmd.preMain?.();
35
+ ConsoleManager.setDebug(GlobalEnv.debug, GlobalEnv.devMode);
36
+ return cmd.main(...known);
37
+ }
38
+
39
+ /**
40
+ * On error, handle response
41
+ */
42
+ static async #onError(command: CliCommandShape | undefined, err: unknown): Promise<void> {
43
+ process.exitCode ||= 1; // Trigger error state
44
+ switch (true) {
45
+ case !(err instanceof Error): {
46
+ throw err;
47
+ }
48
+ case command && err instanceof CliValidationResultError: {
49
+ console.error!(await HelpUtil.renderValidationError(command, err));
50
+ console.error!(await HelpUtil.renderCommandHelp(command));
51
+ break;
52
+ }
53
+ case err instanceof CliUnknownCommandError: {
54
+ if (err.help) {
55
+ console.error!(err.help);
56
+ } else {
57
+ console.error!(err.defaultMessage, '\n');
58
+ console.error!(await HelpUtil.renderAllHelp(''));
59
+ }
60
+ break;
46
61
  }
47
- if ('wait' in result) {
48
- await result.wait(); // Wait for close signal
49
- } else if ('on' in result) {
50
- await new Promise<void>(res => result.on('close', res)); // Wait for callback
62
+ default: {
63
+ console.error!(err);
64
+ console.error!();
51
65
  }
52
66
  }
53
67
  }
@@ -59,38 +73,25 @@ export class ExecutionManager {
59
73
  static async run(argv: string[]): Promise<void> {
60
74
  await GlobalTerminal.init();
61
75
 
62
- const [, , cmd, ...args] = argv;
63
- if (!cmd || /^(-h|--help)$/.test(cmd)) {
64
- console.info!(await HelpUtil.renderHelp());
65
- } else {
66
- let command: CliCommandShape | undefined;
67
- try {
68
- // Load a single command
69
- command = (await CliCommandRegistry.getInstance(cmd, true));
70
- if (args.some(a => /^(-h|--help)$/.test(a))) {
71
- await this.help(command, args);
72
- } else {
73
- await this.command(command, args);
74
- }
75
- } catch (err) {
76
- process.exitCode ||= 1; // Trigger error state
77
- if (!(err instanceof Error)) {
78
- throw err;
79
- } else if (err instanceof CliValidationResultError) {
80
- console.error!(await HelpUtil.renderValidationError(command!, err));
81
- console.error!(await HelpUtil.renderHelp(command));
82
- } else if (err instanceof CliUnknownCommandError) {
83
- if (err.help) {
84
- console.error!(err.help);
85
- } else {
86
- console.error!(err.defaultMessage, '\n');
87
- console.error!(await HelpUtil.renderAllHelp(''));
88
- }
89
- } else {
90
- console.error!(err);
91
- console.error!();
92
- }
76
+ let command: CliCommandShape | undefined;
77
+ try {
78
+ const { cmd, args, help } = await CliParseUtil.getArgs(argv);
79
+
80
+ if (!cmd) {
81
+ console.info!(await HelpUtil.renderAllHelp());
82
+ return;
83
+ }
84
+
85
+ // Load a single command
86
+ command = await CliCommandRegistry.getInstance(cmd, true);
87
+ if (help) {
88
+ console.log!(await HelpUtil.renderCommandHelp(command));
89
+ } else {
90
+ const result = await this.#runCommand(command, args);
91
+ await CliUtil.listenForResponse(result);
93
92
  }
93
+ } catch (err) {
94
+ await this.#onError(command, err);
94
95
  }
95
96
  }
96
97
  }
package/src/help.ts CHANGED
@@ -25,7 +25,7 @@ export class HelpUtil {
25
25
  static async renderCommandHelp(command: CliCommandShape): Promise<string> {
26
26
  const commandName = CliCommandRegistry.getName(command);
27
27
 
28
- command.initialize?.();
28
+ await command.preHelp?.();
29
29
 
30
30
  // Ensure finalized
31
31
  const { flags, args } = await CliCommandSchemaUtil.getSchema(command);
@@ -126,13 +126,6 @@ export class HelpUtil {
126
126
  return lines.map(x => x.trimEnd()).join('\n');
127
127
  }
128
128
 
129
- /**
130
- * Render help
131
- */
132
- static async renderHelp(command?: CliCommandShape): Promise<string> {
133
- return command ? this.renderCommandHelp(command) : this.renderAllHelp();
134
- }
135
-
136
129
  /**
137
130
  * Render validation error to a string
138
131
  */
package/src/parse.ts ADDED
@@ -0,0 +1,231 @@
1
+ import fs from 'fs/promises';
2
+
3
+ import { RootIndex, path } from '@travetto/manifest';
4
+ import { CliCommandInput, CliCommandSchema, CliCommandShape } from './types';
5
+
6
+ type ParsedFlag = { type: 'flag', input: string, array?: boolean, fieldName: string, value?: unknown };
7
+ type ParsedArg = { type: 'arg', input: string, array?: boolean, index: number };
8
+ type ParsedUnknown = { type: 'unknown', input: string };
9
+ type ParsedInput = ParsedUnknown | ParsedFlag | ParsedArg;
10
+
11
+ export type ParsedState = {
12
+ inputs: string[];
13
+ all: ParsedInput[];
14
+ flags: ParsedFlag[];
15
+ unknown: string[];
16
+ };
17
+
18
+ const RAW_SEP = '--';
19
+ const VALID_FLAG = /^-{1,2}[a-z]/i;
20
+ const HELP_FLAG = /^-h|--help$/;
21
+ const LONG_FLAG_WITH_EQ = /^--[a-z][^= ]+=\S+/i;
22
+ const CONFIG_PRE = '+=';
23
+ const ENV_PRE = 'env.';
24
+ const ParsedⲐ = Symbol.for('@travetto/cli:parsed');
25
+ const SPACE = new Set([32, 7, 13, 10]);
26
+
27
+ const isBoolFlag = (x?: CliCommandInput): boolean => x?.type === 'boolean' && !x.array;
28
+
29
+ const getInput = (cfg: { field?: CliCommandInput, rawText?: string, input: string, index?: number, value?: string }): ParsedInput => {
30
+ const { field, input, rawText = input, value, index } = cfg;
31
+ if (!field) {
32
+ return { type: 'unknown', input: rawText };
33
+ } else if (!field.flagNames?.length) {
34
+ return { type: 'arg', input: field ? input : rawText ?? input, array: field.array, index: index! };
35
+ } else {
36
+ return {
37
+ type: 'flag',
38
+ fieldName: field.name,
39
+ array: field.array,
40
+ input: field ? input : rawText ?? input,
41
+ value: value ?? (isBoolFlag(field) ? !input.startsWith('--no-') : undefined)
42
+ };
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Parsing support for the cli
48
+ */
49
+ export class CliParseUtil {
50
+
51
+ static toEnvField(k: string): string {
52
+ return `${ENV_PRE}${k}`;
53
+ }
54
+
55
+ static readToken(text: string, start = 0): { next: number, value?: string } {
56
+ const collected: number[] = [];
57
+ let i = start;
58
+ let done = false;
59
+ let quote: number | undefined;
60
+ let escaped = false;
61
+ outer: for (; i < text.length; i += 1) {
62
+ const ch = text.charCodeAt(i);
63
+ const space = SPACE.has(ch);
64
+ if (escaped) {
65
+ escaped = false;
66
+ collected.push(ch);
67
+ } else if (done && !space) {
68
+ break outer;
69
+ } else if (!quote && space) {
70
+ done = true;
71
+ } else {
72
+ switch (ch) {
73
+ case 92: /* Backslash */ escaped = true; break;
74
+ case 39: /* Single quote */ case 34: /* Double quote */
75
+ if (quote === ch) { // End quote
76
+ quote = undefined;
77
+ } else if (!quote) {
78
+ quote = ch;
79
+ } else {
80
+ collected.push(ch);
81
+ }
82
+ break;
83
+ default: collected.push(ch);
84
+ }
85
+ }
86
+ }
87
+ return { next: i, value: collected.length ? String.fromCharCode(...collected) : undefined };
88
+ }
89
+
90
+ /**
91
+ * Read configuration file given flag
92
+ */
93
+ static async readFlagFile(flag: string): Promise<string[]> {
94
+ const key = flag.replace(CONFIG_PRE, '');
95
+ const mod = RootIndex.mainModuleName;
96
+
97
+ // We have a file
98
+ const rel = (key.includes('/') ? key : `@/support/pack.${key}.flags`)
99
+ .replace('@@/', `${RootIndex.manifest.workspacePath}/`)
100
+ .replace('@/', `${mod}/`)
101
+ .replace(/^(@[^\/]+\/[^\/]+)/, (_, imp) => {
102
+ const val = RootIndex.getModule(imp);
103
+ if (!val) {
104
+ throw new Error(`Unknown module file: ${key}, unable to proceed`);
105
+ }
106
+ return val.sourcePath;
107
+ });
108
+
109
+ const file = path.resolve(rel);
110
+
111
+ if (!await fs.stat(file).catch(() => false)) {
112
+ throw new Error(`Missing flag file: ${key}, unable to proceed`);
113
+ }
114
+
115
+ const data = await fs.readFile(file, 'utf8');
116
+ const args: string[] = [];
117
+ let token: { next: number, value?: string } = { next: 0 };
118
+ while (token.next < data.length) {
119
+ token = this.readToken(data, token.next);
120
+ if (token.value !== undefined) {
121
+ args.push(token.value);
122
+ }
123
+ }
124
+ return args;
125
+ }
126
+
127
+ /**
128
+ * Parse args to extract command from argv along with other params. Will skip
129
+ * argv[0] and argv[1] if equal to process.argv[0:2]
130
+ */
131
+ static async getArgs(argv: string[]): Promise<{ cmd?: string, args: string[], help?: boolean }> {
132
+ let offset = 0;
133
+ if (argv[0] === process.argv[0] && argv[1] === process.argv[1]) {
134
+ offset = 2;
135
+ }
136
+ const out = argv.slice(offset);
137
+ const max = out.includes(RAW_SEP) ? out.indexOf(RAW_SEP) : out.length;
138
+ const valid = out.slice(0, max);
139
+ const cmd = valid.length > 0 && !valid[0].startsWith('-') ? valid[0] : undefined;
140
+ const helpIdx = valid.findIndex(x => HELP_FLAG.test(x));
141
+ const args = [];
142
+ for (const item of out.slice(cmd ? 1 : 0)) {
143
+ if (item.startsWith(CONFIG_PRE)) {
144
+ args.push(...await this.readFlagFile(item));
145
+ } else {
146
+ args.push(item);
147
+ }
148
+ }
149
+ args.push(...out.slice(max));
150
+ const res = { cmd, args, help: helpIdx >= 0 };
151
+ return res;
152
+ }
153
+
154
+ /**
155
+ * Parse inputs to command
156
+ */
157
+ static async parse(schema: CliCommandSchema, inputs: string[]): Promise<ParsedState> {
158
+ const flagMap = new Map<string, CliCommandInput>(
159
+ schema.flags.flatMap(f => (f.flagNames ?? []).map(name => [name, f]))
160
+ );
161
+
162
+ const out: ParsedInput[] = [];
163
+
164
+ // Load env vars to front
165
+ for (const field of schema.flags) {
166
+ for (const envName of field.envVars ?? []) {
167
+ if (envName in process.env) {
168
+ const value: string = process.env[envName]!;
169
+ if (field.array) {
170
+ out.push(...value.split(/\s*,\s*/g).map(v => getInput({ field, input: `${ENV_PRE}${envName}`, value: v })));
171
+ } else {
172
+ out.push(getInput({ field, input: `${ENV_PRE}${envName}`, value }));
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ let argIdx = 0;
179
+
180
+ for (let i = 0; i < inputs.length; i += 1) {
181
+ const input = inputs[i];
182
+
183
+ if (input === RAW_SEP) { // Raw separator
184
+ out.push(...inputs.slice(i + 1).map(x => getInput({ input: x })));
185
+ break;
186
+ } else if (LONG_FLAG_WITH_EQ.test(input)) {
187
+ const [k, ...v] = input.split('=');
188
+ const field = flagMap.get(k);
189
+ out.push(getInput({ field, rawText: input, input: k, value: v.join('=') }));
190
+ } else if (VALID_FLAG.test(input)) { // Flag
191
+ const field = flagMap.get(input);
192
+ const next = inputs[i + 1];
193
+ if ((next && (VALID_FLAG.test(next) || next === RAW_SEP)) || isBoolFlag(field)) {
194
+ out.push(getInput({ field, input }));
195
+ } else {
196
+ out.push(getInput({ field, input, value: next }));
197
+ i += 1;
198
+ }
199
+ } else {
200
+ const field = schema.args[argIdx];
201
+ out.push(getInput({ field, input, index: argIdx }));
202
+ // Move argIdx along if not in a vararg situation
203
+ if (!field?.array) {
204
+ argIdx += 1;
205
+ }
206
+ }
207
+ }
208
+
209
+ // Store for later, if needed
210
+ return {
211
+ inputs,
212
+ all: out,
213
+ unknown: out.filter((x): x is ParsedUnknown => x.type === 'unknown').map(x => x.input),
214
+ flags: out.filter((x): x is ParsedFlag => x.type === 'flag')
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Get parse state from the command
220
+ */
221
+ static getState(cmd: CliCommandShape & { [ParsedⲐ]?: ParsedState }): ParsedState | undefined {
222
+ return cmd[ParsedⲐ];
223
+ }
224
+
225
+ /**
226
+ * Set state for a command
227
+ */
228
+ static setState(cmd: CliCommandShape & { [ParsedⲐ]?: ParsedState }, state: ParsedState): void {
229
+ cmd[ParsedⲐ] = state;
230
+ }
231
+ }
package/src/schema.ts CHANGED
@@ -4,36 +4,13 @@ import { BindUtil, FieldConfig, SchemaRegistry, SchemaValidator, ValidationResul
4
4
  import { CliCommandRegistry } from './registry';
5
5
  import { CliCommandInput, CliCommandSchema, CliCommandShape } from './types';
6
6
  import { CliValidationResultError } from './error';
7
+ import { ParsedState } from './parse';
7
8
 
8
- const VALID_FLAG = /^-{1,2}[a-z]/i;
9
9
  const LONG_FLAG = /^--[a-z][^= ]+/i;
10
- const LONG_FLAG_WITH_EQ = /^--[a-z][^= ]+=\S+/i;
11
10
  const SHORT_FLAG = /^-[a-z]/i;
12
11
 
13
- type ParsedInput =
14
- { type: 'unknown', input: string } |
15
- { type: 'arg', input: string, array?: boolean, index: number } |
16
- { type: 'flag', input: string, array?: boolean, fieldName: string, value?: unknown };
17
-
18
12
  const isBoolFlag = (x?: CliCommandInput): boolean => x?.type === 'boolean' && !x.array;
19
13
 
20
- const getInput = (cfg: { field?: CliCommandInput, rawText?: string, input: string, index?: number, value?: string }): ParsedInput => {
21
- const { field, input, rawText = input, value, index } = cfg;
22
- if (!field) {
23
- return { type: 'unknown', input: rawText };
24
- } else if (!field.flagNames?.length) {
25
- return { type: 'arg', input: field ? input : rawText ?? input, array: field.array, index: index! };
26
- } else {
27
- return {
28
- type: 'flag',
29
- fieldName: field.name,
30
- array: field.array,
31
- input: field ? input : rawText ?? input,
32
- value: value ?? (isBoolFlag(field) ? !input.startsWith('--no-') : undefined)
33
- };
34
- }
35
- };
36
-
37
14
  function fieldToInput(x: FieldConfig): CliCommandInput {
38
15
  const type = x.type === Date ? 'date' :
39
16
  x.type === Boolean ? 'boolean' :
@@ -136,71 +113,13 @@ export class CliCommandSchemaUtil {
136
113
  }
137
114
 
138
115
  /**
139
- * Parse inputs to command
140
- */
141
- static async parse<T extends CliCommandShape>(cls: Class<T>, inputs: string[]): Promise<ParsedInput[]> {
142
- const schema = await this.getSchema(cls);
143
- const flagMap = new Map<string, CliCommandInput>(
144
- schema.flags.flatMap(f => (f.flagNames ?? []).map(name => [name, f]))
145
- );
146
-
147
- const out: ParsedInput[] = [];
148
-
149
- // Load env vars to front
150
- for (const field of schema.flags) {
151
- for (const envName of field.envVars ?? []) {
152
- if (envName in process.env) {
153
- const value: string = process.env[envName]!;
154
- if (field.array) {
155
- out.push(...value.split(/\s*,\s*/g).map(v => getInput({ field, input: `env.${envName}`, value: v })));
156
- } else {
157
- out.push(getInput({ field, input: `env.${envName}`, value }));
158
- }
159
- }
160
- }
161
- }
162
-
163
- let argIdx = 0;
164
-
165
- for (let i = 0; i < inputs.length; i += 1) {
166
- const input = inputs[i];
167
-
168
- if (input === '--') { // Raw separator
169
- out.push(...inputs.slice(i + 1).map(x => getInput({ input: x })));
170
- break;
171
- } else if (LONG_FLAG_WITH_EQ.test(input)) {
172
- const [k, ...v] = input.split('=');
173
- const field = flagMap.get(k);
174
- out.push(getInput({ field, rawText: input, input: k, value: v.join('=') }));
175
- } else if (VALID_FLAG.test(input)) { // Flag
176
- const field = flagMap.get(input);
177
- const next = inputs[i + 1];
178
- if ((next && (VALID_FLAG.test(next) || next === '--')) || isBoolFlag(field)) {
179
- out.push(getInput({ field, input }));
180
- } else {
181
- out.push(getInput({ field, input, value: next }));
182
- i += 1;
183
- }
184
- } else {
185
- const field = schema.args[argIdx];
186
- out.push(getInput({ field, input, index: argIdx }));
187
- // Move argIdx along if not in a vararg situation
188
- if (!field?.array) {
189
- argIdx += 1;
190
- }
191
- }
192
- }
193
-
194
- return out;
195
- }
196
-
197
- /**
198
- * Bind arguments to command
116
+ * Bind parsed inputs to command
199
117
  */
200
- static async bindFlags<T extends CliCommandShape>(cmd: T, args: ParsedInput[]): Promise<void> {
118
+ static async bindInput<T extends CliCommandShape>(cmd: T, state: ParsedState): Promise<unknown[]> {
201
119
  const template: Partial<T> = {};
120
+ const bound: unknown[] = [];
202
121
 
203
- for (const arg of args) {
122
+ for (const arg of state.all) {
204
123
  switch (arg.type) {
205
124
  case 'flag': {
206
125
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -213,6 +132,15 @@ export class CliCommandSchemaUtil {
213
132
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
214
133
  template[key] = value as unknown as T[typeof key];
215
134
  }
135
+ break;
136
+ }
137
+ case 'arg': {
138
+ if (arg.array) {
139
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
140
+ ((bound[arg.index] ??= []) as unknown[]).push(arg.input);
141
+ } else {
142
+ bound[arg.index] = arg.input;
143
+ }
216
144
  }
217
145
  }
218
146
  }
@@ -220,35 +148,9 @@ export class CliCommandSchemaUtil {
220
148
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
221
149
  const cls = cmd.constructor as Class<CliCommandShape>;
222
150
  BindUtil.bindSchemaToObject(cls, cmd, template);
223
- }
224
-
225
- /**
226
- * Produce the arguments into the final argument set
227
- */
228
- static async bindArgs(cmd: CliCommandShape, inputs: ParsedInput[]): Promise<unknown[]> {
229
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
230
- const cls = cmd.constructor as Class<CliCommandShape>;
231
- const bound: unknown[] = [];
232
- for (const input of inputs) {
233
- if (input.type === 'arg') {
234
- if (input.array) {
235
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
236
- ((bound[input.index] ??= []) as unknown[]).push(input.input);
237
- } else {
238
- bound[input.index] = input.input;
239
- }
240
- }
241
- }
242
151
  return BindUtil.coerceMethodParams(cls, 'main', bound);
243
152
  }
244
153
 
245
- /**
246
- * Get the unused arguments
247
- */
248
- static getUnusedArgs(args: ParsedInput[]): string[] {
249
- return args.filter(x => x.type === 'unknown').map(x => x.input);
250
- }
251
-
252
154
  /**
253
155
  * Validate command shape with the given arguments
254
156
  */
@@ -284,19 +186,4 @@ export class CliCommandSchemaUtil {
284
186
  }
285
187
  return cmd;
286
188
  }
287
-
288
- /**
289
- * Bind and validate a command with a given set of arguments
290
- */
291
- static async bindAndValidateArgs(cmd: CliCommandShape, args: string[]): Promise<unknown[]> {
292
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
293
- const cls = cmd.constructor as Class;
294
- await cmd.initialize?.();
295
- const parsed = await this.parse(cls, args);
296
- await this.bindFlags(cmd, parsed);
297
- const known = await this.bindArgs(cmd, parsed);
298
- await cmd.finalize?.(this.getUnusedArgs(parsed));
299
- await this.validate(cmd, known);
300
- return known;
301
- }
302
189
  }
package/src/types.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Closeable, EnvInit } from '@travetto/base';
1
+ import { Closeable } from '@travetto/base';
2
2
 
3
3
  type OrProm<T> = T | Promise<T>;
4
4
 
5
- type RunResponse = { wait(): Promise<unknown> } | { on(event: 'close', cb: Function): unknown } | Closeable | void | undefined;
5
+ export type RunResponse = { wait(): Promise<unknown> } | { on(event: 'close', cb: Function): unknown } | Closeable | void | undefined;
6
6
 
7
7
  /**
8
8
  * Constrained version of Schema's Validation Error
@@ -21,19 +21,23 @@ export type CliValidationError = {
21
21
  /**
22
22
  * CLI Command Contract
23
23
  */
24
- export interface CliCommandShape {
24
+ export interface CliCommandShape<T extends unknown[] = unknown[]> {
25
25
  /**
26
26
  * Action target of the command
27
27
  */
28
- main(...args: unknown[]): OrProm<RunResponse>;
28
+ main(...args: T): OrProm<RunResponse>;
29
29
  /**
30
- * Setup environment before command runs
30
+ * Run before main runs
31
31
  */
32
- envInit?(): OrProm<EnvInit>;
32
+ preMain?(): OrProm<void>;
33
33
  /**
34
34
  * Extra help
35
35
  */
36
36
  help?(): OrProm<string[]>;
37
+ /**
38
+ * Run before help is displayed
39
+ */
40
+ preHelp?(): OrProm<void>;
37
41
  /**
38
42
  * Is the command active/eligible for usage
39
43
  */
@@ -41,15 +45,15 @@ export interface CliCommandShape {
41
45
  /**
42
46
  * Run before binding occurs
43
47
  */
44
- initialize?(): OrProm<void>;
48
+ preBind?(): OrProm<void>;
45
49
  /**
46
50
  * Run before validation occurs
47
51
  */
48
- finalize?(unknownArgs: string[]): OrProm<void>;
52
+ preValidate?(): OrProm<void>;
49
53
  /**
50
54
  * Validation method
51
55
  */
52
- validate?(...unknownArgs: unknown[]): OrProm<CliValidationError | CliValidationError[] | undefined>;
56
+ validate?(...args: T): OrProm<CliValidationError | CliValidationError[] | undefined>;
53
57
  }
54
58
 
55
59
  /**
package/src/util.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Env, ExecUtil, GlobalEnv } from '@travetto/base';
2
- import { RootIndex } from '@travetto/manifest';
1
+ import { Env, ExecUtil, GlobalEnv, ShutdownManager } from '@travetto/base';
2
+ import { RootIndex, path } from '@travetto/manifest';
3
3
 
4
- import { CliCommandShape, CliCommandShapeFields } from './types';
4
+ import { CliCommandShape, CliCommandShapeFields, RunResponse } from './types';
5
5
  import { CliCommandRegistry } from './registry';
6
6
 
7
7
  export class CliUtil {
@@ -16,8 +16,15 @@ export class CliUtil {
16
16
  * Get a simplified version of a module name
17
17
  * @returns
18
18
  */
19
- static getSimpleModuleName(name = RootIndex.mainModuleName): string {
20
- return name.replace(/[\/]/, '_').replace(/@/, '');
19
+ static getSimpleModuleName(placeholder: string, module?: string): string {
20
+ const simple = (module ?? RootIndex.mainModuleName).replace(/[\/]/, '_').replace(/@/, '');
21
+ if (!simple) {
22
+ return placeholder;
23
+ } else if (!module && this.monoRoot) {
24
+ return placeholder;
25
+ } else {
26
+ return placeholder.replace('<module>', simple);
27
+ }
21
28
  }
22
29
 
23
30
  /**
@@ -92,4 +99,21 @@ export class CliUtil {
92
99
  static async writeAndEnsureComplete(data: unknown, channel: 'stdout' | 'stderr' = 'stdout'): Promise<void> {
93
100
  return await new Promise(r => process[channel].write(typeof data === 'string' ? data : JSON.stringify(data, null, 2), () => r()));
94
101
  }
102
+
103
+ /**
104
+ * Listen for a run response to finish
105
+ */
106
+ static async listenForResponse(result: RunResponse): Promise<void> {
107
+ // Listen to result if non-empty
108
+ if (result !== undefined && result !== null) {
109
+ if ('close' in result) {
110
+ ShutdownManager.onShutdown(result, result); // Tie shutdown into app close
111
+ }
112
+ if ('wait' in result) {
113
+ await result.wait(); // Wait for close signal
114
+ } else if ('on' in result) {
115
+ await new Promise<void>(res => result.on('close', res)); // Wait for callback
116
+ }
117
+ }
118
+ }
95
119
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
 
3
3
  import { ShutdownManager } from '@travetto/base';
4
- import { CliCommandShape, CliCommand, CliValidationError } from '@travetto/cli';
4
+ import { CliCommandShape, CliCommand, CliValidationError, CliParseUtil } from '@travetto/cli';
5
5
  import { path, RootIndex } from '@travetto/manifest';
6
6
 
7
7
  /**
@@ -10,8 +10,6 @@ import { path, RootIndex } from '@travetto/manifest';
10
10
  @CliCommand({ hidden: true })
11
11
  export class MainCommand implements CliCommandShape {
12
12
 
13
- #unknownArgs?: string[];
14
-
15
13
  async #getImport(fileOrImport: string): Promise<string | undefined> {
16
14
  // If referenced file exists
17
15
  let file = fileOrImport;
@@ -29,15 +27,12 @@ export class MainCommand implements CliCommandShape {
29
27
  }
30
28
  }
31
29
 
32
- finalize(unknownArgs?: string[] | undefined): void | Promise<void> {
33
- this.#unknownArgs = unknownArgs;
34
- }
35
-
36
30
  async main(fileOrImport: string, args: string[] = []): Promise<void> {
37
31
  try {
38
32
  const imp = await this.#getImport(fileOrImport);
39
33
  const mod = await import(imp!);
40
- await ShutdownManager.exitWithResponse(await mod.main(...args, ...this.#unknownArgs ?? []));
34
+
35
+ await ShutdownManager.exitWithResponse(await mod.main(...args, ...CliParseUtil.getState(this)?.unknown ?? []));
41
36
  } catch (err) {
42
37
  await ShutdownManager.exitWithResponse(err, true);
43
38
  }