@guanghechen/commander 1.0.12 → 2.1.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/lib/esm/index.mjs CHANGED
@@ -1,155 +1,1034 @@
1
- import { Command as Command$1 } from 'commander';
2
- import { commandExistsSync } from '@guanghechen/cli';
3
- export * from '@guanghechen/cli';
4
- import { safeExec } from '@guanghechen/exec';
5
- import select from '@inquirer/select';
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
6
3
 
7
- class Command extends Command$1 {
8
- _actionHandler;
9
- _optionValues;
10
- _storeOptionsAsProperties;
11
- _version;
12
- _versionOptionName;
13
- constructor() {
14
- super();
15
- this._optionValues = {};
16
- this._storeOptionsAsProperties = false;
4
+ class CommanderError extends Error {
5
+ kind;
6
+ commandPath;
7
+ constructor(kind, message, commandPath) {
8
+ super(message);
9
+ this.name = 'CommanderError';
10
+ this.kind = kind;
11
+ this.commandPath = commandPath;
12
+ }
13
+ format() {
14
+ return `Error: ${this.message}\nRun "${this.commandPath} --help" for usage.`;
15
+ }
16
+ }
17
+
18
+ class DefaultReporter {
19
+ debug(message, ...args) {
20
+ console.debug(message, ...args);
21
+ }
22
+ info(message, ...args) {
23
+ console.info(message, ...args);
24
+ }
25
+ warn(message, ...args) {
26
+ console.warn(message, ...args);
27
+ }
28
+ error(message, ...args) {
29
+ console.error(message, ...args);
30
+ }
31
+ }
32
+ const BUILTIN_HELP_OPTION = {
33
+ long: 'help',
34
+ short: 'h',
35
+ type: 'boolean',
36
+ description: 'Show help information',
37
+ };
38
+ const BUILTIN_VERSION_OPTION = {
39
+ long: 'version',
40
+ short: 'V',
41
+ type: 'boolean',
42
+ description: 'Show version number',
43
+ };
44
+ class Command {
45
+ #name;
46
+ #description;
47
+ #version;
48
+ #aliases;
49
+ #helpSubcommandEnabled;
50
+ #options = [];
51
+ #arguments = [];
52
+ #subcommands = [];
53
+ #action;
54
+ #parent;
55
+ constructor(config) {
56
+ this.#name = config.name;
57
+ this.#description = config.description;
58
+ this.#version = config.version;
59
+ this.#aliases = config.aliases ?? [];
60
+ this.#helpSubcommandEnabled = config.help ?? false;
61
+ }
62
+ get name() {
63
+ return this.#name;
64
+ }
65
+ get aliases() {
66
+ return this.#aliases;
67
+ }
68
+ get description() {
69
+ return this.#description;
70
+ }
71
+ get version() {
72
+ return this.#version ?? this.#parent?.version;
73
+ }
74
+ get parent() {
75
+ return this.#parent;
76
+ }
77
+ get options() {
78
+ return [...this.#options];
79
+ }
80
+ get arguments() {
81
+ return [...this.#arguments];
82
+ }
83
+ option(opt) {
84
+ this.#validateOptionConfig(opt);
85
+ this.#checkOptionUniqueness(opt);
86
+ this.#options.push(opt);
87
+ return this;
88
+ }
89
+ argument(arg) {
90
+ this.#validateArgumentConfig(arg);
91
+ this.#arguments.push(arg);
92
+ return this;
17
93
  }
18
94
  action(fn) {
19
- const listener = (args) => {
20
- const expectedArgsCount = this.registeredArguments.length;
21
- const actionArgs = [
22
- args.slice(0, expectedArgsCount),
23
- this.opts(),
24
- args.slice(expectedArgsCount),
25
- this,
26
- ];
27
- const actionResult = fn.apply(this, actionArgs);
28
- return actionResult;
29
- };
30
- this._actionHandler = listener;
95
+ this.#action = fn;
31
96
  return this;
32
97
  }
33
- opts() {
34
- const nodes = [this];
35
- for (let parent = this.parent; parent != null; parent = parent.parent) {
36
- nodes.push(parent);
98
+ subcommand(cmd) {
99
+ if (this.#helpSubcommandEnabled && (cmd.#name === 'help' || cmd.#aliases.includes('help'))) {
100
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
101
+ }
102
+ cmd.#parent = this;
103
+ this.#subcommands.push(cmd);
104
+ return this;
105
+ }
106
+ async run(params) {
107
+ const { argv, envs, reporter } = params;
108
+ try {
109
+ const processedArgv = this.#processHelpSubcommand(argv);
110
+ const { command, remaining } = this.#route(processedArgv);
111
+ const allOptions = command.#getMergedOptions();
112
+ const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
113
+ const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
114
+ if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
115
+ console.log(command.formatHelp());
116
+ return;
117
+ }
118
+ if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
119
+ console.log(command.version ?? 'unknown');
120
+ return;
121
+ }
122
+ const { opts, args } = command.parse(remaining);
123
+ const ctx = {
124
+ cmd: command,
125
+ envs,
126
+ reporter: reporter ?? new DefaultReporter(),
127
+ argv,
128
+ };
129
+ for (const opt of allOptions) {
130
+ if (opt.apply && opts[opt.long] !== undefined) {
131
+ opt.apply(opts[opt.long], ctx);
132
+ }
133
+ }
134
+ const actionParams = { ctx, opts, args };
135
+ if (command.#action) {
136
+ try {
137
+ await command.#action(actionParams);
138
+ }
139
+ catch (err) {
140
+ if (err instanceof Error) {
141
+ console.error(`Error: ${err.message}`);
142
+ }
143
+ else {
144
+ console.error('Error: action failed');
145
+ }
146
+ process.exit(1);
147
+ }
148
+ }
149
+ else if (command.#subcommands.length > 0) {
150
+ console.log(command.formatHelp());
151
+ }
152
+ else {
153
+ throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
154
+ }
155
+ }
156
+ catch (err) {
157
+ if (err instanceof CommanderError) {
158
+ console.error(err.format());
159
+ process.exit(2);
160
+ return;
161
+ }
162
+ throw err;
163
+ }
164
+ }
165
+ parse(argv) {
166
+ const allOptions = this.#getMergedOptions();
167
+ const opts = {};
168
+ const args = [];
169
+ for (const opt of allOptions) {
170
+ if (opt.default !== undefined) {
171
+ opts[opt.long] = opt.default;
172
+ }
173
+ else if (opt.type === 'boolean') {
174
+ opts[opt.long] = false;
175
+ }
176
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
177
+ opts[opt.long] = [];
178
+ }
179
+ }
180
+ let remaining = [...argv];
181
+ const resolverOptions = allOptions.filter(o => o.resolver);
182
+ for (const opt of resolverOptions) {
183
+ const result = opt.resolver(remaining);
184
+ opts[opt.long] = result.value;
185
+ remaining = result.remaining;
186
+ }
187
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
188
+ remaining = this.#normalizeArgv(remaining, booleanOptions);
189
+ let i = 0;
190
+ while (i < remaining.length) {
191
+ const token = remaining[i];
192
+ if (token === '--') {
193
+ args.push(...remaining.slice(i + 1));
194
+ break;
195
+ }
196
+ if (token.startsWith('--')) {
197
+ i = this.#parseLongOption(remaining, i, optionByLong, opts);
198
+ continue;
199
+ }
200
+ if (token.startsWith('-') && token.length > 1) {
201
+ i = this.#parseShortOption(remaining, i, optionByShort, opts);
202
+ continue;
203
+ }
204
+ args.push(token);
205
+ i += 1;
206
+ }
207
+ for (const opt of allOptions) {
208
+ if (opt.required && opts[opt.long] === undefined) {
209
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
210
+ }
211
+ }
212
+ for (const opt of allOptions) {
213
+ if (opt.choices && opts[opt.long] !== undefined) {
214
+ const value = opts[opt.long];
215
+ const values = Array.isArray(value) ? value : [value];
216
+ const choices = opt.choices;
217
+ for (const v of values) {
218
+ if (!choices.includes(v)) {
219
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
220
+ }
221
+ }
222
+ }
223
+ }
224
+ const requiredArgs = this.#arguments.filter(a => a.kind === 'required');
225
+ if (args.length < requiredArgs.length) {
226
+ const missing = requiredArgs.slice(args.length).map(a => a.name);
227
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, this.#getCommandPath());
228
+ }
229
+ return { opts, args };
230
+ }
231
+ formatHelp() {
232
+ const lines = [];
233
+ const allOptions = this.#getMergedOptions();
234
+ lines.push(this.#description);
235
+ lines.push('');
236
+ const commandPath = this.#getCommandPath();
237
+ let usage = `Usage: ${commandPath}`;
238
+ if (allOptions.length > 0)
239
+ usage += ' [options]';
240
+ if (this.#subcommands.length > 0)
241
+ usage += ' [command]';
242
+ for (const arg of this.#arguments) {
243
+ if (arg.kind === 'required') {
244
+ usage += ` <${arg.name}>`;
245
+ }
246
+ else if (arg.kind === 'optional') {
247
+ usage += ` [${arg.name}]`;
248
+ }
249
+ else {
250
+ usage += ` [${arg.name}...]`;
251
+ }
252
+ }
253
+ lines.push(usage);
254
+ lines.push('');
255
+ if (allOptions.length > 0) {
256
+ lines.push('Options:');
257
+ const optLines = [];
258
+ for (const opt of allOptions) {
259
+ let sig = opt.short ? `-${opt.short}, ` : ' ';
260
+ sig += `--${opt.long}`;
261
+ const effectiveType = opt.type ?? 'string';
262
+ if (effectiveType !== 'boolean') {
263
+ sig += ' <value>';
264
+ }
265
+ let desc = opt.description;
266
+ if (opt.default !== undefined && effectiveType !== 'boolean') {
267
+ desc += ` (default: ${JSON.stringify(opt.default)})`;
268
+ }
269
+ if (opt.choices) {
270
+ desc += ` [choices: ${opt.choices.join(', ')}]`;
271
+ }
272
+ optLines.push({ sig, desc });
273
+ if (effectiveType === 'boolean') {
274
+ optLines.push({
275
+ sig: ` --no-${opt.long}`,
276
+ desc: opt.description,
277
+ });
278
+ }
279
+ }
280
+ const maxSigLen = Math.max(...optLines.map(l => l.sig.length));
281
+ for (const { sig, desc } of optLines) {
282
+ const padding = ' '.repeat(maxSigLen - sig.length + 2);
283
+ lines.push(` ${sig}${padding}${desc}`);
284
+ }
285
+ lines.push('');
37
286
  }
38
- const options = {};
39
- for (let i = nodes.length - 1; i >= 0; --i) {
40
- const o = nodes[i];
41
- if (o._storeOptionsAsProperties) {
42
- for (const option of o.options) {
43
- const key = option.attributeName();
44
- options[key] = key === o._versionOptionName ? o._version : o[key];
287
+ const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommands.length > 0;
288
+ if (this.#subcommands.length > 0) {
289
+ lines.push('Commands:');
290
+ const cmdLines = [];
291
+ if (showHelpSubcommand) {
292
+ cmdLines.push({ name: 'help', desc: 'Show help for a command' });
293
+ }
294
+ for (const sub of this.#subcommands) {
295
+ let name = sub.#name;
296
+ if (sub.#aliases.length > 0) {
297
+ name += `, ${sub.#aliases.join(', ')}`;
298
+ }
299
+ cmdLines.push({ name, desc: sub.#description });
300
+ }
301
+ const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
302
+ for (const { name, desc } of cmdLines) {
303
+ const padding = ' '.repeat(maxNameLen - name.length + 2);
304
+ lines.push(` ${name}${padding}${desc}`);
305
+ }
306
+ lines.push('');
307
+ }
308
+ return lines.join('\n');
309
+ }
310
+ getCompletionMeta() {
311
+ const allOptions = this.#getMergedOptions();
312
+ const options = [];
313
+ for (const opt of allOptions) {
314
+ const effectiveType = opt.type ?? 'string';
315
+ options.push({
316
+ long: opt.long,
317
+ short: opt.short,
318
+ description: opt.description,
319
+ takesValue: effectiveType !== 'boolean',
320
+ choices: opt.choices,
321
+ });
322
+ }
323
+ return {
324
+ name: this.#name,
325
+ description: this.#description,
326
+ aliases: this.#aliases,
327
+ options,
328
+ subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
329
+ };
330
+ }
331
+ #processHelpSubcommand(argv) {
332
+ if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
333
+ return argv;
334
+ if (argv.length < 1 || argv[0] !== 'help')
335
+ return argv;
336
+ if (argv.length === 1) {
337
+ return ['--help'];
338
+ }
339
+ const subName = argv[1];
340
+ const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
341
+ if (sub) {
342
+ return [subName, '--help', ...argv.slice(2)];
343
+ }
344
+ return argv;
345
+ }
346
+ #route(argv) {
347
+ let current = this;
348
+ let idx = 0;
349
+ while (idx < argv.length) {
350
+ const token = argv[idx];
351
+ if (token.startsWith('-'))
352
+ break;
353
+ const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
354
+ if (!sub)
355
+ break;
356
+ current = sub;
357
+ idx += 1;
358
+ }
359
+ return { command: current, remaining: argv.slice(idx) };
360
+ }
361
+ #parseLongOption(argv, idx, optionByLong, opts) {
362
+ const token = argv[idx];
363
+ const eqIdx = token.indexOf('=');
364
+ let optName;
365
+ let inlineValue;
366
+ if (eqIdx !== -1) {
367
+ optName = token.slice(2, eqIdx);
368
+ inlineValue = token.slice(eqIdx + 1);
369
+ }
370
+ else {
371
+ optName = token.slice(2);
372
+ }
373
+ const opt = optionByLong.get(optName);
374
+ if (!opt) {
375
+ throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
376
+ }
377
+ if (opt.type === 'boolean') {
378
+ if (inlineValue !== undefined) {
379
+ if (inlineValue === 'true') {
380
+ opts[optName] = true;
381
+ }
382
+ else if (inlineValue === 'false') {
383
+ opts[optName] = false;
384
+ }
385
+ else {
386
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
45
387
  }
46
388
  }
47
389
  else {
48
- const optionValues = o._optionValues;
49
- for (const key of Object.getOwnPropertyNames(optionValues)) {
50
- options[key] = optionValues[key];
390
+ opts[optName] = true;
391
+ }
392
+ return idx + 1;
393
+ }
394
+ let value;
395
+ let nextIdx = idx;
396
+ if (inlineValue !== undefined) {
397
+ value = inlineValue;
398
+ }
399
+ else if (idx + 1 < argv.length) {
400
+ value = argv[idx + 1];
401
+ nextIdx += 1;
402
+ }
403
+ else {
404
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
405
+ }
406
+ this.#applyValue(opt, value, opts);
407
+ return nextIdx + 1;
408
+ }
409
+ #parseShortOption(argv, idx, optionByShort, opts) {
410
+ const token = argv[idx];
411
+ if (token.includes('=')) {
412
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
413
+ }
414
+ const flags = token.slice(1);
415
+ for (let j = 0; j < flags.length; j++) {
416
+ const flag = flags[j];
417
+ const opt = optionByShort.get(flag);
418
+ if (!opt) {
419
+ throw new CommanderError('UnknownOption', `unknown option "-${flag}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
420
+ }
421
+ if (opt.type === 'boolean') {
422
+ opts[opt.long] = true;
423
+ continue;
424
+ }
425
+ if (j < flags.length - 1) {
426
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
427
+ }
428
+ if (idx + 1 < argv.length && !argv[idx + 1].startsWith('-')) {
429
+ const value = argv[idx + 1];
430
+ this.#applyValue(opt, value, opts);
431
+ return idx + 2;
432
+ }
433
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
434
+ }
435
+ return idx + 1;
436
+ }
437
+ #applyValue(opt, rawValue, opts) {
438
+ const type = opt.type ?? 'string';
439
+ let parsedValue = rawValue;
440
+ if (opt.coerce) {
441
+ parsedValue = opt.coerce(rawValue);
442
+ }
443
+ else {
444
+ switch (type) {
445
+ case 'string':
446
+ case 'string[]':
447
+ parsedValue = rawValue;
448
+ break;
449
+ case 'number':
450
+ case 'number[]': {
451
+ const num = Number(rawValue);
452
+ if (Number.isNaN(num)) {
453
+ throw new CommanderError('InvalidType', `invalid number "${rawValue}" for option "--${opt.long}"`, this.#getCommandPath());
454
+ }
455
+ parsedValue = num;
456
+ break;
51
457
  }
52
458
  }
53
459
  }
54
- return options;
460
+ if (type === 'string[]' || type === 'number[]') {
461
+ const currentValue = opts[opt.long];
462
+ const current = Array.isArray(currentValue) ? currentValue : [];
463
+ opts[opt.long] = [...current, parsedValue];
464
+ }
465
+ else {
466
+ opts[opt.long] = parsedValue;
467
+ }
468
+ }
469
+ #getMergedOptions() {
470
+ const ancestors = [];
471
+ for (let node = this; node; node = node.#parent) {
472
+ ancestors.unshift(node);
473
+ }
474
+ const optionMap = new Map();
475
+ const hasUserHelp = ancestors.some(c => c.#options.some(o => o.long === 'help'));
476
+ const hasUserVersion = ancestors.some(c => c.#options.some(o => o.long === 'version'));
477
+ if (!hasUserHelp) {
478
+ optionMap.set('help', BUILTIN_HELP_OPTION);
479
+ }
480
+ if (!hasUserVersion) {
481
+ optionMap.set('version', BUILTIN_VERSION_OPTION);
482
+ }
483
+ for (const ancestor of ancestors) {
484
+ for (const opt of ancestor.#options) {
485
+ optionMap.set(opt.long, opt);
486
+ }
487
+ }
488
+ const shortToLong = new Map();
489
+ for (const [long, opt] of optionMap) {
490
+ if (opt.short) {
491
+ const existing = shortToLong.get(opt.short);
492
+ if (existing && existing !== long) {
493
+ throw new CommanderError('OptionConflict', `short option "-${opt.short}" is used by both "--${existing}" and "--${long}"`, this.#getCommandPath());
494
+ }
495
+ shortToLong.set(opt.short, long);
496
+ }
497
+ }
498
+ return Array.from(optionMap.values());
499
+ }
500
+ #validateOptionConfig(opt) {
501
+ if (opt.long.startsWith('no-')) {
502
+ throw new CommanderError('ConfigurationError', `option long name cannot start with "no-": "${opt.long}"`, this.#getCommandPath());
503
+ }
504
+ if (opt.required && opt.default !== undefined) {
505
+ throw new CommanderError('ConfigurationError', `option "--${opt.long}" cannot be both required and have a default value`, this.#getCommandPath());
506
+ }
507
+ if (opt.type === 'boolean' && opt.required) {
508
+ throw new CommanderError('ConfigurationError', `boolean option "--${opt.long}" cannot be required`, this.#getCommandPath());
509
+ }
510
+ }
511
+ #checkOptionUniqueness(opt) {
512
+ if (this.#options.some(o => o.long === opt.long)) {
513
+ throw new CommanderError('OptionConflict', `option "--${opt.long}" is already defined`, this.#getCommandPath());
514
+ }
515
+ if (opt.short && this.#options.some(o => o.short === opt.short)) {
516
+ throw new CommanderError('OptionConflict', `short option "-${opt.short}" is already defined`, this.#getCommandPath());
517
+ }
518
+ }
519
+ #validateArgumentConfig(arg) {
520
+ if (arg.kind === 'variadic') {
521
+ if (this.#arguments.some(a => a.kind === 'variadic')) {
522
+ throw new CommanderError('ConfigurationError', 'only one variadic argument is allowed', this.#getCommandPath());
523
+ }
524
+ }
525
+ if (this.#arguments.length > 0) {
526
+ const last = this.#arguments[this.#arguments.length - 1];
527
+ if (last.kind === 'variadic') {
528
+ throw new CommanderError('ConfigurationError', 'variadic argument must be the last argument', this.#getCommandPath());
529
+ }
530
+ }
531
+ if (arg.kind === 'required') {
532
+ const hasOptional = this.#arguments.some(a => a.kind === 'optional' || a.kind === 'variadic');
533
+ if (hasOptional) {
534
+ throw new CommanderError('ConfigurationError', `required argument "${arg.name}" cannot come after optional/variadic arguments`, this.#getCommandPath());
535
+ }
536
+ }
537
+ }
538
+ #isBuiltinOption(opt) {
539
+ return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
540
+ }
541
+ #buildOptionMaps(allOptions, excludeResolver = false) {
542
+ const optionByLong = new Map();
543
+ const optionByShort = new Map();
544
+ const booleanOptions = new Set();
545
+ for (const opt of allOptions) {
546
+ if (excludeResolver && opt.resolver)
547
+ continue;
548
+ optionByLong.set(opt.long, opt);
549
+ if (opt.short) {
550
+ optionByShort.set(opt.short, opt);
551
+ }
552
+ if (opt.type === 'boolean') {
553
+ booleanOptions.add(opt.long);
554
+ }
555
+ }
556
+ return { optionByLong, optionByShort, booleanOptions };
557
+ }
558
+ #hasHelpFlag(argv, allOptions) {
559
+ return this.#hasBuiltinFlag(argv, 'help', 'h', allOptions);
560
+ }
561
+ #hasVersionFlag(argv, allOptions) {
562
+ return this.#hasBuiltinFlag(argv, 'version', 'V', allOptions);
563
+ }
564
+ #hasBuiltinFlag(argv, flagLong, flagShort, allOptions) {
565
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions);
566
+ const normalizedArgv = this.#normalizeArgv(argv, booleanOptions);
567
+ for (let i = 0; i < normalizedArgv.length; i++) {
568
+ const arg = normalizedArgv[i];
569
+ if (arg === '--') {
570
+ break;
571
+ }
572
+ if (arg === `--${flagLong}` || (flagShort && arg === `-${flagShort}`)) {
573
+ return true;
574
+ }
575
+ if (this.#optionConsumesNextValue(arg, optionByLong, optionByShort)) {
576
+ i += 1;
577
+ }
578
+ }
579
+ return false;
580
+ }
581
+ #optionConsumesNextValue(arg, optionByLong, optionByShort) {
582
+ if (arg.startsWith('--')) {
583
+ const eqIdx = arg.indexOf('=');
584
+ if (eqIdx !== -1) {
585
+ return false;
586
+ }
587
+ const optName = arg.slice(2);
588
+ const opt = optionByLong.get(optName);
589
+ if (!opt) {
590
+ return false;
591
+ }
592
+ const type = opt.type ?? 'string';
593
+ return type !== 'boolean';
594
+ }
595
+ if (arg.startsWith('-') && arg.length === 2) {
596
+ const opt = optionByShort.get(arg[1]);
597
+ if (!opt) {
598
+ return false;
599
+ }
600
+ const type = opt.type ?? 'string';
601
+ return type !== 'boolean';
602
+ }
603
+ return false;
604
+ }
605
+ #normalizeArgv(argv, booleanOptions) {
606
+ const result = [];
607
+ let seenDoubleDash = false;
608
+ for (const arg of argv) {
609
+ if (arg === '--') {
610
+ seenDoubleDash = true;
611
+ result.push(arg);
612
+ continue;
613
+ }
614
+ if (!seenDoubleDash && arg.startsWith('--no-')) {
615
+ const eqIdx = arg.indexOf('=');
616
+ if (eqIdx !== -1) {
617
+ const optName = arg.slice(5, eqIdx);
618
+ if (booleanOptions.has(optName)) {
619
+ throw new CommanderError('InvalidBooleanValue', `"--no-${optName}" does not accept a value`, this.#getCommandPath());
620
+ }
621
+ }
622
+ else {
623
+ const optName = arg.slice(5);
624
+ if (booleanOptions.has(optName)) {
625
+ result.push(`--${optName}=false`);
626
+ continue;
627
+ }
628
+ }
629
+ }
630
+ result.push(arg);
631
+ }
632
+ return result;
633
+ }
634
+ #getCommandPath() {
635
+ const parts = [];
636
+ for (let node = this; node; node = node.#parent) {
637
+ parts.unshift(node.#name);
638
+ }
639
+ return parts.join(' ');
55
640
  }
56
641
  }
57
642
 
58
- function createMainCommandMounter(create, handle) {
59
- return (program, opts) => {
60
- const command = create(handle);
61
- if (command.name().length <= 0) {
62
- command.name('__main__');
63
- }
64
- program.addCommand(command, { ...opts, isDefault: true });
65
- };
66
- }
67
- function createMainCommandExecutor(create, handle) {
68
- return (args) => {
69
- return new Promise(resolve => {
70
- const wrappedHandler = async (options) => {
71
- await handle(options);
72
- resolve();
73
- };
74
- const command = create(wrappedHandler);
75
- command.parse(args);
643
+ class CompletionCommand extends Command {
644
+ constructor(root, config) {
645
+ const name = config.name ?? 'completion';
646
+ const paths = config.paths;
647
+ super({
648
+ name,
649
+ description: 'Generate shell completion script',
76
650
  });
77
- };
78
- }
79
-
80
- class SubCommand {
81
- mount(parentCommand, opts) {
82
- const processor = this;
83
- const command = this.command(processor);
84
- parentCommand.addCommand(command, opts);
85
- }
86
- execute(parentCommand, rawArgs) {
87
- return new Promise((resolve, reject) => {
88
- const processor = {
89
- process: async (args, options) => {
90
- try {
91
- await this.process(args, options);
92
- resolve();
93
- }
94
- catch (error) {
95
- reject(error);
96
- }
97
- },
98
- };
99
- const command = this.command(processor);
100
- parentCommand.addCommand(command);
101
- parentCommand.parse(rawArgs);
651
+ this.option({
652
+ long: 'bash',
653
+ type: 'boolean',
654
+ description: 'Generate Bash completion script',
655
+ })
656
+ .option({
657
+ long: 'fish',
658
+ type: 'boolean',
659
+ description: 'Generate Fish completion script',
660
+ })
661
+ .option({
662
+ long: 'pwsh',
663
+ type: 'boolean',
664
+ description: 'Generate PowerShell completion script',
665
+ })
666
+ .option({
667
+ long: 'write',
668
+ short: 'w',
669
+ type: 'string',
670
+ description: 'Write to file (default path if no value given)',
671
+ resolver: argv => resolveOptionalStringOption(argv, 'write', 'w'),
672
+ })
673
+ .action(({ opts }) => {
674
+ const meta = root.getCompletionMeta();
675
+ const programName = root.name;
676
+ const selectedShells = [
677
+ opts['bash'] && 'bash',
678
+ opts['fish'] && 'fish',
679
+ opts['pwsh'] && 'pwsh',
680
+ ].filter(Boolean);
681
+ if (selectedShells.length === 0) {
682
+ console.error('Please specify a shell: --bash, --fish, or --pwsh');
683
+ process.exit(1);
684
+ return;
685
+ }
686
+ if (selectedShells.length > 1) {
687
+ console.error('Please specify only one shell option');
688
+ process.exit(1);
689
+ return;
690
+ }
691
+ const shell = selectedShells[0];
692
+ let script;
693
+ switch (shell) {
694
+ case 'bash':
695
+ script = new BashCompletion(meta, programName).generate();
696
+ break;
697
+ case 'fish':
698
+ script = new FishCompletion(meta, programName).generate();
699
+ break;
700
+ case 'pwsh':
701
+ script = new PwshCompletion(meta, programName).generate();
702
+ break;
703
+ }
704
+ const writeOpt = opts['write'];
705
+ if (writeOpt !== undefined) {
706
+ const filePath = typeof writeOpt === 'string' && writeOpt !== '' ? writeOpt : paths[shell];
707
+ const expandedPath = expandHome(filePath);
708
+ const dir = path.dirname(expandedPath);
709
+ if (!fs.existsSync(dir)) {
710
+ fs.mkdirSync(dir, { recursive: true });
711
+ }
712
+ fs.writeFileSync(expandedPath, script, 'utf-8');
713
+ console.log(`Completion script written to: ${expandedPath}`);
714
+ }
715
+ else {
716
+ console.log(script);
717
+ }
102
718
  });
103
719
  }
104
- async process(args, options) {
105
- const processor = await this.resolveProcessor(args, options);
106
- await processor.process(args, options);
720
+ }
721
+ function expandHome(filepath) {
722
+ if (filepath.startsWith('~/') || filepath === '~') {
723
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
724
+ return filepath.replace(/^~/, home);
107
725
  }
726
+ return filepath;
108
727
  }
109
-
110
- function createTopCommand(commandName, version) {
111
- const program = new Command();
112
- program
113
- .storeOptionsAsProperties(false)
114
- .version(version)
115
- .name(commandName)
116
- .option('--log-encoding <encoding>', 'Encoding of log file.')
117
- .option('--log-filepath <filepath>', 'Path which the log file is located.')
118
- .option('--log-level <level>', 'Log level.')
119
- .option('--log-name <name>', 'Logger name.')
120
- .option('--log-mode <normal|loose>', 'Log format mode.')
121
- .option('--log-flight <[[no-]<date|title|colorful|inline>]>', 'Enable / disable logger flights.', (val, acc) => acc.concat(val), [])
122
- .option('-c, --config-path, --configPath <configPath>', 'config filepaths', (val, acc) => acc.concat(val), [])
123
- .option('--parastic-config-path, --parasticConfigFilepath <parasticConfigFilepath>', 'parastic config filepath')
124
- .option('--parastic-config-entry, --parasticConfigEntry <parasticConfigEntry>', 'parastic config filepath')
125
- .option('--workspace <workspace>', 'The root dir to locate the config files and resolve relative paths');
126
- return program;
728
+ function resolveOptionalStringOption(argv, longName, shortName) {
729
+ const remaining = [];
730
+ let value;
731
+ for (let i = 0; i < argv.length; i++) {
732
+ const arg = argv[i];
733
+ if (arg.startsWith(`--${longName}=`)) {
734
+ value = arg.slice(`--${longName}=`.length);
735
+ continue;
736
+ }
737
+ if (arg === `--${longName}`) {
738
+ const next = argv[i + 1];
739
+ if (next !== undefined && !next.startsWith('-')) {
740
+ value = next;
741
+ i += 1;
742
+ }
743
+ else {
744
+ value = '';
745
+ }
746
+ continue;
747
+ }
748
+ if (arg.startsWith(`-${shortName}=`)) {
749
+ value = arg.slice(`-${shortName}=`.length);
750
+ continue;
751
+ }
752
+ if (arg === `-${shortName}`) {
753
+ const next = argv[i + 1];
754
+ if (next !== undefined && !next.startsWith('-')) {
755
+ value = next;
756
+ i += 1;
757
+ }
758
+ else {
759
+ value = '';
760
+ }
761
+ continue;
762
+ }
763
+ remaining.push(arg);
764
+ }
765
+ return { value, remaining };
127
766
  }
128
-
129
- const hasGitInstalled = () => commandExistsSync('git');
130
- async function installDependencies(params) {
131
- const { cwd, plopBypass, reporter } = params;
132
- const hasYarnInstalled = commandExistsSync('yarn');
133
- if (!hasYarnInstalled) {
134
- const hasNpmInstalled = commandExistsSync('npm');
135
- if (!hasNpmInstalled)
136
- return;
137
- }
138
- let npmScript;
139
- if (plopBypass.length > 0) {
140
- npmScript = plopBypass.shift();
141
- }
142
- else {
143
- npmScript = await select({
144
- message: 'npm or yarn?',
145
- choices: ['npm', 'yarn', 'skip'],
146
- default: hasYarnInstalled ? 'yarn' : 'npm',
147
- });
767
+ class BashCompletion {
768
+ #meta;
769
+ #programName;
770
+ constructor(meta, programName) {
771
+ this.#meta = meta;
772
+ this.#programName = programName;
773
+ }
774
+ generate() {
775
+ const funcName = `_${this.#sanitizeName(this.#programName)}_completions`;
776
+ const lines = [
777
+ `# Bash completion for ${this.#programName}`,
778
+ '# Generated by @guanghechen/commander',
779
+ '',
780
+ `${funcName}() {`,
781
+ ' local cur prev words cword',
782
+ ' _init_completion || return',
783
+ '',
784
+ ...this.#generateCommandCase(this.#meta, 1),
785
+ '',
786
+ ' COMPREPLY=($(compgen -W "$opts" -- "$cur"))',
787
+ '}',
788
+ '',
789
+ `complete -F ${funcName} ${this.#programName}`,
790
+ '',
791
+ ];
792
+ return lines.join('\n');
793
+ }
794
+ #generateCommandCase(cmd, depth) {
795
+ const indent = ' '.repeat(depth);
796
+ const lines = [];
797
+ const optParts = [];
798
+ for (const opt of cmd.options) {
799
+ if (opt.short)
800
+ optParts.push(`-${opt.short}`);
801
+ optParts.push(`--${opt.long}`);
802
+ if (!opt.takesValue) {
803
+ optParts.push(`--no-${opt.long}`);
804
+ }
805
+ }
806
+ const subParts = cmd.subcommands.flatMap(sub => [sub.name, ...sub.aliases]);
807
+ const allOpts = [...optParts, ...subParts].join(' ');
808
+ if (cmd.subcommands.length > 0) {
809
+ lines.push(`${indent}case "\${words[${depth}]}" in`);
810
+ for (const sub of cmd.subcommands) {
811
+ const pattern = [sub.name, ...sub.aliases].join('|');
812
+ lines.push(`${indent} ${pattern})`);
813
+ lines.push(...this.#generateCommandCase(sub, depth + 1));
814
+ lines.push(`${indent} ;;`);
815
+ }
816
+ lines.push(`${indent} *)`);
817
+ lines.push(`${indent} opts="${allOpts}"`);
818
+ lines.push(`${indent} ;;`);
819
+ lines.push(`${indent}esac`);
820
+ }
821
+ else {
822
+ lines.push(`${indent}opts="${allOpts}"`);
823
+ }
824
+ return lines;
825
+ }
826
+ #sanitizeName(name) {
827
+ return name.replace(/[^a-zA-Z0-9]/g, '_');
828
+ }
829
+ }
830
+ class FishCompletion {
831
+ #meta;
832
+ #programName;
833
+ constructor(meta, programName) {
834
+ this.#meta = meta;
835
+ this.#programName = programName;
836
+ }
837
+ generate() {
838
+ const lines = [
839
+ `# Fish completion for ${this.#programName}`,
840
+ '# Generated by @guanghechen/commander',
841
+ '',
842
+ ...this.#generateCommandCompletions(this.#meta, []),
843
+ '',
844
+ ];
845
+ return lines.join('\n');
846
+ }
847
+ #generateCommandCompletions(cmd, parentPath) {
848
+ const lines = [];
849
+ const isRoot = parentPath.length === 0;
850
+ const condition = this.#buildCondition(parentPath);
851
+ for (const opt of cmd.options) {
852
+ let line = `complete -c ${this.#programName}`;
853
+ if (condition)
854
+ line += ` -n '${condition}'`;
855
+ if (opt.short)
856
+ line += ` -s ${opt.short}`;
857
+ line += ` -l ${opt.long}`;
858
+ line += ` -d '${this.#escape(opt.description)}'`;
859
+ if (opt.choices && opt.choices.length > 0) {
860
+ line += ` -xa '${opt.choices.join(' ')}'`;
861
+ }
862
+ lines.push(line);
863
+ if (!opt.takesValue) {
864
+ let noLine = `complete -c ${this.#programName}`;
865
+ if (condition)
866
+ noLine += ` -n '${condition}'`;
867
+ noLine += ` -l no-${opt.long}`;
868
+ noLine += ` -d '${this.#escape(opt.description)}'`;
869
+ lines.push(noLine);
870
+ }
871
+ }
872
+ for (const sub of cmd.subcommands) {
873
+ let line = `complete -c ${this.#programName}`;
874
+ if (isRoot) {
875
+ line += ' -n __fish_use_subcommand';
876
+ }
877
+ else if (condition) {
878
+ line += ` -n '${condition}; and not __fish_seen_subcommand_from ${this.#getSubcommandNames(cmd).join(' ')}'`;
879
+ }
880
+ line += ` -a ${sub.name}`;
881
+ line += ` -d '${this.#escape(sub.description)}'`;
882
+ lines.push(line);
883
+ for (const alias of sub.aliases) {
884
+ let aliasLine = `complete -c ${this.#programName}`;
885
+ if (isRoot) {
886
+ aliasLine += ' -n __fish_use_subcommand';
887
+ }
888
+ else if (condition) {
889
+ aliasLine += ` -n '${condition}; and not __fish_seen_subcommand_from ${this.#getSubcommandNames(cmd).join(' ')}'`;
890
+ }
891
+ aliasLine += ` -a ${alias}`;
892
+ aliasLine += ` -d 'Alias for ${sub.name}'`;
893
+ lines.push(aliasLine);
894
+ }
895
+ const newPath = [...parentPath, sub.name];
896
+ lines.push(...this.#generateCommandCompletions(sub, newPath));
897
+ }
898
+ return lines;
899
+ }
900
+ #buildCondition(path) {
901
+ if (path.length === 0)
902
+ return '';
903
+ return `__fish_seen_subcommand_from ${path[path.length - 1]}`;
904
+ }
905
+ #getSubcommandNames(cmd) {
906
+ return cmd.subcommands.flatMap(sub => [sub.name, ...sub.aliases]);
907
+ }
908
+ #escape(s) {
909
+ return s.replace(/'/g, "\\'");
910
+ }
911
+ }
912
+ class PwshCompletion {
913
+ #meta;
914
+ #programName;
915
+ constructor(meta, programName) {
916
+ this.#meta = meta;
917
+ this.#programName = programName;
918
+ }
919
+ generate() {
920
+ const lines = [
921
+ `# PowerShell completion for ${this.#programName}`,
922
+ '# Generated by @guanghechen/commander',
923
+ '',
924
+ `Register-ArgumentCompleter -Native -CommandName ${this.#programName} -ScriptBlock {`,
925
+ ' param($wordToComplete, $commandAst, $cursorPosition)',
926
+ '',
927
+ ' $commands = @{',
928
+ this.#generateCommandHash(this.#meta, ' '),
929
+ ' }',
930
+ '',
931
+ ' $words = $commandAst.CommandElements | ForEach-Object { $_.ToString() }',
932
+ ' $current = $wordToComplete',
933
+ '',
934
+ ' # Find current command context',
935
+ ' $cmd = $commands',
936
+ ' foreach ($word in $words[1..($words.Count - 1)]) {',
937
+ ' if ($word.StartsWith("-")) { continue }',
938
+ ' if ($cmd.subcommands -and $cmd.subcommands.ContainsKey($word)) {',
939
+ ' $cmd = $cmd.subcommands[$word]',
940
+ ' }',
941
+ ' }',
942
+ '',
943
+ ' # Generate completions',
944
+ ' $completions = @()',
945
+ '',
946
+ ' # Options',
947
+ ' if ($current.StartsWith("-")) {',
948
+ ' foreach ($opt in $cmd.options) {',
949
+ ' if ("--$($opt.long)" -like "$current*") {',
950
+ ' $completions += [System.Management.Automation.CompletionResult]::new(',
951
+ ' "--$($opt.long)",',
952
+ ' $opt.long,',
953
+ ' "ParameterName",',
954
+ ' $opt.description',
955
+ ' )',
956
+ ' }',
957
+ ' if ($opt.isBoolean -and "--no-$($opt.long)" -like "$current*") {',
958
+ ' $completions += [System.Management.Automation.CompletionResult]::new(',
959
+ ' "--no-$($opt.long)",',
960
+ ' "no-$($opt.long)",',
961
+ ' "ParameterName",',
962
+ ' $opt.description',
963
+ ' )',
964
+ ' }',
965
+ ' if ($opt.short -and "-$($opt.short)" -like "$current*") {',
966
+ ' $completions += [System.Management.Automation.CompletionResult]::new(',
967
+ ' "-$($opt.short)",',
968
+ ' $opt.short,',
969
+ ' "ParameterName",',
970
+ ' $opt.description',
971
+ ' )',
972
+ ' }',
973
+ ' }',
974
+ ' }',
975
+ '',
976
+ ' # Subcommands',
977
+ ' if ($cmd.subcommands) {',
978
+ ' foreach ($sub in $cmd.subcommands.Keys) {',
979
+ ' if ($sub -like "$current*") {',
980
+ ' $completions += [System.Management.Automation.CompletionResult]::new(',
981
+ ' $sub,',
982
+ ' $sub,',
983
+ ' "Command",',
984
+ ' $cmd.subcommands[$sub].description',
985
+ ' )',
986
+ ' }',
987
+ ' }',
988
+ ' }',
989
+ '',
990
+ ' return $completions',
991
+ '}',
992
+ '',
993
+ ];
994
+ return lines.join('\n');
995
+ }
996
+ #generateCommandHash(cmd, indent) {
997
+ const lines = [];
998
+ lines.push(`${indent}description = '${this.#escape(cmd.description)}'`);
999
+ lines.push(`${indent}options = @(`);
1000
+ for (const opt of cmd.options) {
1001
+ lines.push(`${indent} @{`);
1002
+ if (opt.short)
1003
+ lines.push(`${indent} short = '${opt.short}'`);
1004
+ lines.push(`${indent} long = '${opt.long}'`);
1005
+ lines.push(`${indent} description = '${this.#escape(opt.description)}'`);
1006
+ lines.push(`${indent} isBoolean = $${!opt.takesValue}`);
1007
+ if (opt.choices) {
1008
+ lines.push(`${indent} choices = @('${opt.choices.join("', '")}')`);
1009
+ }
1010
+ lines.push(`${indent} }`);
1011
+ }
1012
+ lines.push(`${indent})`);
1013
+ if (cmd.subcommands.length > 0) {
1014
+ lines.push(`${indent}subcommands = @{`);
1015
+ for (const sub of cmd.subcommands) {
1016
+ lines.push(`${indent} '${sub.name}' = @{`);
1017
+ lines.push(this.#generateCommandHash(sub, `${indent} `));
1018
+ lines.push(`${indent} }`);
1019
+ for (const alias of sub.aliases) {
1020
+ lines.push(`${indent} '${alias}' = @{`);
1021
+ lines.push(this.#generateCommandHash(sub, `${indent} `));
1022
+ lines.push(`${indent} }`);
1023
+ }
1024
+ }
1025
+ lines.push(`${indent}}`);
1026
+ }
1027
+ return lines.join('\n');
1028
+ }
1029
+ #escape(s) {
1030
+ return s.replace(/'/g, "''");
148
1031
  }
149
- reporter?.debug?.('npmScript:', npmScript);
150
- if (npmScript === 'skip')
151
- return;
152
- await safeExec({ from: 'installDependencies', cmd: npmScript, args: ['install'], cwd, reporter });
153
1032
  }
154
1033
 
155
- export { Command, SubCommand, createMainCommandExecutor, createMainCommandMounter, createTopCommand, hasGitInstalled, installDependencies };
1034
+ export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion };