@guanghechen/commander 4.7.1 → 4.7.2

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,6 +1,85 @@
1
- import { Reporter, LOG_LEVELS, resolveLogLevel } from '@guanghechen/reporter';
2
- import * as fs from 'node:fs';
1
+ import { parse } from '@guanghechen/env';
2
+ import { Reporter } from '@guanghechen/reporter';
3
+ import { stat, readFile } from 'node:fs/promises';
3
4
  import * as path from 'node:path';
5
+ import path__default from 'node:path';
6
+ import * as fs from 'node:fs';
7
+
8
+ const TERMINAL_STYLE = {
9
+ bold: '\x1b[1m',
10
+ italic: '\x1b[3m',
11
+ underline: '\x1b[4m',
12
+ cyan: '\x1b[36m',
13
+ dim: '\x1b[2m',
14
+ reset: '\x1b[0m',
15
+ };
16
+ function styleText(text, ...styles) {
17
+ return `${styles.join('')}${text}${TERMINAL_STYLE.reset}`;
18
+ }
19
+
20
+ const BUILTIN_LOG_LEVELS = ['debug', 'info', 'hint', 'warn', 'error'];
21
+ function resolveReporterLogLevel(raw) {
22
+ const normalized = raw.trim().toLowerCase();
23
+ return BUILTIN_LOG_LEVELS.find(level => level === normalized);
24
+ }
25
+ function setReporterLevel(ctx, level) {
26
+ const reporter = ctx.reporter;
27
+ reporter?.setLevel?.(level);
28
+ }
29
+ function setReporterFlight(ctx, flight) {
30
+ const reporter = ctx.reporter;
31
+ reporter?.setFlight?.(flight);
32
+ }
33
+ const logLevelOption = {
34
+ long: 'logLevel',
35
+ type: 'string',
36
+ args: 'required',
37
+ desc: 'Set log level',
38
+ default: 'info',
39
+ choices: [...BUILTIN_LOG_LEVELS],
40
+ coerce: (raw) => {
41
+ const level = resolveReporterLogLevel(raw);
42
+ if (level === undefined) {
43
+ throw new Error(`Invalid log level: ${raw}`);
44
+ }
45
+ return level;
46
+ },
47
+ apply: (value, ctx) => {
48
+ setReporterLevel(ctx, value);
49
+ },
50
+ };
51
+ const logDateOption = {
52
+ long: 'logDate',
53
+ type: 'boolean',
54
+ args: 'none',
55
+ desc: 'Enable log timestamp',
56
+ default: true,
57
+ apply: (value, ctx) => {
58
+ setReporterFlight(ctx, { date: Boolean(value) });
59
+ },
60
+ };
61
+ const logColorfulOption = {
62
+ long: 'logColorful',
63
+ type: 'boolean',
64
+ args: 'none',
65
+ desc: 'Enable colorful log output',
66
+ default: true,
67
+ apply: (value, ctx) => {
68
+ setReporterFlight(ctx, { color: Boolean(value) });
69
+ },
70
+ };
71
+ const silentOption = {
72
+ long: 'silent',
73
+ type: 'boolean',
74
+ args: 'none',
75
+ desc: 'Suppress non-error output',
76
+ default: false,
77
+ apply: (value, ctx) => {
78
+ if (value) {
79
+ setReporterLevel(ctx, 'error');
80
+ }
81
+ },
82
+ };
4
83
 
5
84
  class CommanderError extends Error {
6
85
  kind;
@@ -18,6 +97,11 @@ class CommanderError extends Error {
18
97
 
19
98
  const LONG_OPTION_REGEX = /^--[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
20
99
  const NEGATIVE_OPTION_REGEX = /^--no-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
100
+ const PRESET_OPTS_FLAG = '--preset-opts';
101
+ const PRESET_ENVS_FLAG = '--preset-envs';
102
+ const PRESET_ROOT_FLAG = '--preset-root';
103
+ const DEFAULT_PRESET_OPTS_FILENAME = '.opt.local';
104
+ const DEFAULT_PRESET_ENVS_FILENAME = '.env.local';
21
105
  function kebabToCamelCase(str) {
22
106
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
23
107
  }
@@ -105,27 +189,91 @@ function tokenize(argv, commandPath) {
105
189
  }
106
190
  const BUILTIN_HELP_OPTION = {
107
191
  long: 'help',
108
- short: 'h',
109
192
  type: 'boolean',
110
193
  args: 'none',
111
194
  desc: 'Show help information',
112
195
  };
113
196
  const BUILTIN_VERSION_OPTION = {
114
197
  long: 'version',
115
- short: 'V',
116
198
  type: 'boolean',
117
199
  args: 'none',
118
200
  desc: 'Show version number',
119
201
  };
202
+ const BUILTIN_COLOR_OPTION = {
203
+ long: 'color',
204
+ type: 'boolean',
205
+ args: 'none',
206
+ desc: 'Enable colored help output',
207
+ default: true,
208
+ };
209
+ function createBuiltinOptionState(enabled) {
210
+ return {
211
+ version: enabled,
212
+ color: enabled,
213
+ logLevel: enabled,
214
+ silent: enabled,
215
+ logDate: enabled,
216
+ logColorful: enabled,
217
+ };
218
+ }
219
+ function isNoColorEnabled(envs) {
220
+ return envs['NO_COLOR'] !== undefined;
221
+ }
222
+ function normalizeBuiltinConfig(builtin) {
223
+ const resolved = {
224
+ option: createBuiltinOptionState(true),
225
+ };
226
+ if (builtin === undefined) {
227
+ return resolved;
228
+ }
229
+ if (builtin === true) {
230
+ return {
231
+ option: createBuiltinOptionState(true),
232
+ };
233
+ }
234
+ if (builtin === false) {
235
+ return {
236
+ option: createBuiltinOptionState(false),
237
+ };
238
+ }
239
+ if (builtin.option !== undefined) {
240
+ if (builtin.option === false) {
241
+ resolved.option = createBuiltinOptionState(false);
242
+ }
243
+ else if (builtin.option === true) {
244
+ resolved.option = createBuiltinOptionState(true);
245
+ }
246
+ else {
247
+ if (builtin.option.version !== undefined)
248
+ resolved.option.version = builtin.option.version;
249
+ if (builtin.option.color !== undefined)
250
+ resolved.option.color = builtin.option.color;
251
+ if (builtin.option.logLevel !== undefined) {
252
+ resolved.option.logLevel = builtin.option.logLevel;
253
+ }
254
+ if (builtin.option.silent !== undefined)
255
+ resolved.option.silent = builtin.option.silent;
256
+ if (builtin.option.logDate !== undefined)
257
+ resolved.option.logDate = builtin.option.logDate;
258
+ if (builtin.option.logColorful !== undefined) {
259
+ resolved.option.logColorful = builtin.option.logColorful;
260
+ }
261
+ }
262
+ }
263
+ return resolved;
264
+ }
120
265
  class Command {
121
266
  #name;
122
267
  #desc;
123
268
  #version;
124
- #helpSubcommandEnabled;
269
+ #builtinConfig;
270
+ #builtin;
271
+ #presetConfig;
125
272
  #reporter;
126
273
  #parent;
127
274
  #options = [];
128
275
  #arguments = [];
276
+ #examples = [];
129
277
  #subcommandsList = [];
130
278
  #subcommandsMap = new Map();
131
279
  #action = undefined;
@@ -133,7 +281,9 @@ class Command {
133
281
  this.#name = config.name ?? '';
134
282
  this.#desc = config.desc;
135
283
  this.#version = config.version;
136
- this.#helpSubcommandEnabled = config.help ?? false;
284
+ this.#builtinConfig = config.builtin;
285
+ this.#builtin = normalizeBuiltinConfig(config.builtin);
286
+ this.#presetConfig = config.preset;
137
287
  this.#reporter = config.reporter;
138
288
  }
139
289
  get name() {
@@ -145,6 +295,12 @@ class Command {
145
295
  get version() {
146
296
  return this.#version;
147
297
  }
298
+ get builtin() {
299
+ return this.#builtinConfig;
300
+ }
301
+ get preset() {
302
+ return this.#presetConfig === undefined ? undefined : { ...this.#presetConfig };
303
+ }
148
304
  get parent() {
149
305
  return this.#parent;
150
306
  }
@@ -154,6 +310,9 @@ class Command {
154
310
  get arguments() {
155
311
  return [...this.#arguments];
156
312
  }
313
+ get examples() {
314
+ return this.#examples.map(example => ({ ...example }));
315
+ }
157
316
  get subcommands() {
158
317
  return new Map(this.#subcommandsMap);
159
318
  }
@@ -172,15 +331,22 @@ class Command {
172
331
  this.#action = fn;
173
332
  return this;
174
333
  }
334
+ example(title, usage, desc) {
335
+ this.#examples.push(this.#normalizeExample({ title, usage, desc }));
336
+ return this;
337
+ }
175
338
  subcommand(name, cmd) {
176
- if (this.#helpSubcommandEnabled && name === 'help') {
177
- throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
339
+ if (name === 'help') {
340
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name', this.#getCommandPath());
178
341
  }
179
342
  if (cmd.#parent && cmd.#parent !== this) {
180
343
  throw new CommanderError('ConfigurationError', `command "${cmd.#name}" already has a parent`, this.#getCommandPath());
181
344
  }
182
345
  const existing = this.#subcommandsList.find(e => e.command === cmd);
183
346
  if (existing) {
347
+ if (existing.aliases.includes(name)) {
348
+ return this;
349
+ }
184
350
  existing.aliases.push(name);
185
351
  this.#subcommandsMap.set(name, cmd);
186
352
  }
@@ -195,33 +361,36 @@ class Command {
195
361
  async run(params) {
196
362
  const { argv, envs, reporter } = params;
197
363
  try {
198
- const processedArgv = this.#processHelpSubcommand(argv);
199
- const routeResult = this.#route(processedArgv);
200
- const { chain, remaining } = routeResult;
364
+ const routeResult = this.#route(argv);
365
+ const { chain } = routeResult;
201
366
  const leafCommand = chain[chain.length - 1];
202
- const rootCommand = chain[0];
203
- const tokenizeResult = tokenize(remaining, leafCommand.#getCommandPath());
204
- const { optionTokens, restArgs } = tokenizeResult;
205
- const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
206
- const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
207
- if (!hasUserHelp && this.#hasFlag(optionTokens, 'help', 'h')) {
208
- console.log(leafCommand.formatHelp());
367
+ const ctx = this.#createContext({
368
+ chain,
369
+ cmds: routeResult.cmds,
370
+ envs,
371
+ reporter,
372
+ });
373
+ const controlScanResult = this.#controlScan(routeResult.remaining, leafCommand);
374
+ ctx.controls = controlScanResult.controls;
375
+ ctx.sources.user.argv = [...controlScanResult.remaining];
376
+ if (ctx.controls.help) {
377
+ const helpCommand = this.#resolveHelpCommand(leafCommand, controlScanResult.helpTarget);
378
+ const helpColor = helpCommand.#resolveHelpColorFromTailArgv(controlScanResult.remaining, ctx.envs);
379
+ console.log(helpCommand.#formatHelpForDisplay({ color: helpColor }));
209
380
  return;
210
381
  }
211
- if (!hasUserVersion && leafCommand === rootCommand && leafCommand.#version) {
212
- if (this.#hasFlag(optionTokens, 'version', 'V')) {
213
- console.log(leafCommand.#version);
214
- return;
215
- }
382
+ if (ctx.controls.version) {
383
+ console.log(leafCommand.#version);
384
+ return;
216
385
  }
217
- const resolveResult = this.#resolve(chain, optionTokens);
218
- const ctx = {
219
- cmd: leafCommand,
220
- envs,
221
- reporter: reporter ?? this.#reporter ?? new Reporter(),
222
- argv,
223
- };
224
- const parseResult = this.#parse(chain, resolveResult, ctx, restArgs);
386
+ const optionPolicyMap = this.#buildOptionPolicyMap(chain);
387
+ const presetResult = await this.#preset(controlScanResult.remaining, ctx, optionPolicyMap);
388
+ ctx.sources = presetResult.sources;
389
+ ctx.envs = presetResult.envs;
390
+ const tokenizeResult = tokenize(presetResult.tailArgv, leafCommand.#getCommandPath());
391
+ const { optionTokens, restArgs } = tokenizeResult;
392
+ const resolveResult = this.#resolve(chain, optionTokens, optionPolicyMap);
393
+ const parseResult = this.#parse(chain, resolveResult, optionPolicyMap, ctx, restArgs);
225
394
  const actionParams = {
226
395
  ctx: parseResult.ctx,
227
396
  opts: parseResult.opts,
@@ -232,7 +401,8 @@ class Command {
232
401
  await leafCommand.#runAction(actionParams);
233
402
  }
234
403
  else if (leafCommand.#subcommandsList.length > 0) {
235
- console.log(leafCommand.formatHelp());
404
+ const helpColor = leafCommand.#resolveHelpColorFromTailArgv(presetResult.tailArgv, ctx.envs);
405
+ console.log(leafCommand.#formatHelpForDisplay({ color: helpColor }));
236
406
  }
237
407
  else {
238
408
  throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
@@ -247,28 +417,49 @@ class Command {
247
417
  throw err;
248
418
  }
249
419
  }
250
- parse(params) {
420
+ async parse(params) {
251
421
  const { argv, envs, reporter } = params;
252
- const processedArgv = this.#processHelpSubcommand(argv);
253
- const routeResult = this.#route(processedArgv);
254
- const { chain, remaining } = routeResult;
422
+ const routeResult = this.#route(argv);
423
+ const { chain } = routeResult;
255
424
  const leafCommand = chain[chain.length - 1];
256
- const tokenizeResult = tokenize(remaining, leafCommand.#getCommandPath());
257
- const { optionTokens, restArgs } = tokenizeResult;
258
- const resolveResult = this.#resolve(chain, optionTokens);
259
- const ctx = {
260
- cmd: leafCommand,
425
+ const ctx = this.#createContext({
426
+ chain,
427
+ cmds: routeResult.cmds,
261
428
  envs,
262
- reporter: reporter ?? this.#reporter ?? new Reporter(),
263
- argv,
264
- };
265
- return this.#parse(chain, resolveResult, ctx, restArgs);
429
+ reporter,
430
+ });
431
+ const controlScanResult = this.#controlScan(routeResult.remaining, leafCommand);
432
+ ctx.controls = controlScanResult.controls;
433
+ ctx.sources.user.argv = [...controlScanResult.remaining];
434
+ const optionPolicyMap = this.#buildOptionPolicyMap(chain);
435
+ const presetResult = await this.#preset(controlScanResult.remaining, ctx, optionPolicyMap);
436
+ ctx.sources = presetResult.sources;
437
+ ctx.envs = presetResult.envs;
438
+ const tokenizeResult = tokenize(presetResult.tailArgv, leafCommand.#getCommandPath());
439
+ const { optionTokens, restArgs } = tokenizeResult;
440
+ const resolveResult = this.#resolve(chain, optionTokens, optionPolicyMap);
441
+ return this.#parse(chain, resolveResult, optionPolicyMap, ctx, restArgs);
266
442
  }
267
443
  formatHelp() {
268
- const lines = [];
269
- const allOptions = this.#getMergedOptions();
270
- lines.push(this.#desc);
271
- lines.push('');
444
+ return this.#renderHelpPlain(this.#buildHelpData());
445
+ }
446
+ #formatHelpForDisplay(params = {}) {
447
+ const { color = true } = params;
448
+ const helpData = this.#buildHelpData();
449
+ if (!this.#shouldRenderStyledHelp(color)) {
450
+ return this.#renderHelpPlain(helpData);
451
+ }
452
+ return this.#renderHelpTerminal(helpData);
453
+ }
454
+ #shouldRenderStyledHelp(color) {
455
+ return color && process.stdout.isTTY === true;
456
+ }
457
+ #buildHelpData() {
458
+ const parseOptions = this.#resolveOptionPolicy().mergedOptions;
459
+ const allOptions = [...parseOptions, BUILTIN_HELP_OPTION];
460
+ if (this.#supportsBuiltinVersion()) {
461
+ allOptions.push(BUILTIN_VERSION_OPTION);
462
+ }
272
463
  const commandPath = this.#getCommandPath();
273
464
  let usage = `Usage: ${commandPath}`;
274
465
  if (allOptions.length > 0)
@@ -286,65 +477,128 @@ class Command {
286
477
  usage += ` [${arg.name}...]`;
287
478
  }
288
479
  }
289
- lines.push(usage);
480
+ const options = [];
481
+ for (const opt of allOptions) {
482
+ const kebabLong = camelToKebabCase$1(opt.long);
483
+ let sig = opt.short ? `-${opt.short}, ` : ' ';
484
+ sig += `--${kebabLong}`;
485
+ if (opt.args !== 'none') {
486
+ sig += ' <value>';
487
+ }
488
+ let desc = opt.desc;
489
+ if (opt.default !== undefined && opt.type !== 'boolean') {
490
+ desc += ` (default: ${JSON.stringify(opt.default)})`;
491
+ }
492
+ if (opt.choices) {
493
+ desc += ` [choices: ${opt.choices.join(', ')}]`;
494
+ }
495
+ options.push({ sig, desc });
496
+ if (opt.type === 'boolean' &&
497
+ opt.args === 'none' &&
498
+ opt.long !== 'help' &&
499
+ opt.long !== 'version') {
500
+ options.push({
501
+ sig: ` --no-${kebabLong}`,
502
+ desc: `Negate --${kebabLong}`,
503
+ });
504
+ }
505
+ }
506
+ const commands = [];
507
+ if (this.#subcommandsList.length > 0) {
508
+ commands.push({ name: 'help', desc: 'Show help for a command' });
509
+ }
510
+ for (const entry of this.#subcommandsList) {
511
+ let name = entry.name;
512
+ if (entry.aliases.length > 0) {
513
+ name += `, ${entry.aliases.join(', ')}`;
514
+ }
515
+ commands.push({ name, desc: entry.command.#desc });
516
+ }
517
+ const examples = this.#examples.map(example => ({
518
+ title: example.title,
519
+ usage: commandPath ? `${commandPath} ${example.usage}` : example.usage,
520
+ desc: example.desc,
521
+ }));
522
+ return {
523
+ desc: this.#desc,
524
+ usage,
525
+ options,
526
+ commands,
527
+ examples,
528
+ };
529
+ }
530
+ #renderHelpPlain(helpData) {
531
+ const lines = [];
532
+ lines.push(helpData.desc);
533
+ lines.push('');
534
+ lines.push(helpData.usage);
290
535
  lines.push('');
291
- if (allOptions.length > 0) {
536
+ if (helpData.options.length > 0) {
292
537
  lines.push('Options:');
293
- const optLines = [];
294
- for (const opt of allOptions) {
295
- const kebabLong = camelToKebabCase$1(opt.long);
296
- let sig = opt.short ? `-${opt.short}, ` : ' ';
297
- sig += `--${kebabLong}`;
298
- if (opt.args !== 'none') {
299
- sig += ' <value>';
300
- }
301
- let desc = opt.desc;
302
- if (opt.default !== undefined && opt.type !== 'boolean') {
303
- desc += ` (default: ${JSON.stringify(opt.default)})`;
304
- }
305
- if (opt.choices) {
306
- desc += ` [choices: ${opt.choices.join(', ')}]`;
307
- }
308
- optLines.push({ sig, desc });
309
- if (opt.type === 'boolean' && opt.args === 'none') {
310
- optLines.push({
311
- sig: ` --no-${kebabLong}`,
312
- desc: `Negate --${kebabLong}`,
313
- });
314
- }
315
- }
316
- const maxSigLen = Math.max(...optLines.map(l => l.sig.length));
317
- for (const { sig, desc } of optLines) {
538
+ const maxSigLen = Math.max(...helpData.options.map(line => line.sig.length));
539
+ for (const { sig, desc } of helpData.options) {
318
540
  const padding = ' '.repeat(maxSigLen - sig.length + 2);
319
541
  lines.push(` ${sig}${padding}${desc}`);
320
542
  }
321
543
  lines.push('');
322
544
  }
323
- const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommandsList.length > 0;
324
- if (this.#subcommandsList.length > 0) {
545
+ if (helpData.commands.length > 0) {
325
546
  lines.push('Commands:');
326
- const cmdLines = [];
327
- if (showHelpSubcommand) {
328
- cmdLines.push({ name: 'help', desc: 'Show help for a command' });
329
- }
330
- for (const entry of this.#subcommandsList) {
331
- let name = entry.name;
332
- if (entry.aliases.length > 0) {
333
- name += `, ${entry.aliases.join(', ')}`;
334
- }
335
- cmdLines.push({ name, desc: entry.command.#desc });
336
- }
337
- const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
338
- for (const { name, desc } of cmdLines) {
547
+ const maxNameLen = Math.max(...helpData.commands.map(line => line.name.length));
548
+ for (const { name, desc } of helpData.commands) {
339
549
  const padding = ' '.repeat(maxNameLen - name.length + 2);
340
550
  lines.push(` ${name}${padding}${desc}`);
341
551
  }
342
552
  lines.push('');
343
553
  }
554
+ if (helpData.examples.length > 0) {
555
+ lines.push('Examples:');
556
+ for (const example of helpData.examples) {
557
+ lines.push(` - ${example.title}`);
558
+ lines.push(` ${example.usage}`);
559
+ lines.push(` ${example.desc}`);
560
+ lines.push('');
561
+ }
562
+ }
563
+ return lines.join('\n');
564
+ }
565
+ #renderHelpTerminal(helpData) {
566
+ const lines = [];
567
+ lines.push(helpData.desc);
568
+ lines.push('');
569
+ lines.push(styleText(helpData.usage, TERMINAL_STYLE.bold));
570
+ lines.push('');
571
+ if (helpData.options.length > 0) {
572
+ lines.push(styleText('Options:', TERMINAL_STYLE.bold, TERMINAL_STYLE.underline));
573
+ const maxSigLen = Math.max(...helpData.options.map(line => line.sig.length));
574
+ for (const { sig, desc } of helpData.options) {
575
+ const padding = ' '.repeat(maxSigLen - sig.length + 2);
576
+ lines.push(` ${styleText(sig, TERMINAL_STYLE.cyan)}${padding}${desc}`);
577
+ }
578
+ lines.push('');
579
+ }
580
+ if (helpData.commands.length > 0) {
581
+ lines.push(styleText('Commands:', TERMINAL_STYLE.bold, TERMINAL_STYLE.underline));
582
+ const maxNameLen = Math.max(...helpData.commands.map(line => line.name.length));
583
+ for (const { name, desc } of helpData.commands) {
584
+ const padding = ' '.repeat(maxNameLen - name.length + 2);
585
+ lines.push(` ${styleText(name, TERMINAL_STYLE.cyan)}${padding}${desc}`);
586
+ }
587
+ lines.push('');
588
+ }
589
+ if (helpData.examples.length > 0) {
590
+ lines.push(styleText('Examples:', TERMINAL_STYLE.bold, TERMINAL_STYLE.underline));
591
+ for (const example of helpData.examples) {
592
+ lines.push(` - ${styleText(example.title, TERMINAL_STYLE.bold)}`);
593
+ lines.push(` ${styleText(example.usage, TERMINAL_STYLE.cyan)}`);
594
+ lines.push(` ${styleText(example.desc, TERMINAL_STYLE.italic, TERMINAL_STYLE.dim)}`);
595
+ lines.push('');
596
+ }
597
+ }
344
598
  return lines.join('\n');
345
599
  }
346
600
  getCompletionMeta() {
347
- const allOptions = this.#getMergedOptions();
601
+ const allOptions = this.#resolveOptionPolicy().mergedOptions;
348
602
  const options = [];
349
603
  for (const opt of allOptions) {
350
604
  options.push({
@@ -370,46 +624,407 @@ class Command {
370
624
  }),
371
625
  };
372
626
  }
373
- #processHelpSubcommand(argv) {
374
- if (!this.#helpSubcommandEnabled)
375
- return argv;
376
- if (argv.length < 1 || argv[0] !== 'help')
377
- return argv;
378
- if (argv.length === 1 || this.#subcommandsList.length === 0) {
379
- return ['--help'];
380
- }
381
- const subName = argv[1];
382
- const entry = this.#subcommandsList.find(e => e.name === subName || e.aliases.includes(subName));
383
- if (entry) {
384
- return [subName, '--help', ...argv.slice(2)];
385
- }
386
- return argv;
627
+ #findSubcommandEntry(token) {
628
+ return this.#subcommandsList.find(e => e.name === token || e.aliases.includes(token));
387
629
  }
388
630
  #route(argv) {
389
631
  const chain = [this];
632
+ const cmds = [];
390
633
  let current = this;
391
634
  let idx = 0;
392
635
  while (idx < argv.length) {
393
636
  const token = argv[idx];
394
637
  if (token.startsWith('-'))
395
638
  break;
396
- const entry = current.#subcommandsList.find(e => e.name === token || e.aliases.includes(token));
639
+ const entry = current.#findSubcommandEntry(token);
397
640
  if (!entry)
398
641
  break;
399
642
  current = entry.command;
643
+ cmds.push(token);
400
644
  chain.push(current);
401
645
  idx += 1;
402
646
  }
403
- return { chain, remaining: argv.slice(idx) };
647
+ return { chain, remaining: argv.slice(idx), cmds };
648
+ }
649
+ #controlScan(tailArgv, leafCommand) {
650
+ const controls = { help: false, version: false };
651
+ const separatorIndex = tailArgv.indexOf('--');
652
+ const beforeSeparator = separatorIndex === -1 ? tailArgv : tailArgv.slice(0, separatorIndex);
653
+ const afterSeparator = separatorIndex === -1 ? [] : tailArgv.slice(separatorIndex + 1);
654
+ let helpTarget;
655
+ let scanStartIndex = 0;
656
+ if (beforeSeparator[0] === 'help') {
657
+ controls.help = true;
658
+ scanStartIndex = 1;
659
+ const candidate = beforeSeparator[1];
660
+ if (candidate !== undefined && !candidate.startsWith('-')) {
661
+ helpTarget = candidate;
662
+ scanStartIndex = 2;
663
+ }
664
+ }
665
+ const remainingBeforeSeparator = [];
666
+ for (let i = scanStartIndex; i < beforeSeparator.length; i += 1) {
667
+ const token = beforeSeparator[i];
668
+ if (token === '--help') {
669
+ controls.help = true;
670
+ continue;
671
+ }
672
+ if (token === '--version' && leafCommand.#supportsBuiltinVersion()) {
673
+ controls.version = true;
674
+ continue;
675
+ }
676
+ remainingBeforeSeparator.push(token);
677
+ }
678
+ const remaining = separatorIndex === -1
679
+ ? remainingBeforeSeparator
680
+ : [...remainingBeforeSeparator, '--', ...afterSeparator];
681
+ return {
682
+ controls,
683
+ remaining,
684
+ helpTarget,
685
+ };
686
+ }
687
+ #createContext(params) {
688
+ const { chain, cmds, envs, reporter } = params;
689
+ const leafCommand = chain[chain.length - 1];
690
+ const envSnapshot = { ...envs };
691
+ return {
692
+ cmd: leafCommand,
693
+ chain,
694
+ envs: envSnapshot,
695
+ controls: { help: false, version: false },
696
+ sources: {
697
+ preset: {
698
+ argv: [],
699
+ envs: {},
700
+ },
701
+ user: {
702
+ cmds: [...cmds],
703
+ argv: [],
704
+ envs: envSnapshot,
705
+ },
706
+ },
707
+ reporter: reporter ?? this.#reporter ?? new Reporter(),
708
+ };
709
+ }
710
+ #resolveHelpCommand(leafCommand, helpTarget) {
711
+ if (helpTarget === undefined) {
712
+ return leafCommand;
713
+ }
714
+ const target = leafCommand.#findSubcommandEntry(helpTarget);
715
+ if (target === undefined) {
716
+ return leafCommand;
717
+ }
718
+ return target.command;
719
+ }
720
+ async #preset(controlTailArgv, ctx, optionPolicyMap) {
721
+ const commandPath = ctx.chain[ctx.chain.length - 1].#getCommandPath();
722
+ const separatorIndex = controlTailArgv.indexOf('--');
723
+ const beforeSeparator = separatorIndex === -1 ? controlTailArgv : controlTailArgv.slice(0, separatorIndex);
724
+ const afterSeparator = separatorIndex === -1 ? [] : controlTailArgv.slice(separatorIndex + 1);
725
+ const rootScanResult = this.#scanPresetRootDirectives(beforeSeparator, commandPath);
726
+ const commandPreset = this.#resolveCommandPresetFromChain(ctx.chain);
727
+ const presetRoot = await this.#resolveEffectivePresetRoot(rootScanResult.cliPresetRoots, commandPreset, commandPath);
728
+ const fileScanResult = this.#scanPresetFileDirectives(rootScanResult.cleanArgv, commandPath);
729
+ const cleanArgv = separatorIndex === -1
730
+ ? fileScanResult.cleanArgv
731
+ : [...fileScanResult.cleanArgv, '--', ...afterSeparator];
732
+ const presetOptsFiles = this.#resolvePresetFileSources({
733
+ cliFiles: fileScanResult.cliPresetOptsFiles,
734
+ commandPresetFile: this.#normalizeCommandPresetFile(commandPreset?.opt),
735
+ presetRoot,
736
+ defaultFilename: DEFAULT_PRESET_OPTS_FILENAME,
737
+ });
738
+ const presetEnvsFiles = this.#resolvePresetFileSources({
739
+ cliFiles: fileScanResult.cliPresetEnvsFiles,
740
+ commandPresetFile: this.#normalizeCommandPresetFile(commandPreset?.env),
741
+ presetRoot,
742
+ defaultFilename: DEFAULT_PRESET_ENVS_FILENAME,
743
+ });
744
+ const userSources = {
745
+ cmds: [...ctx.sources.user.cmds],
746
+ argv: [...cleanArgv],
747
+ envs: { ...ctx.sources.user.envs },
748
+ };
749
+ const presetArgv = [];
750
+ for (const file of presetOptsFiles) {
751
+ const content = await this.#readPresetFile(file, commandPath);
752
+ if (content === undefined) {
753
+ continue;
754
+ }
755
+ const tokens = this.#tokenizePresetOptions(content);
756
+ this.#validatePresetOptionTokens(tokens, file.displayPath, commandPath);
757
+ this.#assertPresetOptionFragments(tokens, file.displayPath, ctx.chain, optionPolicyMap);
758
+ presetArgv.push(...tokens);
759
+ }
760
+ const presetEnvs = {};
761
+ for (const file of presetEnvsFiles) {
762
+ const content = await this.#readPresetFile(file, commandPath);
763
+ if (content === undefined) {
764
+ continue;
765
+ }
766
+ let parsed;
767
+ try {
768
+ parsed = parse(content);
769
+ }
770
+ catch (error) {
771
+ throw new CommanderError('ConfigurationError', `failed to parse preset envs file "${file.displayPath}": ${error.message}`, commandPath);
772
+ }
773
+ Object.assign(presetEnvs, parsed);
774
+ }
775
+ const sources = {
776
+ user: userSources,
777
+ preset: {
778
+ argv: presetArgv,
779
+ envs: presetEnvs,
780
+ },
781
+ };
782
+ const envs = { ...sources.user.envs, ...sources.preset.envs };
783
+ const tailArgv = [...sources.preset.argv, ...sources.user.argv];
784
+ return { tailArgv, envs, sources };
785
+ }
786
+ #resolveCommandPresetFromChain(chain) {
787
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
788
+ const preset = chain[index].#presetConfig;
789
+ if (preset?.root !== undefined) {
790
+ return preset;
791
+ }
792
+ }
793
+ return undefined;
404
794
  }
405
- #resolve(chain, tokens) {
795
+ async #resolveEffectivePresetRoot(cliPresetRoots, commandPreset, commandPath) {
796
+ if (cliPresetRoots.length > 0) {
797
+ const root = cliPresetRoots[cliPresetRoots.length - 1];
798
+ return await this.#assertPresetRoot(root, PRESET_ROOT_FLAG, commandPath);
799
+ }
800
+ if (commandPreset?.root === undefined) {
801
+ return undefined;
802
+ }
803
+ return await this.#assertPresetRoot(commandPreset.root, 'command.preset.root', commandPath);
804
+ }
805
+ async #assertPresetRoot(root, sourceName, commandPath) {
806
+ if (!path__default.isAbsolute(root)) {
807
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" is not an absolute directory`, commandPath);
808
+ }
809
+ let stats;
810
+ try {
811
+ stats = await stat(root);
812
+ }
813
+ catch (error) {
814
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" cannot be accessed (${error.message})`, commandPath);
815
+ }
816
+ if (!stats.isDirectory()) {
817
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" is not a directory`, commandPath);
818
+ }
819
+ return root;
820
+ }
821
+ #normalizeCommandPresetFile(filepath) {
822
+ if (filepath === undefined) {
823
+ return undefined;
824
+ }
825
+ if (!this.#isValidPresetFileValue(filepath)) {
826
+ return undefined;
827
+ }
828
+ return filepath;
829
+ }
830
+ #resolvePresetFileSources(params) {
831
+ const { cliFiles, commandPresetFile, presetRoot, defaultFilename } = params;
832
+ if (cliFiles.length > 0) {
833
+ return cliFiles.map(filepath => ({
834
+ displayPath: filepath,
835
+ absolutePath: this.#resolvePresetFileAbsolutePath(filepath, presetRoot),
836
+ explicit: true,
837
+ }));
838
+ }
839
+ if (presetRoot === undefined) {
840
+ return [];
841
+ }
842
+ if (commandPresetFile !== undefined) {
843
+ return [
844
+ {
845
+ displayPath: commandPresetFile,
846
+ absolutePath: this.#resolvePresetFileAbsolutePath(commandPresetFile, presetRoot),
847
+ explicit: true,
848
+ },
849
+ ];
850
+ }
851
+ const absolutePath = path__default.resolve(presetRoot, defaultFilename);
852
+ return [
853
+ {
854
+ displayPath: absolutePath,
855
+ absolutePath,
856
+ explicit: false,
857
+ },
858
+ ];
859
+ }
860
+ #resolvePresetFileAbsolutePath(filepath, presetRoot) {
861
+ if (path__default.isAbsolute(filepath)) {
862
+ return filepath;
863
+ }
864
+ if (presetRoot !== undefined) {
865
+ return path__default.resolve(presetRoot, filepath);
866
+ }
867
+ return path__default.resolve(process.cwd(), filepath);
868
+ }
869
+ #assertPresetOptionFragments(tokens, filepath, chain, optionPolicyMap) {
870
+ if (tokens.length === 0) {
871
+ return;
872
+ }
873
+ const commandPath = chain[chain.length - 1].#getCommandPath();
874
+ try {
875
+ const { optionTokens, restArgs } = tokenize(tokens, commandPath);
876
+ void restArgs;
877
+ const { argTokens } = this.#resolve(chain, optionTokens, optionPolicyMap);
878
+ if (argTokens.length > 0) {
879
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": token "${argTokens[0].original}" cannot be resolved as an option fragment`, commandPath);
880
+ }
881
+ }
882
+ catch (error) {
883
+ if (error instanceof CommanderError) {
884
+ if (error.kind === 'ConfigurationError') {
885
+ throw error;
886
+ }
887
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": ${error.message}`, commandPath);
888
+ }
889
+ throw error;
890
+ }
891
+ }
892
+ #scanPresetRootDirectives(argv, commandPath) {
893
+ const cleanArgv = [];
894
+ const cliPresetRoots = [];
895
+ let index = 0;
896
+ while (index < argv.length) {
897
+ const token = argv[index];
898
+ if (token === PRESET_ROOT_FLAG) {
899
+ const value = argv[index + 1];
900
+ if (value === undefined || value.length === 0) {
901
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ROOT_FLAG}"`, commandPath);
902
+ }
903
+ cliPresetRoots.push(value);
904
+ index += 2;
905
+ continue;
906
+ }
907
+ if (token.startsWith(`${PRESET_ROOT_FLAG}=`)) {
908
+ const value = token.slice(PRESET_ROOT_FLAG.length + 1);
909
+ if (value.length === 0) {
910
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ROOT_FLAG}"`, commandPath);
911
+ }
912
+ cliPresetRoots.push(value);
913
+ index += 1;
914
+ continue;
915
+ }
916
+ cleanArgv.push(token);
917
+ index += 1;
918
+ }
919
+ return { cleanArgv, cliPresetRoots };
920
+ }
921
+ #scanPresetFileDirectives(argv, commandPath) {
922
+ const cleanArgv = [];
923
+ const cliPresetOptsFiles = [];
924
+ const cliPresetEnvsFiles = [];
925
+ const assertAndPush = (flag, value) => {
926
+ this.#assertPresetFileValue(value, flag, commandPath);
927
+ if (flag === PRESET_OPTS_FLAG) {
928
+ cliPresetOptsFiles.push(value);
929
+ }
930
+ else {
931
+ cliPresetEnvsFiles.push(value);
932
+ }
933
+ };
934
+ let index = 0;
935
+ while (index < argv.length) {
936
+ const token = argv[index];
937
+ if (token === PRESET_OPTS_FLAG || token === PRESET_ENVS_FLAG) {
938
+ const value = argv[index + 1];
939
+ if (value === undefined || value.length === 0) {
940
+ throw new CommanderError('ConfigurationError', `missing value for "${token}"`, commandPath);
941
+ }
942
+ assertAndPush(token, value);
943
+ index += 2;
944
+ continue;
945
+ }
946
+ if (token.startsWith(`${PRESET_OPTS_FLAG}=`)) {
947
+ const value = token.slice(PRESET_OPTS_FLAG.length + 1);
948
+ if (value.length === 0) {
949
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_OPTS_FLAG}"`, commandPath);
950
+ }
951
+ assertAndPush(PRESET_OPTS_FLAG, value);
952
+ index += 1;
953
+ continue;
954
+ }
955
+ if (token.startsWith(`${PRESET_ENVS_FLAG}=`)) {
956
+ const value = token.slice(PRESET_ENVS_FLAG.length + 1);
957
+ if (value.length === 0) {
958
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ENVS_FLAG}"`, commandPath);
959
+ }
960
+ assertAndPush(PRESET_ENVS_FLAG, value);
961
+ index += 1;
962
+ continue;
963
+ }
964
+ cleanArgv.push(token);
965
+ index += 1;
966
+ }
967
+ return { cleanArgv, cliPresetOptsFiles, cliPresetEnvsFiles };
968
+ }
969
+ #isValidPresetFileValue(filepath) {
970
+ return filepath.length > 0 && !filepath.startsWith('..');
971
+ }
972
+ #assertPresetFileValue(filepath, directive, commandPath) {
973
+ if (this.#isValidPresetFileValue(filepath)) {
974
+ return;
975
+ }
976
+ throw new CommanderError('ConfigurationError', `invalid value for "${directive}": "${filepath}" (must be non-empty and must not start with "..")`, commandPath);
977
+ }
978
+ async #readPresetFile(file, commandPath) {
979
+ try {
980
+ return await readFile(file.absolutePath, 'utf8');
981
+ }
982
+ catch (error) {
983
+ const ioError = error;
984
+ if (!file.explicit && ioError.code === 'ENOENT') {
985
+ return undefined;
986
+ }
987
+ throw new CommanderError('ConfigurationError', `failed to read preset file "${file.displayPath}": ${ioError.message}`, commandPath);
988
+ }
989
+ }
990
+ #tokenizePresetOptions(content) {
991
+ return content
992
+ .split(/\s+/)
993
+ .map(token => token.trim())
994
+ .filter(token => token.length > 0);
995
+ }
996
+ #validatePresetOptionTokens(tokens, filepath, commandPath) {
997
+ if (tokens.length === 0) {
998
+ return;
999
+ }
1000
+ if (!tokens[0].startsWith('-')) {
1001
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": bare token "${tokens[0]}" cannot appear before any option token`, commandPath);
1002
+ }
1003
+ for (const token of tokens) {
1004
+ if (token === '--') {
1005
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": "--" is not allowed`, commandPath);
1006
+ }
1007
+ if (token === 'help' || token === '--help' || token === '--version') {
1008
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": control token "${token}" is not allowed`, commandPath);
1009
+ }
1010
+ if (token === PRESET_ROOT_FLAG ||
1011
+ token.startsWith(`${PRESET_ROOT_FLAG}=`) ||
1012
+ token === PRESET_OPTS_FLAG ||
1013
+ token.startsWith(`${PRESET_OPTS_FLAG}=`) ||
1014
+ token === PRESET_ENVS_FLAG ||
1015
+ token.startsWith(`${PRESET_ENVS_FLAG}=`)) {
1016
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": preset directive "${token}" is not allowed`, commandPath);
1017
+ }
1018
+ }
1019
+ }
1020
+ #resolve(chain, tokens, optionPolicyMap) {
406
1021
  const consumedTokens = new Map();
407
1022
  let remaining = [...tokens];
408
1023
  const shadowed = new Set();
409
1024
  for (let i = chain.length - 1; i >= 0; i--) {
410
1025
  const cmd = chain[i];
411
- const includeVersion = i === 0;
412
- const result = cmd.#shift(remaining, shadowed, includeVersion);
1026
+ const policy = this.#mustGetOptionPolicy(optionPolicyMap, cmd);
1027
+ const result = cmd.#shift(remaining, shadowed, policy.mergedOptions);
413
1028
  consumedTokens.set(cmd, result.consumed);
414
1029
  remaining = result.remaining;
415
1030
  for (const opt of cmd.#options) {
@@ -426,8 +1041,7 @@ class Command {
426
1041
  }
427
1042
  return { consumedTokens, argTokens };
428
1043
  }
429
- #shift(tokens, shadowed, includeVersion) {
430
- const allOptions = this.#getMergedOptions(includeVersion);
1044
+ #shift(tokens, shadowed, allOptions) {
431
1045
  const effectiveOptions = allOptions.filter(o => !shadowed.has(o.long));
432
1046
  const optionByLong = new Map();
433
1047
  const optionByShort = new Map();
@@ -495,34 +1109,40 @@ class Command {
495
1109
  }
496
1110
  return { consumed, remaining };
497
1111
  }
498
- #parse(chain, resolveResult, ctx, restArgs) {
1112
+ #parse(chain, resolveResult, optionPolicyMap, ctx, restArgs) {
499
1113
  const { consumedTokens, argTokens } = resolveResult;
500
1114
  const leafCommand = chain[chain.length - 1];
501
- this.#validateMergedShortOptions(chain);
1115
+ this.#validateMergedShortOptions(chain, optionPolicyMap);
502
1116
  const optsMap = new Map();
503
- for (let i = 0; i < chain.length; i++) {
504
- const cmd = chain[i];
505
- const includeVersion = i === 0;
1117
+ for (const cmd of chain) {
1118
+ const policy = this.#mustGetOptionPolicy(optionPolicyMap, cmd);
506
1119
  const tokens = consumedTokens.get(cmd) ?? [];
507
- const opts = cmd.#parseOptions(tokens, includeVersion);
1120
+ const opts = cmd.#parseOptions(tokens, policy.mergedOptions, ctx.envs);
508
1121
  optsMap.set(cmd, opts);
509
- for (const opt of cmd.#getMergedOptions(includeVersion)) {
1122
+ for (const opt of policy.mergedOptions) {
510
1123
  if (opt.apply && opts[opt.long] !== undefined) {
511
1124
  opt.apply(opts[opt.long], ctx);
512
1125
  }
513
1126
  }
514
1127
  }
515
- const mergedOpts = {};
516
- for (const cmd of chain) {
517
- Object.assign(mergedOpts, optsMap.get(cmd) ?? {});
1128
+ const leafLocalOpts = {};
1129
+ const leafParsedOpts = optsMap.get(leafCommand) ?? {};
1130
+ for (const opt of leafCommand.#options) {
1131
+ if (Object.prototype.hasOwnProperty.call(leafParsedOpts, opt.long)) {
1132
+ leafLocalOpts[opt.long] = leafParsedOpts[opt.long];
1133
+ }
518
1134
  }
519
1135
  const rawArgStrings = [...argTokens.map(t => t.original), ...restArgs];
520
1136
  const { args, rawArgs } = leafCommand.#parseArguments(rawArgStrings);
521
- return { ctx, opts: mergedOpts, args, rawArgs };
1137
+ const parseCtx = {
1138
+ ...ctx,
1139
+ sources: this.#freezeInputSources(ctx.sources),
1140
+ };
1141
+ return { ctx: parseCtx, opts: leafLocalOpts, args, rawArgs };
522
1142
  }
523
- #parseOptions(tokens, includeVersion) {
524
- const allOptions = this.#getMergedOptions(includeVersion);
1143
+ #parseOptions(tokens, allOptions, envs) {
525
1144
  const opts = {};
1145
+ let sawColorToken = false;
526
1146
  for (const opt of allOptions) {
527
1147
  if (opt.default !== undefined) {
528
1148
  opts[opt.long] = opt.default;
@@ -550,6 +1170,9 @@ class Command {
550
1170
  i += 1;
551
1171
  continue;
552
1172
  }
1173
+ if (opt.long === 'color') {
1174
+ sawColorToken = true;
1175
+ }
553
1176
  const isNegativeToken = token.original.toLowerCase().startsWith('--no-');
554
1177
  if (isNegativeToken && !(opt.type === 'boolean' && opt.args === 'none')) {
555
1178
  throw new CommanderError('NegativeOptionType', `"--no-${camelToKebabCase$1(opt.long)}" can only be used with boolean options`, this.#getCommandPath());
@@ -626,6 +1249,9 @@ class Command {
626
1249
  }
627
1250
  }
628
1251
  }
1252
+ if (isNoColorEnabled(envs) && !sawColorToken && opts['color'] === true) {
1253
+ opts['color'] = false;
1254
+ }
629
1255
  return opts;
630
1256
  }
631
1257
  #convertValue(opt, rawValue) {
@@ -696,27 +1322,60 @@ class Command {
696
1322
  }
697
1323
  return raw;
698
1324
  }
699
- #getMergedOptions(includeVersion = !this.#parent) {
1325
+ #hasUserOption(long) {
1326
+ return this.#options.some(option => option.long === long);
1327
+ }
1328
+ #supportsBuiltinVersion() {
1329
+ return this.#parent === undefined && this.#version !== undefined && this.#builtin.option.version;
1330
+ }
1331
+ #resolveOptionPolicy() {
700
1332
  const optionMap = new Map();
701
- const hasUserHelp = this.#options.some(o => o.long === 'help');
702
- const hasUserVersion = this.#options.some(o => o.long === 'version');
703
- if (!hasUserHelp) {
704
- optionMap.set('help', BUILTIN_HELP_OPTION);
1333
+ const hasUserColor = this.#hasUserOption('color');
1334
+ const hasUserLogLevel = this.#hasUserOption('logLevel');
1335
+ const hasUserSilent = this.#hasUserOption('silent');
1336
+ const hasUserLogDate = this.#hasUserOption('logDate');
1337
+ const hasUserLogColorful = this.#hasUserOption('logColorful');
1338
+ if (this.#builtin.option.color && !hasUserColor) {
1339
+ optionMap.set('color', BUILTIN_COLOR_OPTION);
1340
+ }
1341
+ if (this.#builtin.option.logLevel && !hasUserLogLevel) {
1342
+ optionMap.set('logLevel', logLevelOption);
705
1343
  }
706
- if (!hasUserVersion && includeVersion) {
707
- optionMap.set('version', BUILTIN_VERSION_OPTION);
1344
+ if (this.#builtin.option.silent && !hasUserSilent) {
1345
+ optionMap.set('silent', silentOption);
1346
+ }
1347
+ if (this.#builtin.option.logDate && !hasUserLogDate) {
1348
+ optionMap.set('logDate', logDateOption);
1349
+ }
1350
+ if (this.#builtin.option.logColorful && !hasUserLogColorful) {
1351
+ optionMap.set('logColorful', logColorfulOption);
708
1352
  }
709
1353
  for (const opt of this.#options) {
710
1354
  optionMap.set(opt.long, opt);
711
1355
  }
712
- return Array.from(optionMap.values());
1356
+ return {
1357
+ mergedOptions: Array.from(optionMap.values()),
1358
+ };
1359
+ }
1360
+ #buildOptionPolicyMap(chain) {
1361
+ const optionPolicyMap = new Map();
1362
+ for (const cmd of chain) {
1363
+ optionPolicyMap.set(cmd, cmd.#resolveOptionPolicy());
1364
+ }
1365
+ return optionPolicyMap;
1366
+ }
1367
+ #mustGetOptionPolicy(optionPolicyMap, cmd) {
1368
+ const policy = optionPolicyMap.get(cmd);
1369
+ if (policy !== undefined) {
1370
+ return policy;
1371
+ }
1372
+ throw new CommanderError('ConfigurationError', `missing option policy for command "${cmd.#getCommandPath()}"`, this.#getCommandPath());
713
1373
  }
714
- #validateMergedShortOptions(chain) {
1374
+ #validateMergedShortOptions(chain, optionPolicyMap) {
715
1375
  const mergedByLong = new Map();
716
- for (let i = 0; i < chain.length; i++) {
717
- const cmd = chain[i];
718
- const includeVersion = i === 0;
719
- for (const opt of cmd.#getMergedOptions(includeVersion)) {
1376
+ for (const cmd of chain) {
1377
+ const policy = this.#mustGetOptionPolicy(optionPolicyMap, cmd);
1378
+ for (const opt of policy.mergedOptions) {
720
1379
  mergedByLong.set(opt.long, opt);
721
1380
  }
722
1381
  }
@@ -732,6 +1391,9 @@ class Command {
732
1391
  }
733
1392
  }
734
1393
  #validateOptionConfig(opt) {
1394
+ if (opt.long === 'help' || opt.long === 'version') {
1395
+ throw new CommanderError('ConfigurationError', `option long name "${opt.long}" is reserved`, this.#getCommandPath());
1396
+ }
735
1397
  if (opt.type === 'boolean' && opt.args !== 'none') {
736
1398
  throw new CommanderError('ConfigurationError', `boolean option "--${opt.long}" must have args: 'none'`, this.#getCommandPath());
737
1399
  }
@@ -781,6 +1443,21 @@ class Command {
781
1443
  }
782
1444
  }
783
1445
  }
1446
+ #normalizeExample(example) {
1447
+ const title = example.title.trim();
1448
+ const usage = example.usage.trim();
1449
+ const desc = example.desc.trim();
1450
+ if (!title) {
1451
+ throw new CommanderError('ConfigurationError', 'example title cannot be empty', this.#getCommandPath());
1452
+ }
1453
+ if (!usage) {
1454
+ throw new CommanderError('ConfigurationError', 'example usage cannot be empty', this.#getCommandPath());
1455
+ }
1456
+ if (!desc) {
1457
+ throw new CommanderError('ConfigurationError', 'example description cannot be empty', this.#getCommandPath());
1458
+ }
1459
+ return { title, usage, desc };
1460
+ }
784
1461
  async #runAction(params) {
785
1462
  if (!this.#action)
786
1463
  return;
@@ -797,16 +1474,51 @@ class Command {
797
1474
  process.exit(1);
798
1475
  }
799
1476
  }
800
- #hasFlag(tokens, longName, shortName) {
801
- for (const token of tokens) {
802
- if (token.type === 'long' && token.name === longName) {
803
- return true;
1477
+ #resolveHelpColorFromTailArgv(tailArgv, envs, policy = this.#resolveOptionPolicy()) {
1478
+ const colorOption = policy.mergedOptions.find(opt => opt.long === 'color');
1479
+ let color = !isNoColorEnabled(envs);
1480
+ if (!colorOption || colorOption.type !== 'boolean' || colorOption.args !== 'none') {
1481
+ return color;
1482
+ }
1483
+ const separatorIndex = tailArgv.indexOf('--');
1484
+ const scanTokens = separatorIndex === -1 ? tailArgv : tailArgv.slice(0, separatorIndex);
1485
+ for (const token of scanTokens) {
1486
+ if (token === '--color') {
1487
+ color = true;
1488
+ continue;
1489
+ }
1490
+ if (token === '--no-color') {
1491
+ color = false;
1492
+ continue;
804
1493
  }
805
- if (token.type === 'short' && token.name === shortName) {
806
- return true;
1494
+ if (!token.startsWith('--color=')) {
1495
+ continue;
1496
+ }
1497
+ const value = token.slice('--color='.length);
1498
+ if (value === 'true') {
1499
+ color = true;
1500
+ }
1501
+ else if (value === 'false') {
1502
+ color = false;
1503
+ }
1504
+ else {
1505
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${value}" for boolean option "--color". Use "true" or "false"`, this.#getCommandPath());
807
1506
  }
808
1507
  }
809
- return false;
1508
+ return color;
1509
+ }
1510
+ #freezeInputSources(sources) {
1511
+ return Object.freeze({
1512
+ preset: Object.freeze({
1513
+ argv: Object.freeze([...sources.preset.argv]),
1514
+ envs: Object.freeze({ ...sources.preset.envs }),
1515
+ }),
1516
+ user: Object.freeze({
1517
+ cmds: Object.freeze([...sources.user.cmds]),
1518
+ argv: Object.freeze([...sources.user.argv]),
1519
+ envs: Object.freeze({ ...sources.user.envs }),
1520
+ }),
1521
+ });
810
1522
  }
811
1523
  #getCommandPath() {
812
1524
  const parts = [];
@@ -821,13 +1533,166 @@ class Command {
821
1533
  }
822
1534
  }
823
1535
 
1536
+ function isIpv4(rawValue) {
1537
+ const parts = rawValue.split('.');
1538
+ if (parts.length !== 4) {
1539
+ return false;
1540
+ }
1541
+ for (const part of parts) {
1542
+ if (part.length < 1 || !/^\d+$/.test(part)) {
1543
+ return false;
1544
+ }
1545
+ if (part.length > 1 && part.startsWith('0')) {
1546
+ return false;
1547
+ }
1548
+ const value = Number(part);
1549
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
1550
+ return false;
1551
+ }
1552
+ }
1553
+ return true;
1554
+ }
1555
+ function countIpv6Segments(part, allowIpv4Tail) {
1556
+ if (!part) {
1557
+ return { count: 0, hasIpv4Tail: false };
1558
+ }
1559
+ const segments = part.split(':');
1560
+ let count = 0;
1561
+ let hasIpv4Tail = false;
1562
+ for (let i = 0; i < segments.length; ++i) {
1563
+ const segment = segments[i];
1564
+ const isLastSegment = i === segments.length - 1;
1565
+ if (!segment) {
1566
+ return null;
1567
+ }
1568
+ if (segment.includes('.')) {
1569
+ if (!allowIpv4Tail || !isLastSegment || hasIpv4Tail || !isIpv4(segment)) {
1570
+ return null;
1571
+ }
1572
+ hasIpv4Tail = true;
1573
+ count += 2;
1574
+ continue;
1575
+ }
1576
+ if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) {
1577
+ return null;
1578
+ }
1579
+ count += 1;
1580
+ }
1581
+ return { count, hasIpv4Tail };
1582
+ }
1583
+ function isIpv6(rawValue) {
1584
+ if (!rawValue || !/^[0-9A-Fa-f:.]+$/.test(rawValue)) {
1585
+ return false;
1586
+ }
1587
+ const doubleColonCount = rawValue.split('::').length - 1;
1588
+ if (doubleColonCount > 1) {
1589
+ return false;
1590
+ }
1591
+ if (doubleColonCount === 0) {
1592
+ const full = countIpv6Segments(rawValue, true);
1593
+ return full !== null && full.count === 8;
1594
+ }
1595
+ const [left, right] = rawValue.split('::');
1596
+ const leftPart = countIpv6Segments(left, right.length === 0);
1597
+ const rightPart = countIpv6Segments(right, true);
1598
+ if (!leftPart || !rightPart) {
1599
+ return false;
1600
+ }
1601
+ const totalSegments = leftPart.count + rightPart.count;
1602
+ return totalSegments < 8;
1603
+ }
1604
+ function isIp(rawValue) {
1605
+ return isIpv4(rawValue) || isIpv6(rawValue);
1606
+ }
1607
+ function isDomain(rawValue) {
1608
+ if (rawValue.length < 1 || rawValue.length > 253 || rawValue.endsWith('.')) {
1609
+ return false;
1610
+ }
1611
+ const labels = rawValue.split('.');
1612
+ if (labels.length < 2) {
1613
+ return false;
1614
+ }
1615
+ if (labels.some(label => label.length < 1 || label.length > 63)) {
1616
+ return false;
1617
+ }
1618
+ const labelPattern = /^[A-Za-z0-9-]+$/;
1619
+ if (labels.some(label => !labelPattern.test(label) || label.startsWith('-') || label.endsWith('-'))) {
1620
+ return false;
1621
+ }
1622
+ const topLevelLabel = labels[labels.length - 1];
1623
+ return /[A-Za-z]/.test(topLevelLabel);
1624
+ }
1625
+
1626
+ class Coerce {
1627
+ constructor() { }
1628
+ static create(name, expectedType, validator, errorMessage) {
1629
+ return (rawValue) => {
1630
+ const value = Number(rawValue);
1631
+ if (!validator(value)) {
1632
+ throw new Error(errorMessage ?? `${name} is expected as ${expectedType}, but got ${rawValue}`);
1633
+ }
1634
+ return value;
1635
+ };
1636
+ }
1637
+ static choice(name, values, errorMessage) {
1638
+ return (rawValue) => {
1639
+ if (values.includes(rawValue)) {
1640
+ return rawValue;
1641
+ }
1642
+ throw new Error(errorMessage ?? `${name} is expected as one of [${values.join(', ')}], but got ${rawValue}`);
1643
+ };
1644
+ }
1645
+ static domain(name, errorMessage) {
1646
+ return (rawValue) => {
1647
+ if (isDomain(rawValue)) {
1648
+ return rawValue;
1649
+ }
1650
+ throw new Error(errorMessage ?? `${name} is expected as a valid domain, but got ${rawValue}`);
1651
+ };
1652
+ }
1653
+ static host(name, errorMessage) {
1654
+ return (rawValue) => {
1655
+ if (isIp(rawValue) || isDomain(rawValue)) {
1656
+ return rawValue;
1657
+ }
1658
+ throw new Error(errorMessage ?? `${name} is expected as a valid host (IP or domain), but got ${rawValue}`);
1659
+ };
1660
+ }
1661
+ static integer(name, errorMessage) {
1662
+ return this.create(name, 'an integer', value => Number.isInteger(value), errorMessage);
1663
+ }
1664
+ static ip(name, errorMessage) {
1665
+ return (rawValue) => {
1666
+ if (isIp(rawValue)) {
1667
+ return rawValue;
1668
+ }
1669
+ throw new Error(errorMessage ?? `${name} is expected as a valid IP address, but got ${rawValue}`);
1670
+ };
1671
+ }
1672
+ static number(name, errorMessage) {
1673
+ return this.create(name, 'a finite number', value => Number.isFinite(value), errorMessage);
1674
+ }
1675
+ static port(name, errorMessage) {
1676
+ return this.create(name, 'a valid port number (0-65535)', value => Number.isInteger(value) && value >= 0 && value <= 65535, errorMessage);
1677
+ }
1678
+ static positiveInteger(name, errorMessage) {
1679
+ return this.create(name, 'a positive integer', value => Number.isInteger(value) && value > 0, errorMessage);
1680
+ }
1681
+ static positiveNumber(name, errorMessage) {
1682
+ return this.create(name, 'a positive number', value => Number.isFinite(value) && value > 0, errorMessage);
1683
+ }
1684
+ }
1685
+
824
1686
  function camelToKebabCase(str) {
825
1687
  return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
826
1688
  }
827
1689
  class CompletionCommand extends Command {
828
- constructor(root, config) {
829
- const paths = config.paths;
1690
+ constructor(root, config = {}) {
830
1691
  const programName = config.programName ?? root.name ?? 'program';
1692
+ const paths = {
1693
+ ...createDefaultCompletionPaths(programName),
1694
+ ...config.paths,
1695
+ };
831
1696
  super({ desc: 'Generate shell completion script' });
832
1697
  this.option({
833
1698
  long: 'bash',
@@ -902,6 +1767,13 @@ class CompletionCommand extends Command {
902
1767
  });
903
1768
  }
904
1769
  }
1770
+ function createDefaultCompletionPaths(programName) {
1771
+ return {
1772
+ bash: `~/.local/share/bash-completion/completions/${programName}`,
1773
+ fish: `~/.config/fish/completions/${programName}.fish`,
1774
+ pwsh: '~/.config/powershell/Microsoft.PowerShell_profile.ps1',
1775
+ };
1776
+ }
905
1777
  function expandHome(filepath) {
906
1778
  if (filepath.startsWith('~/') || filepath === '~') {
907
1779
  const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
@@ -1179,30 +2051,4 @@ class PwshCompletion {
1179
2051
  }
1180
2052
  }
1181
2053
 
1182
- const logLevelOption = {
1183
- long: 'logLevel',
1184
- type: 'string',
1185
- args: 'required',
1186
- desc: 'Set log level',
1187
- default: 'info',
1188
- choices: LOG_LEVELS,
1189
- coerce: (raw) => {
1190
- const level = resolveLogLevel(raw);
1191
- if (level === undefined) {
1192
- throw new Error(`Invalid log level: ${raw}`);
1193
- }
1194
- return level;
1195
- },
1196
- apply: (value, ctx) => {
1197
- ctx.reporter.setLevel(value);
1198
- },
1199
- };
1200
- const silentOption = {
1201
- long: 'silent',
1202
- type: 'boolean',
1203
- args: 'none',
1204
- desc: 'Suppress non-error output',
1205
- default: false,
1206
- };
1207
-
1208
- export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion, logLevelOption, silentOption };
2054
+ export { BashCompletion, Coerce, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion, isDomain, isIp, isIpv4, isIpv6, logColorfulOption, logDateOption, logLevelOption, silentOption };