@h3ravel/musket 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -29,6 +29,232 @@ let node_path = require("node:path");
29
29
  node_path = __toESM(node_path, 1);
30
30
  let module$1 = require("module");
31
31
  let node_fs_promises = require("node:fs/promises");
32
+ //#region src/SignatureBuilder.ts
33
+ /**
34
+ * A fluent builder for constructing a command's signature programmatically,
35
+ * without authoring the string DSL by hand.
36
+ *
37
+ * ```ts
38
+ * buildSignature (sig: SignatureBuilder) {
39
+ * return sig
40
+ * .command('queue:work')
41
+ * .describe('Process jobs on the queue')
42
+ * .argument('connection', { description: 'The connection to work', required: false })
43
+ * .option('queue', { description: 'The queue to process', short: 'Q' })
44
+ * .option('once', { description: 'Process a single job and exit' })
45
+ * }
46
+ * ```
47
+ *
48
+ * Musket consumes the builder directly (lossless) for normal commands, and can
49
+ * also reconstruct the equivalent signature string via {@link toString} for
50
+ * anything that still expects the DSL.
51
+ */
52
+ var SignatureBuilder = class {
53
+ baseName = "";
54
+ commandDescription;
55
+ hidden = false;
56
+ args = [];
57
+ opts = [];
58
+ /**
59
+ * Set the command name, e.g. `make:model` or `queue:work`. A trailing `:`
60
+ * marks a namespace command.
61
+ */
62
+ command(name) {
63
+ this.baseName = name.trim();
64
+ return this;
65
+ }
66
+ /**
67
+ * Alias for {@link command}.
68
+ *
69
+ * @param name
70
+ * @returns
71
+ */
72
+ name(name) {
73
+ return this.command(name);
74
+ }
75
+ /**
76
+ * Set the command description.
77
+ *
78
+ * @param name
79
+ * @returns
80
+ */
81
+ describe(description) {
82
+ this.commandDescription = description;
83
+ return this;
84
+ }
85
+ /**
86
+ * Alias for {@link describe}.
87
+ *
88
+ * @param name
89
+ * @returns
90
+ */
91
+ description(description) {
92
+ return this.describe(description);
93
+ }
94
+ /**
95
+ * Mark the command as hidden from help output.
96
+ *
97
+ * @param name
98
+ * @returns
99
+ */
100
+ hide(hidden = true) {
101
+ this.hidden = hidden;
102
+ return this;
103
+ }
104
+ /**
105
+ * Add a positional argument.
106
+ *
107
+ * @param name
108
+ * @returns
109
+ */
110
+ argument(name, definition = {}) {
111
+ this.args.push({
112
+ name: name.trim(),
113
+ ...definition
114
+ });
115
+ return this;
116
+ }
117
+ /**
118
+ * Add an option/flag.
119
+ *
120
+ * @param name
121
+ * @returns
122
+ */
123
+ option(name, definition = {}) {
124
+ this.opts.push({
125
+ name: name.replace(/^--?/, "").trim(),
126
+ ...definition
127
+ });
128
+ return this;
129
+ }
130
+ /**
131
+ * The configured command name (empty when unset).
132
+ *
133
+ * @param name
134
+ * @returns
135
+ */
136
+ getName() {
137
+ return this.baseName;
138
+ }
139
+ /**
140
+ * The configured description, if any.
141
+ *
142
+ * @param name
143
+ * @returns
144
+ */
145
+ getDescription() {
146
+ return this.commandDescription;
147
+ }
148
+ /**
149
+ * Whether the command is a namespace command (name ends with `:`).
150
+ *
151
+ * @param name
152
+ * @returns
153
+ */
154
+ isNamespace() {
155
+ return this.baseName.endsWith(":");
156
+ }
157
+ /**
158
+ * Whether anything has been configured on this builder.
159
+ *
160
+ * @param name
161
+ * @returns
162
+ */
163
+ isEmpty() {
164
+ return this.baseName.length === 0;
165
+ }
166
+ /**
167
+ * Build the structured {@link ParsedCommand} consumed by the kernel. This is
168
+ * lossless — no string round-trip — and is the path used for normal commands.
169
+ *
170
+ * @param commandClass
171
+ * @returns
172
+ */
173
+ toParsed(commandClass) {
174
+ const options = [...this.args.map((arg) => this.argumentToOption(arg)), ...this.opts.map((opt) => this.optionToOption(opt))];
175
+ return {
176
+ baseCommand: this.baseName,
177
+ isNamespaceCommand: false,
178
+ description: this.commandDescription ?? commandClass.getDescription(),
179
+ commandClass,
180
+ options,
181
+ isHidden: this.hidden
182
+ };
183
+ }
184
+ argumentToOption(arg) {
185
+ const required = arg.required ?? arg.default === void 0;
186
+ const multiple = arg.multiple ?? false;
187
+ let placeholder;
188
+ if (multiple) placeholder = required ? `<${arg.name}...>` : `[${arg.name}...]`;
189
+ return {
190
+ name: arg.name,
191
+ isFlag: false,
192
+ required,
193
+ multiple,
194
+ description: arg.description ?? "",
195
+ choices: arg.choices ?? [],
196
+ defaultValue: arg.default,
197
+ placeholder
198
+ };
199
+ }
200
+ optionToOption(opt) {
201
+ const long = `--${opt.name}`;
202
+ const flags = opt.short ? [`-${opt.short}`, long] : [long];
203
+ const takesValue = opt.default !== void 0 || opt.requiresValue === true || opt.optionalValue === true || (opt.choices?.length ?? 0) > 0;
204
+ let required = false;
205
+ let placeholder;
206
+ if (takesValue) if (opt.requiresValue === true && opt.default === void 0 && opt.optionalValue !== true) required = true;
207
+ else placeholder = `[${opt.name}]`;
208
+ return {
209
+ name: long,
210
+ flags,
211
+ isFlag: true,
212
+ required,
213
+ placeholder,
214
+ description: opt.description ?? "",
215
+ choices: opt.choices ?? [],
216
+ defaultValue: opt.default ?? (takesValue ? void 0 : false),
217
+ shared: opt.shared,
218
+ isHidden: opt.hidden
219
+ };
220
+ }
221
+ /**
222
+ * Reconstruct the equivalent signature string in musket's DSL. Used by
223
+ * {@link Command.getSignature} so external consumers (and the namespace path)
224
+ * keep working unchanged.
225
+ */
226
+ toString() {
227
+ const tokens = [this.baseName];
228
+ for (const arg of this.args) tokens.push(this.argumentToken(arg));
229
+ for (const opt of this.opts) tokens.push(this.optionToken(opt));
230
+ return tokens.join("\n ");
231
+ }
232
+ argumentToken(arg) {
233
+ const required = arg.required ?? arg.default === void 0;
234
+ const multiple = arg.multiple ?? false;
235
+ let token = arg.name;
236
+ if (arg.default !== void 0) token += `=${arg.default}`;
237
+ else if (!required && multiple) token += "?*";
238
+ else if (multiple) token += "*";
239
+ else if (!required) token += "?";
240
+ return this.wrap(token, arg.description, arg.choices);
241
+ }
242
+ optionToken(opt) {
243
+ const takesValue = opt.default !== void 0 || opt.requiresValue === true || opt.optionalValue === true;
244
+ let token = opt.short ? `--${opt.short}|${opt.name}` : `--${opt.name}`;
245
+ if (opt.default !== void 0) token += `=${opt.default}`;
246
+ else if (opt.requiresValue === true && opt.optionalValue !== true) token += "=";
247
+ else if (opt.optionalValue === true) token += "?";
248
+ return this.wrap(token, opt.description, opt.choices, takesValue);
249
+ }
250
+ wrap(token, description, choices, _value) {
251
+ let body = token;
252
+ if (description) body += ` : ${description}`;
253
+ if (choices?.length) body += ` : [${choices.join(", ")}]`;
254
+ return `{${body}}`;
255
+ }
256
+ };
257
+ //#endregion
32
258
  //#region src/Core/Command.ts
33
259
  var Command = class {
34
260
  app;
@@ -62,6 +288,11 @@ var Command = class {
62
288
  */
63
289
  description;
64
290
  /**
291
+ * Memoized result of {@link resolveBuilder}. `undefined` means "not yet
292
+ * resolved"; `null` means "resolved, no builder in use".
293
+ */
294
+ resolvedBuilder;
295
+ /**
65
296
  * The console command input.
66
297
  *
67
298
  * @var object
@@ -142,20 +373,70 @@ var Command = class {
142
373
  return this;
143
374
  }
144
375
  /**
145
- * Get the command signature
146
- *
147
- * @returns
376
+ * Define the command signature programmatically.
377
+ *
378
+ * Override this instead of (or in addition to) the {@link signature} string
379
+ * to describe the command, its arguments and its options through the fluent
380
+ * {@link SignatureBuilder} — no string DSL required. When the `signature`
381
+ * string is set it always takes precedence and this method is ignored.
382
+ *
383
+ * ```ts
384
+ * buildSignature (sig: SignatureBuilder) {
385
+ * return sig
386
+ * .command('queue:work')
387
+ * .describe('Process jobs on the queue')
388
+ * .argument('connection', { required: false })
389
+ * .option('queue', { short: 'Q', description: 'The queue to process' })
390
+ * .option('once', { description: 'Process a single job and exit' })
391
+ * }
392
+ * ```
393
+ *
394
+ * @param _builder A fresh builder to configure.
395
+ */
396
+ buildSignature(_builder) {
397
+ /** Override to use. */
398
+ }
399
+ /**
400
+ * Resolve (and memoize) the signature builder for this command, or `null`
401
+ * when the command uses the string {@link signature} (which always wins) or
402
+ * does not implement {@link buildSignature}.
403
+ */
404
+ resolveBuilder() {
405
+ if (this.resolvedBuilder !== void 0) return this.resolvedBuilder;
406
+ if (this.signature) return this.resolvedBuilder = null;
407
+ const builder = new SignatureBuilder();
408
+ const result = this.buildSignature(builder);
409
+ const resolved = result instanceof SignatureBuilder ? result : builder;
410
+ return this.resolvedBuilder = resolved.isEmpty() ? null : resolved;
411
+ }
412
+ /**
413
+ * Get the command signature. Returns the string {@link signature} when set,
414
+ * otherwise reconstructs it from {@link buildSignature}.
415
+ *
416
+ * @returns
148
417
  */
149
418
  getSignature() {
150
- return this.signature;
419
+ if (this.signature) return this.signature;
420
+ return this.resolveBuilder()?.toString() ?? this.signature;
421
+ }
422
+ /**
423
+ * The structured, lossless parsed command when {@link buildSignature} is used
424
+ * for a non-namespace command; `undefined` otherwise (callers then fall back
425
+ * to parsing {@link getSignature}). This avoids a lossy string round-trip.
426
+ */
427
+ toParsedSignature() {
428
+ const builder = this.resolveBuilder();
429
+ if (!builder || builder.isNamespace()) return;
430
+ return builder.toParsed(this);
151
431
  }
152
432
  /**
153
433
  * Get the command description
154
- *
155
- * @returns
434
+ *
435
+ * @returns
156
436
  */
157
437
  getDescription() {
158
- return this.description;
438
+ if (this.description) return this.description;
439
+ return this.resolveBuilder()?.getDescription() ?? this.description;
159
440
  }
160
441
  /**
161
442
  * Get a specific option
@@ -757,6 +1038,13 @@ var Musket = class Musket {
757
1038
  name = "musket";
758
1039
  config = {};
759
1040
  commands = [];
1041
+ /**
1042
+ * Keys (baseCommand, namespace-aware) of commands already registered, so the
1043
+ * same command surfacing from more than one source — e.g. discovered both as
1044
+ * built `dist/*.js` and as `src/*.ts` via the jiti loader — is only added
1045
+ * once. The first registration wins (base commands before discovered ones).
1046
+ */
1047
+ registeredKeys = /* @__PURE__ */ new Set();
760
1048
  program;
761
1049
  constructor(app, kernel, baseCommands = [], resolver, tsDownConfig = {}) {
762
1050
  this.app = app;
@@ -859,9 +1147,26 @@ var Musket = class Musket {
859
1147
  */
860
1148
  resolveCommandClass(mod, name) {
861
1149
  const named = mod[name];
862
- if (typeof named === "function") return named;
863
- if (typeof mod.default === "function") return mod.default;
864
- return Object.values(mod).find((value) => typeof value === "function");
1150
+ if (this.isCommandClass(named)) return named;
1151
+ if (this.isCommandClass(mod.default)) return mod.default;
1152
+ return Object.values(mod).find((value) => this.isCommandClass(value));
1153
+ }
1154
+ /**
1155
+ * Whether a value is a Command class (constructor), as opposed to any other
1156
+ * exported function/class.
1157
+ *
1158
+ * The check is structural — it looks for `getSignature` on the prototype
1159
+ * rather than using `instanceof Command` — so it stays correct when the
1160
+ * discovered command extends a Command from a different copy/version of
1161
+ * musket. Crucially, it rejects non-command exports such as the shared
1162
+ * bundler chunks tools like tsdown can emit alongside built commands (e.g. a
1163
+ * `Rebuilder` helper), which would otherwise be `new`-ed and then crash when
1164
+ * `getSignature()` is called on them.
1165
+ *
1166
+ * @param value The candidate export.
1167
+ */
1168
+ isCommandClass(value) {
1169
+ return typeof value === "function" && typeof value.prototype?.getSignature === "function";
865
1170
  }
866
1171
  /**
867
1172
  * Emit a non-fatal command-discovery warning.
@@ -872,21 +1177,53 @@ var Musket = class Musket {
872
1177
  _h3ravel_shared.Logger.log([["[musket]", "yellow"], [message, "white"]], " ");
873
1178
  }
874
1179
  /**
875
- * Push a new command into the commands stack
876
- *
877
- * @param command
1180
+ * Push a new command into the commands stack.
1181
+ *
1182
+ * Resolution prefers a command's structured signature (built via
1183
+ * {@link Command.buildSignature}) and falls back to parsing its signature
1184
+ * string. Commands that expose no usable signature are skipped with a warning
1185
+ * rather than crashing the CLI, and a command whose key was already
1186
+ * registered is ignored (de-duplication — see {@link registeredKeys}).
1187
+ *
1188
+ * @param command
878
1189
  */
879
1190
  addCommand(command) {
880
- this.commands.push(Signature.parseSignature(command.getSignature(), command));
1191
+ if (!command || typeof command.getSignature !== "function") {
1192
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: not a valid command (no getSignature()).`);
1193
+ return this;
1194
+ }
1195
+ let parsed;
1196
+ try {
1197
+ parsed = command.toParsedSignature?.() ?? Signature.parseSignature(command.getSignature(), command);
1198
+ } catch (error) {
1199
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: ${error?.message ?? String(error)}`);
1200
+ return this;
1201
+ }
1202
+ if (!parsed || !parsed.baseCommand) {
1203
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: empty or unparsable signature.`);
1204
+ return this;
1205
+ }
1206
+ const key = parsed.isNamespaceCommand ? `${parsed.baseCommand}:` : parsed.baseCommand;
1207
+ if (this.registeredKeys.has(key)) return this;
1208
+ this.registeredKeys.add(key);
1209
+ this.commands.push(parsed);
881
1210
  return this;
882
1211
  }
883
1212
  /**
1213
+ * A best-effort human label for a command, for diagnostics.
1214
+ *
1215
+ * @param command
1216
+ */
1217
+ describeCommand(command) {
1218
+ return command?.constructor?.name ?? "command";
1219
+ }
1220
+ /**
884
1221
  * Push a list of new commands to commands stack
885
- *
886
- * @param command
1222
+ *
1223
+ * @param command
887
1224
  */
888
1225
  registerCommands(commands) {
889
- commands.forEach(this.addCommand);
1226
+ commands.forEach((e) => this.addCommand(e));
890
1227
  return this;
891
1228
  }
892
1229
  /**
@@ -928,7 +1265,7 @@ var Musket = class Musket {
928
1265
  * Load the root command here
929
1266
  */
930
1267
  const root = new this.config.rootCommand(this.app, this.kernel);
931
- const sign = Signature.parseSignature(root.getSignature(), root);
1268
+ const sign = root.toParsedSignature?.() ?? Signature.parseSignature(root.getSignature(), root);
932
1269
  const cmd = this.program.name(sign.baseCommand).description(sign.description ?? sign.baseCommand).configureHelp({ showGlobalOptions: true }).action(async () => {
933
1270
  root.setInput(this.program.opts(), this.program.args, this.program.registeredArguments, {}, this.program);
934
1271
  await this.handle(root);
@@ -1302,3 +1639,4 @@ exports.Command = Command;
1302
1639
  exports.Kernel = Kernel;
1303
1640
  exports.Musket = Musket;
1304
1641
  exports.Signature = Signature;
1642
+ exports.SignatureBuilder = SignatureBuilder;
package/dist/index.d.ts CHANGED
@@ -327,6 +327,168 @@ declare class Kernel<A extends Application = Application> {
327
327
  getVersionString(): string;
328
328
  }
329
329
  //#endregion
330
+ //#region src/SignatureBuilder.d.ts
331
+ type SignatureValue = string | number | boolean | string[] | undefined;
332
+ /**
333
+ * Definition of a positional argument added via {@link SignatureBuilder.argument}.
334
+ */
335
+ interface ArgumentDefinition {
336
+ /** Human readable description. */
337
+ description?: string;
338
+ /** Whether the argument must be provided. Defaults to `true`. */
339
+ required?: boolean;
340
+ /** A default value. Implies the argument is optional. */
341
+ default?: SignatureValue;
342
+ /** Whether the argument accepts multiple (variadic) values. */
343
+ multiple?: boolean;
344
+ /** Restrict the accepted values to this list. */
345
+ choices?: string[];
346
+ }
347
+ /**
348
+ * Definition of an option/flag added via {@link SignatureBuilder.option}.
349
+ *
350
+ * By default an option is a boolean flag. Give it a `default`, set
351
+ * `requiresValue`, or set `optionalValue` to make it take a value.
352
+ */
353
+ interface OptionDefinition {
354
+ /** Human readable description. */
355
+ description?: string;
356
+ /** A single character short alias, e.g. `Q` for `-Q`. */
357
+ short?: string;
358
+ /** A default value. Implies the option takes an optional value. */
359
+ default?: SignatureValue;
360
+ /** The option requires a value (`--queue <value>`). */
361
+ requiresValue?: boolean;
362
+ /** The option takes an optional value (`--queue [value]`). */
363
+ optionalValue?: boolean;
364
+ /** Restrict the accepted values to this list. */
365
+ choices?: string[];
366
+ /** Share this option with sub-commands of a namespace command. */
367
+ shared?: boolean;
368
+ /** Hide this option from help output. */
369
+ hidden?: boolean;
370
+ }
371
+ /**
372
+ * A fluent builder for constructing a command's signature programmatically,
373
+ * without authoring the string DSL by hand.
374
+ *
375
+ * ```ts
376
+ * buildSignature (sig: SignatureBuilder) {
377
+ * return sig
378
+ * .command('queue:work')
379
+ * .describe('Process jobs on the queue')
380
+ * .argument('connection', { description: 'The connection to work', required: false })
381
+ * .option('queue', { description: 'The queue to process', short: 'Q' })
382
+ * .option('once', { description: 'Process a single job and exit' })
383
+ * }
384
+ * ```
385
+ *
386
+ * Musket consumes the builder directly (lossless) for normal commands, and can
387
+ * also reconstruct the equivalent signature string via {@link toString} for
388
+ * anything that still expects the DSL.
389
+ */
390
+ declare class SignatureBuilder {
391
+ private baseName;
392
+ private commandDescription?;
393
+ private hidden;
394
+ private args;
395
+ private opts;
396
+ /**
397
+ * Set the command name, e.g. `make:model` or `queue:work`. A trailing `:`
398
+ * marks a namespace command.
399
+ */
400
+ command(name: string): this;
401
+ /**
402
+ * Alias for {@link command}.
403
+ *
404
+ * @param name
405
+ * @returns
406
+ */
407
+ name(name: string): this;
408
+ /**
409
+ * Set the command description.
410
+ *
411
+ * @param name
412
+ * @returns
413
+ */
414
+ describe(description: string): this;
415
+ /**
416
+ * Alias for {@link describe}.
417
+ *
418
+ * @param name
419
+ * @returns
420
+ */
421
+ description(description: string): this;
422
+ /**
423
+ * Mark the command as hidden from help output.
424
+ *
425
+ * @param name
426
+ * @returns
427
+ */
428
+ hide(hidden?: boolean): this;
429
+ /**
430
+ * Add a positional argument.
431
+ *
432
+ * @param name
433
+ * @returns
434
+ */
435
+ argument(name: string, definition?: ArgumentDefinition): this;
436
+ /**
437
+ * Add an option/flag.
438
+ *
439
+ * @param name
440
+ * @returns
441
+ */
442
+ option(name: string, definition?: OptionDefinition): this;
443
+ /**
444
+ * The configured command name (empty when unset).
445
+ *
446
+ * @param name
447
+ * @returns
448
+ */
449
+ getName(): string;
450
+ /**
451
+ * The configured description, if any.
452
+ *
453
+ * @param name
454
+ * @returns
455
+ */
456
+ getDescription(): string | undefined;
457
+ /**
458
+ * Whether the command is a namespace command (name ends with `:`).
459
+ *
460
+ * @param name
461
+ * @returns
462
+ */
463
+ isNamespace(): boolean;
464
+ /**
465
+ * Whether anything has been configured on this builder.
466
+ *
467
+ * @param name
468
+ * @returns
469
+ */
470
+ isEmpty(): boolean;
471
+ /**
472
+ * Build the structured {@link ParsedCommand} consumed by the kernel. This is
473
+ * lossless — no string round-trip — and is the path used for normal commands.
474
+ *
475
+ * @param commandClass
476
+ * @returns
477
+ */
478
+ toParsed<A extends Application = Application>(commandClass: Command<A>): ParsedCommand<A>;
479
+ private argumentToOption;
480
+ private optionToOption;
481
+ /**
482
+ * Reconstruct the equivalent signature string in musket's DSL. Used by
483
+ * {@link Command.getSignature} so external consumers (and the namespace path)
484
+ * keep working unchanged.
485
+ */
486
+ toString(): string;
487
+ private argumentToken;
488
+ private optionToken;
489
+ private wrap;
490
+ }
491
+ //#endregion
330
492
  //#region src/Core/Command.d.ts
331
493
  declare class Command<A extends Application = Application> {
332
494
  protected app: A;
@@ -356,6 +518,11 @@ declare class Command<A extends Application = Application> {
356
518
  * @var string
357
519
  */
358
520
  protected description?: string;
521
+ /**
522
+ * Memoized result of {@link resolveBuilder}. `undefined` means "not yet
523
+ * resolved"; `null` means "resolved, no builder in use".
524
+ */
525
+ private resolvedBuilder?;
359
526
  /**
360
527
  * The console command input.
361
528
  *
@@ -409,11 +576,46 @@ declare class Command<A extends Application = Application> {
409
576
  */
410
577
  setProgram(program: Command$1): this;
411
578
  /**
412
- * Get the command signature
579
+ * Define the command signature programmatically.
580
+ *
581
+ * Override this instead of (or in addition to) the {@link signature} string
582
+ * to describe the command, its arguments and its options through the fluent
583
+ * {@link SignatureBuilder} — no string DSL required. When the `signature`
584
+ * string is set it always takes precedence and this method is ignored.
585
+ *
586
+ * ```ts
587
+ * buildSignature (sig: SignatureBuilder) {
588
+ * return sig
589
+ * .command('queue:work')
590
+ * .describe('Process jobs on the queue')
591
+ * .argument('connection', { required: false })
592
+ * .option('queue', { short: 'Q', description: 'The queue to process' })
593
+ * .option('once', { description: 'Process a single job and exit' })
594
+ * }
595
+ * ```
596
+ *
597
+ * @param _builder A fresh builder to configure.
598
+ */
599
+ protected buildSignature(_builder: SignatureBuilder): SignatureBuilder | void;
600
+ /**
601
+ * Resolve (and memoize) the signature builder for this command, or `null`
602
+ * when the command uses the string {@link signature} (which always wins) or
603
+ * does not implement {@link buildSignature}.
604
+ */
605
+ private resolveBuilder;
606
+ /**
607
+ * Get the command signature. Returns the string {@link signature} when set,
608
+ * otherwise reconstructs it from {@link buildSignature}.
413
609
  *
414
610
  * @returns
415
611
  */
416
612
  getSignature(): string;
613
+ /**
614
+ * The structured, lossless parsed command when {@link buildSignature} is used
615
+ * for a non-namespace command; `undefined` otherwise (callers then fall back
616
+ * to parsing {@link getSignature}). This avoids a lossy string round-trip.
617
+ */
618
+ toParsedSignature(): ParsedCommand<A> | undefined;
417
619
  /**
418
620
  * Get the command description
419
621
  *
@@ -626,6 +828,13 @@ declare class Musket<A extends Application = Application> {
626
828
  name: string;
627
829
  private config;
628
830
  private commands;
831
+ /**
832
+ * Keys (baseCommand, namespace-aware) of commands already registered, so the
833
+ * same command surfacing from more than one source — e.g. discovered both as
834
+ * built `dist/*.js` and as `src/*.ts` via the jiti loader — is only added
835
+ * once. The first registration wins (base commands before discovered ones).
836
+ */
837
+ private registeredKeys;
629
838
  private program;
630
839
  constructor(app: A, kernel: Kernel<A>, baseCommands?: Command<A>[], resolver?: CommandMethodResolver | undefined, tsDownConfig?: UserConfig);
631
840
  build(): Promise<Command$1>;
@@ -674,6 +883,21 @@ declare class Musket<A extends Application = Application> {
674
883
  * @param name The file name without extension.
675
884
  */
676
885
  private resolveCommandClass;
886
+ /**
887
+ * Whether a value is a Command class (constructor), as opposed to any other
888
+ * exported function/class.
889
+ *
890
+ * The check is structural — it looks for `getSignature` on the prototype
891
+ * rather than using `instanceof Command` — so it stays correct when the
892
+ * discovered command extends a Command from a different copy/version of
893
+ * musket. Crucially, it rejects non-command exports such as the shared
894
+ * bundler chunks tools like tsdown can emit alongside built commands (e.g. a
895
+ * `Rebuilder` helper), which would otherwise be `new`-ed and then crash when
896
+ * `getSignature()` is called on them.
897
+ *
898
+ * @param value The candidate export.
899
+ */
900
+ private isCommandClass;
677
901
  /**
678
902
  * Emit a non-fatal command-discovery warning.
679
903
  *
@@ -681,11 +905,23 @@ declare class Musket<A extends Application = Application> {
681
905
  */
682
906
  private warnDiscovery;
683
907
  /**
684
- * Push a new command into the commands stack
908
+ * Push a new command into the commands stack.
909
+ *
910
+ * Resolution prefers a command's structured signature (built via
911
+ * {@link Command.buildSignature}) and falls back to parsing its signature
912
+ * string. Commands that expose no usable signature are skipped with a warning
913
+ * rather than crashing the CLI, and a command whose key was already
914
+ * registered is ignored (de-duplication — see {@link registeredKeys}).
685
915
  *
686
916
  * @param command
687
917
  */
688
918
  addCommand(command: Command<A>): this;
919
+ /**
920
+ * A best-effort human label for a command, for diagnostics.
921
+ *
922
+ * @param command
923
+ */
924
+ private describeCommand;
689
925
  /**
690
926
  * Push a list of new commands to commands stack
691
927
  *
@@ -736,4 +972,4 @@ declare class Signature {
736
972
  static parseSignature<A extends Application = Application>(signature: string, commandClass: Command<A>): ParsedCommand<A>;
737
973
  }
738
974
  //#endregion
739
- export { Application, Command, CommandMethodResolver, CommandOption, Kernel, KernelConfig, ModuleMeta, Musket, PackageMeta, ParsedCommand, Signature, TGeneric, VersionRenderHelpers, XGeneric };
975
+ export { Application, ArgumentDefinition, Command, CommandMethodResolver, CommandOption, Kernel, KernelConfig, ModuleMeta, Musket, OptionDefinition, PackageMeta, ParsedCommand, Signature, SignatureBuilder, SignatureValue, TGeneric, VersionRenderHelpers, XGeneric };
package/dist/index.js CHANGED
@@ -5,6 +5,232 @@ import { glob } from "glob";
5
5
  import path from "node:path";
6
6
  import { createRequire } from "module";
7
7
  import { mkdir } from "node:fs/promises";
8
+ //#region src/SignatureBuilder.ts
9
+ /**
10
+ * A fluent builder for constructing a command's signature programmatically,
11
+ * without authoring the string DSL by hand.
12
+ *
13
+ * ```ts
14
+ * buildSignature (sig: SignatureBuilder) {
15
+ * return sig
16
+ * .command('queue:work')
17
+ * .describe('Process jobs on the queue')
18
+ * .argument('connection', { description: 'The connection to work', required: false })
19
+ * .option('queue', { description: 'The queue to process', short: 'Q' })
20
+ * .option('once', { description: 'Process a single job and exit' })
21
+ * }
22
+ * ```
23
+ *
24
+ * Musket consumes the builder directly (lossless) for normal commands, and can
25
+ * also reconstruct the equivalent signature string via {@link toString} for
26
+ * anything that still expects the DSL.
27
+ */
28
+ var SignatureBuilder = class {
29
+ baseName = "";
30
+ commandDescription;
31
+ hidden = false;
32
+ args = [];
33
+ opts = [];
34
+ /**
35
+ * Set the command name, e.g. `make:model` or `queue:work`. A trailing `:`
36
+ * marks a namespace command.
37
+ */
38
+ command(name) {
39
+ this.baseName = name.trim();
40
+ return this;
41
+ }
42
+ /**
43
+ * Alias for {@link command}.
44
+ *
45
+ * @param name
46
+ * @returns
47
+ */
48
+ name(name) {
49
+ return this.command(name);
50
+ }
51
+ /**
52
+ * Set the command description.
53
+ *
54
+ * @param name
55
+ * @returns
56
+ */
57
+ describe(description) {
58
+ this.commandDescription = description;
59
+ return this;
60
+ }
61
+ /**
62
+ * Alias for {@link describe}.
63
+ *
64
+ * @param name
65
+ * @returns
66
+ */
67
+ description(description) {
68
+ return this.describe(description);
69
+ }
70
+ /**
71
+ * Mark the command as hidden from help output.
72
+ *
73
+ * @param name
74
+ * @returns
75
+ */
76
+ hide(hidden = true) {
77
+ this.hidden = hidden;
78
+ return this;
79
+ }
80
+ /**
81
+ * Add a positional argument.
82
+ *
83
+ * @param name
84
+ * @returns
85
+ */
86
+ argument(name, definition = {}) {
87
+ this.args.push({
88
+ name: name.trim(),
89
+ ...definition
90
+ });
91
+ return this;
92
+ }
93
+ /**
94
+ * Add an option/flag.
95
+ *
96
+ * @param name
97
+ * @returns
98
+ */
99
+ option(name, definition = {}) {
100
+ this.opts.push({
101
+ name: name.replace(/^--?/, "").trim(),
102
+ ...definition
103
+ });
104
+ return this;
105
+ }
106
+ /**
107
+ * The configured command name (empty when unset).
108
+ *
109
+ * @param name
110
+ * @returns
111
+ */
112
+ getName() {
113
+ return this.baseName;
114
+ }
115
+ /**
116
+ * The configured description, if any.
117
+ *
118
+ * @param name
119
+ * @returns
120
+ */
121
+ getDescription() {
122
+ return this.commandDescription;
123
+ }
124
+ /**
125
+ * Whether the command is a namespace command (name ends with `:`).
126
+ *
127
+ * @param name
128
+ * @returns
129
+ */
130
+ isNamespace() {
131
+ return this.baseName.endsWith(":");
132
+ }
133
+ /**
134
+ * Whether anything has been configured on this builder.
135
+ *
136
+ * @param name
137
+ * @returns
138
+ */
139
+ isEmpty() {
140
+ return this.baseName.length === 0;
141
+ }
142
+ /**
143
+ * Build the structured {@link ParsedCommand} consumed by the kernel. This is
144
+ * lossless — no string round-trip — and is the path used for normal commands.
145
+ *
146
+ * @param commandClass
147
+ * @returns
148
+ */
149
+ toParsed(commandClass) {
150
+ const options = [...this.args.map((arg) => this.argumentToOption(arg)), ...this.opts.map((opt) => this.optionToOption(opt))];
151
+ return {
152
+ baseCommand: this.baseName,
153
+ isNamespaceCommand: false,
154
+ description: this.commandDescription ?? commandClass.getDescription(),
155
+ commandClass,
156
+ options,
157
+ isHidden: this.hidden
158
+ };
159
+ }
160
+ argumentToOption(arg) {
161
+ const required = arg.required ?? arg.default === void 0;
162
+ const multiple = arg.multiple ?? false;
163
+ let placeholder;
164
+ if (multiple) placeholder = required ? `<${arg.name}...>` : `[${arg.name}...]`;
165
+ return {
166
+ name: arg.name,
167
+ isFlag: false,
168
+ required,
169
+ multiple,
170
+ description: arg.description ?? "",
171
+ choices: arg.choices ?? [],
172
+ defaultValue: arg.default,
173
+ placeholder
174
+ };
175
+ }
176
+ optionToOption(opt) {
177
+ const long = `--${opt.name}`;
178
+ const flags = opt.short ? [`-${opt.short}`, long] : [long];
179
+ const takesValue = opt.default !== void 0 || opt.requiresValue === true || opt.optionalValue === true || (opt.choices?.length ?? 0) > 0;
180
+ let required = false;
181
+ let placeholder;
182
+ if (takesValue) if (opt.requiresValue === true && opt.default === void 0 && opt.optionalValue !== true) required = true;
183
+ else placeholder = `[${opt.name}]`;
184
+ return {
185
+ name: long,
186
+ flags,
187
+ isFlag: true,
188
+ required,
189
+ placeholder,
190
+ description: opt.description ?? "",
191
+ choices: opt.choices ?? [],
192
+ defaultValue: opt.default ?? (takesValue ? void 0 : false),
193
+ shared: opt.shared,
194
+ isHidden: opt.hidden
195
+ };
196
+ }
197
+ /**
198
+ * Reconstruct the equivalent signature string in musket's DSL. Used by
199
+ * {@link Command.getSignature} so external consumers (and the namespace path)
200
+ * keep working unchanged.
201
+ */
202
+ toString() {
203
+ const tokens = [this.baseName];
204
+ for (const arg of this.args) tokens.push(this.argumentToken(arg));
205
+ for (const opt of this.opts) tokens.push(this.optionToken(opt));
206
+ return tokens.join("\n ");
207
+ }
208
+ argumentToken(arg) {
209
+ const required = arg.required ?? arg.default === void 0;
210
+ const multiple = arg.multiple ?? false;
211
+ let token = arg.name;
212
+ if (arg.default !== void 0) token += `=${arg.default}`;
213
+ else if (!required && multiple) token += "?*";
214
+ else if (multiple) token += "*";
215
+ else if (!required) token += "?";
216
+ return this.wrap(token, arg.description, arg.choices);
217
+ }
218
+ optionToken(opt) {
219
+ const takesValue = opt.default !== void 0 || opt.requiresValue === true || opt.optionalValue === true;
220
+ let token = opt.short ? `--${opt.short}|${opt.name}` : `--${opt.name}`;
221
+ if (opt.default !== void 0) token += `=${opt.default}`;
222
+ else if (opt.requiresValue === true && opt.optionalValue !== true) token += "=";
223
+ else if (opt.optionalValue === true) token += "?";
224
+ return this.wrap(token, opt.description, opt.choices, takesValue);
225
+ }
226
+ wrap(token, description, choices, _value) {
227
+ let body = token;
228
+ if (description) body += ` : ${description}`;
229
+ if (choices?.length) body += ` : [${choices.join(", ")}]`;
230
+ return `{${body}}`;
231
+ }
232
+ };
233
+ //#endregion
8
234
  //#region src/Core/Command.ts
9
235
  var Command = class {
10
236
  app;
@@ -38,6 +264,11 @@ var Command = class {
38
264
  */
39
265
  description;
40
266
  /**
267
+ * Memoized result of {@link resolveBuilder}. `undefined` means "not yet
268
+ * resolved"; `null` means "resolved, no builder in use".
269
+ */
270
+ resolvedBuilder;
271
+ /**
41
272
  * The console command input.
42
273
  *
43
274
  * @var object
@@ -118,20 +349,70 @@ var Command = class {
118
349
  return this;
119
350
  }
120
351
  /**
121
- * Get the command signature
122
- *
123
- * @returns
352
+ * Define the command signature programmatically.
353
+ *
354
+ * Override this instead of (or in addition to) the {@link signature} string
355
+ * to describe the command, its arguments and its options through the fluent
356
+ * {@link SignatureBuilder} — no string DSL required. When the `signature`
357
+ * string is set it always takes precedence and this method is ignored.
358
+ *
359
+ * ```ts
360
+ * buildSignature (sig: SignatureBuilder) {
361
+ * return sig
362
+ * .command('queue:work')
363
+ * .describe('Process jobs on the queue')
364
+ * .argument('connection', { required: false })
365
+ * .option('queue', { short: 'Q', description: 'The queue to process' })
366
+ * .option('once', { description: 'Process a single job and exit' })
367
+ * }
368
+ * ```
369
+ *
370
+ * @param _builder A fresh builder to configure.
371
+ */
372
+ buildSignature(_builder) {
373
+ /** Override to use. */
374
+ }
375
+ /**
376
+ * Resolve (and memoize) the signature builder for this command, or `null`
377
+ * when the command uses the string {@link signature} (which always wins) or
378
+ * does not implement {@link buildSignature}.
379
+ */
380
+ resolveBuilder() {
381
+ if (this.resolvedBuilder !== void 0) return this.resolvedBuilder;
382
+ if (this.signature) return this.resolvedBuilder = null;
383
+ const builder = new SignatureBuilder();
384
+ const result = this.buildSignature(builder);
385
+ const resolved = result instanceof SignatureBuilder ? result : builder;
386
+ return this.resolvedBuilder = resolved.isEmpty() ? null : resolved;
387
+ }
388
+ /**
389
+ * Get the command signature. Returns the string {@link signature} when set,
390
+ * otherwise reconstructs it from {@link buildSignature}.
391
+ *
392
+ * @returns
124
393
  */
125
394
  getSignature() {
126
- return this.signature;
395
+ if (this.signature) return this.signature;
396
+ return this.resolveBuilder()?.toString() ?? this.signature;
397
+ }
398
+ /**
399
+ * The structured, lossless parsed command when {@link buildSignature} is used
400
+ * for a non-namespace command; `undefined` otherwise (callers then fall back
401
+ * to parsing {@link getSignature}). This avoids a lossy string round-trip.
402
+ */
403
+ toParsedSignature() {
404
+ const builder = this.resolveBuilder();
405
+ if (!builder || builder.isNamespace()) return;
406
+ return builder.toParsed(this);
127
407
  }
128
408
  /**
129
409
  * Get the command description
130
- *
131
- * @returns
410
+ *
411
+ * @returns
132
412
  */
133
413
  getDescription() {
134
- return this.description;
414
+ if (this.description) return this.description;
415
+ return this.resolveBuilder()?.getDescription() ?? this.description;
135
416
  }
136
417
  /**
137
418
  * Get a specific option
@@ -733,6 +1014,13 @@ var Musket = class Musket {
733
1014
  name = "musket";
734
1015
  config = {};
735
1016
  commands = [];
1017
+ /**
1018
+ * Keys (baseCommand, namespace-aware) of commands already registered, so the
1019
+ * same command surfacing from more than one source — e.g. discovered both as
1020
+ * built `dist/*.js` and as `src/*.ts` via the jiti loader — is only added
1021
+ * once. The first registration wins (base commands before discovered ones).
1022
+ */
1023
+ registeredKeys = /* @__PURE__ */ new Set();
736
1024
  program;
737
1025
  constructor(app, kernel, baseCommands = [], resolver, tsDownConfig = {}) {
738
1026
  this.app = app;
@@ -835,9 +1123,26 @@ var Musket = class Musket {
835
1123
  */
836
1124
  resolveCommandClass(mod, name) {
837
1125
  const named = mod[name];
838
- if (typeof named === "function") return named;
839
- if (typeof mod.default === "function") return mod.default;
840
- return Object.values(mod).find((value) => typeof value === "function");
1126
+ if (this.isCommandClass(named)) return named;
1127
+ if (this.isCommandClass(mod.default)) return mod.default;
1128
+ return Object.values(mod).find((value) => this.isCommandClass(value));
1129
+ }
1130
+ /**
1131
+ * Whether a value is a Command class (constructor), as opposed to any other
1132
+ * exported function/class.
1133
+ *
1134
+ * The check is structural — it looks for `getSignature` on the prototype
1135
+ * rather than using `instanceof Command` — so it stays correct when the
1136
+ * discovered command extends a Command from a different copy/version of
1137
+ * musket. Crucially, it rejects non-command exports such as the shared
1138
+ * bundler chunks tools like tsdown can emit alongside built commands (e.g. a
1139
+ * `Rebuilder` helper), which would otherwise be `new`-ed and then crash when
1140
+ * `getSignature()` is called on them.
1141
+ *
1142
+ * @param value The candidate export.
1143
+ */
1144
+ isCommandClass(value) {
1145
+ return typeof value === "function" && typeof value.prototype?.getSignature === "function";
841
1146
  }
842
1147
  /**
843
1148
  * Emit a non-fatal command-discovery warning.
@@ -848,21 +1153,53 @@ var Musket = class Musket {
848
1153
  Logger.log([["[musket]", "yellow"], [message, "white"]], " ");
849
1154
  }
850
1155
  /**
851
- * Push a new command into the commands stack
852
- *
853
- * @param command
1156
+ * Push a new command into the commands stack.
1157
+ *
1158
+ * Resolution prefers a command's structured signature (built via
1159
+ * {@link Command.buildSignature}) and falls back to parsing its signature
1160
+ * string. Commands that expose no usable signature are skipped with a warning
1161
+ * rather than crashing the CLI, and a command whose key was already
1162
+ * registered is ignored (de-duplication — see {@link registeredKeys}).
1163
+ *
1164
+ * @param command
854
1165
  */
855
1166
  addCommand(command) {
856
- this.commands.push(Signature.parseSignature(command.getSignature(), command));
1167
+ if (!command || typeof command.getSignature !== "function") {
1168
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: not a valid command (no getSignature()).`);
1169
+ return this;
1170
+ }
1171
+ let parsed;
1172
+ try {
1173
+ parsed = command.toParsedSignature?.() ?? Signature.parseSignature(command.getSignature(), command);
1174
+ } catch (error) {
1175
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: ${error?.message ?? String(error)}`);
1176
+ return this;
1177
+ }
1178
+ if (!parsed || !parsed.baseCommand) {
1179
+ this.warnDiscovery(`Skipping ${this.describeCommand(command)}: empty or unparsable signature.`);
1180
+ return this;
1181
+ }
1182
+ const key = parsed.isNamespaceCommand ? `${parsed.baseCommand}:` : parsed.baseCommand;
1183
+ if (this.registeredKeys.has(key)) return this;
1184
+ this.registeredKeys.add(key);
1185
+ this.commands.push(parsed);
857
1186
  return this;
858
1187
  }
859
1188
  /**
1189
+ * A best-effort human label for a command, for diagnostics.
1190
+ *
1191
+ * @param command
1192
+ */
1193
+ describeCommand(command) {
1194
+ return command?.constructor?.name ?? "command";
1195
+ }
1196
+ /**
860
1197
  * Push a list of new commands to commands stack
861
- *
862
- * @param command
1198
+ *
1199
+ * @param command
863
1200
  */
864
1201
  registerCommands(commands) {
865
- commands.forEach(this.addCommand);
1202
+ commands.forEach((e) => this.addCommand(e));
866
1203
  return this;
867
1204
  }
868
1205
  /**
@@ -904,7 +1241,7 @@ var Musket = class Musket {
904
1241
  * Load the root command here
905
1242
  */
906
1243
  const root = new this.config.rootCommand(this.app, this.kernel);
907
- const sign = Signature.parseSignature(root.getSignature(), root);
1244
+ const sign = root.toParsedSignature?.() ?? Signature.parseSignature(root.getSignature(), root);
908
1245
  const cmd = this.program.name(sign.baseCommand).description(sign.description ?? sign.baseCommand).configureHelp({ showGlobalOptions: true }).action(async () => {
909
1246
  root.setInput(this.program.opts(), this.program.args, this.program.registeredArguments, {}, this.program);
910
1247
  await this.handle(root);
@@ -1274,4 +1611,4 @@ var Kernel = class Kernel {
1274
1611
  }
1275
1612
  };
1276
1613
  //#endregion
1277
- export { Command, Kernel, Musket, Signature };
1614
+ export { Command, Kernel, Musket, Signature, SignatureBuilder };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@h3ravel/musket",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Musket CLI is a framework-agnostic CLI framework designed to allow you build artisan-like CLI apps and for use in the H3ravel framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",