@f5-sales-demo/pi-utils 20.2.7 → 20.3.1

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/cli.ts +117 -94
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-utils",
4
- "version": "20.2.7",
4
+ "version": "20.3.1",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3",
42
- "@f5-sales-demo/pi-natives": "20.2.7"
42
+ "@f5-sales-demo/pi-natives": "20.3.1"
43
43
  },
44
44
  "engines": {
45
45
  "bun": ">=1.3.7"
package/src/cli.ts CHANGED
@@ -116,6 +116,18 @@ export interface CommandCtor {
116
116
  args?: Record<string, ArgDescriptor>;
117
117
  }
118
118
 
119
+ export interface CommandSyntax {
120
+ strict?: boolean;
121
+ flags?: Record<string, FlagDescriptor>;
122
+ args?: Record<string, ArgDescriptor>;
123
+ }
124
+
125
+ export interface CommandParseResult {
126
+ flags: Record<string, unknown>;
127
+ args: Record<string, unknown>;
128
+ argv: string[];
129
+ }
130
+
119
131
  /** Configuration passed to every command instance and help renderers. */
120
132
  export interface CliConfig {
121
133
  bin: string;
@@ -152,110 +164,115 @@ export abstract class Command {
152
164
  : Record<string, ArgDescriptor>
153
165
  >
154
166
  > {
155
- const Cmd = _Cmd as CommandCtor;
156
- const flagDefs = (Cmd.flags ?? {}) as Record<string, FlagDescriptor>;
157
- const argDefs = (Cmd.args ?? {}) as Record<string, ArgDescriptor>;
158
- const strict = Cmd.strict !== false;
159
-
160
- // Build node:util parseArgs options from flag descriptors
161
- const options: Record<
162
- string,
163
- { type: "string" | "boolean"; short?: string; multiple?: boolean; default?: string | boolean }
164
- > = {};
165
- for (const [name, desc] of Object.entries(flagDefs)) {
166
- const opt: (typeof options)[string] = {
167
- type: desc.kind === "boolean" ? "boolean" : "string",
168
- };
169
- if (desc.char) opt.short = desc.char;
170
- if (desc.multiple) opt.multiple = true;
171
- if (desc.default !== undefined) {
172
- opt.default = desc.kind === "boolean" ? Boolean(desc.default) : String(desc.default);
173
- }
174
- options[name] = opt;
175
- }
167
+ return parseCommandArgv(this.argv, _Cmd) as never;
168
+ }
169
+ }
176
170
 
177
- // strict=false when command declares args (positionals must pass through)
178
- // or when the command itself opts out
179
- const { values: rawValues, positionals } = (() => {
180
- try {
181
- return nodeParseArgs({
182
- args: this.argv,
183
- options,
184
- allowPositionals: true,
185
- strict,
186
- });
187
- } catch (error) {
188
- if (error instanceof TypeError) throw new CliUsageError(error.message);
189
- throw error;
190
- }
191
- })();
192
-
193
- // Convert raw values to proper types and validate
194
- const flags: Record<string, unknown> = {};
195
- for (const [name, desc] of Object.entries(flagDefs)) {
196
- const raw = rawValues[name];
197
- if (desc.kind === "integer") {
198
- if (raw === undefined || typeof raw === "boolean") {
199
- flags[name] = desc.default ?? undefined;
200
- } else {
201
- const n = Number.parseInt(raw as string, 10);
202
- if (Number.isNaN(n)) {
203
- throw new CliUsageError(`Expected integer for --${name}, got "${raw}"`);
204
- }
205
- flags[name] = n;
206
- }
207
- } else if (desc.kind === "boolean") {
208
- flags[name] =
209
- raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined;
210
- } else {
211
- // string
212
- const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined);
213
- // Validate options constraint
214
- if (val !== undefined && desc.options && !Array.isArray(val)) {
215
- if (!desc.options.includes(val as string)) {
216
- throw new CliUsageError(
217
- `Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
218
- );
219
- }
220
- }
221
- flags[name] = val;
222
- }
223
- // Validate required
224
- if (desc.required && flags[name] === undefined) {
225
- throw new CliUsageError(`Missing required flag: --${name}`);
226
- }
171
+ /** Parse command argv from lightweight syntax metadata without loading its implementation. */
172
+ export function parseCommandArgv(argv: readonly string[], command: CommandSyntax): CommandParseResult {
173
+ const Cmd = command;
174
+ const flagDefs = (Cmd.flags ?? {}) as Record<string, FlagDescriptor>;
175
+ const argDefs = (Cmd.args ?? {}) as Record<string, ArgDescriptor>;
176
+ const strict = Cmd.strict !== false;
177
+
178
+ // Build node:util parseArgs options from flag descriptors
179
+ const options: Record<
180
+ string,
181
+ { type: "string" | "boolean"; short?: string; multiple?: boolean; default?: string | boolean }
182
+ > = {};
183
+ for (const [name, desc] of Object.entries(flagDefs)) {
184
+ const opt: (typeof options)[string] = {
185
+ type: desc.kind === "boolean" ? "boolean" : "string",
186
+ };
187
+ if (desc.char) opt.short = desc.char;
188
+ if (desc.multiple) opt.multiple = true;
189
+ if (desc.default !== undefined) {
190
+ opt.default = desc.kind === "boolean" ? Boolean(desc.default) : String(desc.default);
227
191
  }
192
+ options[name] = opt;
193
+ }
228
194
 
229
- // Map positionals to named args in declaration order and validate
230
- const args: Record<string, unknown> = {};
231
- let posIdx = 0;
232
- for (const [argName, desc] of Object.entries(argDefs)) {
233
- if (desc.multiple) {
234
- const val = positionals.slice(posIdx);
235
- args[argName] = val.length > 0 ? val : undefined;
236
- posIdx = positionals.length;
195
+ // strict=false when command declares args (positionals must pass through)
196
+ // or when the command itself opts out
197
+ const { values: rawValues, positionals } = (() => {
198
+ try {
199
+ return nodeParseArgs({
200
+ args: [...argv],
201
+ options,
202
+ allowPositionals: true,
203
+ strict,
204
+ });
205
+ } catch (error) {
206
+ if (error instanceof TypeError) throw new CliUsageError(error.message);
207
+ throw error;
208
+ }
209
+ })();
210
+
211
+ // Convert raw values to proper types and validate
212
+ const flags: Record<string, unknown> = {};
213
+ for (const [name, desc] of Object.entries(flagDefs)) {
214
+ const raw = rawValues[name];
215
+ if (desc.kind === "integer") {
216
+ if (raw === undefined || typeof raw === "boolean") {
217
+ flags[name] = desc.default ?? undefined;
237
218
  } else {
238
- const val = positionals[posIdx];
239
- args[argName] = val;
240
- posIdx++;
241
- }
242
- // Validate required
243
- if (desc.required && args[argName] === undefined) {
244
- throw new CliUsageError(`Missing required argument: ${argName}`);
219
+ const n = Number.parseInt(raw as string, 10);
220
+ if (Number.isNaN(n)) {
221
+ throw new CliUsageError(`Expected integer for --${name}, got "${raw}"`);
222
+ }
223
+ flags[name] = n;
245
224
  }
225
+ } else if (desc.kind === "boolean") {
226
+ flags[name] =
227
+ raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined;
228
+ } else {
229
+ // string
230
+ const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined);
246
231
  // Validate options constraint
247
- const argVal = args[argName];
248
- if (argVal !== undefined && desc.options && typeof argVal === "string") {
249
- if (!desc.options.includes(argVal)) {
232
+ if (val !== undefined && desc.options && !Array.isArray(val)) {
233
+ if (!desc.options.includes(val as string)) {
250
234
  throw new CliUsageError(
251
- `Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
235
+ `Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
252
236
  );
253
237
  }
254
238
  }
239
+ flags[name] = val;
240
+ }
241
+ // Validate required
242
+ if (desc.required && flags[name] === undefined) {
243
+ throw new CliUsageError(`Missing required flag: --${name}`);
255
244
  }
245
+ }
256
246
 
257
- return { flags, args, argv: positionals } as never;
247
+ // Map positionals to named args in declaration order and validate
248
+ const args: Record<string, unknown> = {};
249
+ let posIdx = 0;
250
+ for (const [argName, desc] of Object.entries(argDefs)) {
251
+ if (desc.multiple) {
252
+ const val = positionals.slice(posIdx);
253
+ args[argName] = val.length > 0 ? val : undefined;
254
+ posIdx = positionals.length;
255
+ } else {
256
+ const val = positionals[posIdx];
257
+ args[argName] = val;
258
+ posIdx++;
259
+ }
260
+ // Validate required
261
+ if (desc.required && args[argName] === undefined) {
262
+ throw new CliUsageError(`Missing required argument: ${argName}`);
263
+ }
264
+ // Validate options constraint
265
+ const argVal = args[argName];
266
+ if (argVal !== undefined && desc.options && typeof argVal === "string") {
267
+ if (!desc.options.includes(argVal)) {
268
+ throw new CliUsageError(
269
+ `Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
270
+ );
271
+ }
272
+ }
258
273
  }
274
+
275
+ return { flags, args, argv: positionals };
259
276
  }
260
277
 
261
278
  // ---------------------------------------------------------------------------
@@ -361,6 +378,10 @@ export interface CommandEntry {
361
378
  name: string;
362
379
  load: () => Promise<CommandCtor>;
363
380
  aliases?: string[];
381
+ /** Optional command-specific validation that runs before generic syntax parsing. */
382
+ validate?: (argv: readonly string[]) => void;
383
+ /** Optional leaf metadata used to reject invalid argv before loading a heavy command module. */
384
+ syntax?: CommandSyntax;
364
385
  }
365
386
 
366
387
  export interface RunOptions {
@@ -429,10 +450,12 @@ export async function run(opts: RunOptions): Promise<void> {
429
450
  return;
430
451
  }
431
452
 
432
- const Cmd = await entry.load();
433
- const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
434
- const instance = new Cmd(commandArgv, config);
435
453
  try {
454
+ entry.validate?.(commandArgv);
455
+ if (entry.syntax) parseCommandArgv(commandArgv, entry.syntax);
456
+ const Cmd = await entry.load();
457
+ const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
458
+ const instance = new Cmd(commandArgv, config);
436
459
  await instance.run();
437
460
  } catch (error) {
438
461
  if (!(error instanceof CliUsageError)) throw error;