@guanghechen/commander 4.5.1 → 4.7.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.
@@ -1,6 +1,96 @@
1
- import { LOG_LEVELS, resolveLogLevel, Reporter } from '@guanghechen/reporter';
2
- import * as fs from 'node:fs';
3
- import * as path from 'node:path';
1
+ import { stat, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse } from '@guanghechen/env';
4
+ import { Reporter } from '@guanghechen/reporter';
5
+ import fs from 'node:fs';
6
+
7
+ const WINDOWS_DRIVE_ABSOLUTE_REGEX = /^[a-zA-Z]:[\\/]/;
8
+ function isAbsolutePath(filepath) {
9
+ return (filepath.startsWith('/') ||
10
+ filepath.startsWith('\\\\') ||
11
+ WINDOWS_DRIVE_ABSOLUTE_REGEX.test(filepath));
12
+ }
13
+ function resolvePathFrom(base, fragment) {
14
+ const useWindowsStyle = WINDOWS_DRIVE_ABSOLUTE_REGEX.test(base);
15
+ const normalizedBase = base.replace(/\\/g, '/');
16
+ const normalizedFragment = fragment.replace(/\\/g, '/');
17
+ const source = isAbsolutePath(normalizedFragment)
18
+ ? normalizedFragment
19
+ : `${normalizedBase.replace(/\/$/, '')}/${normalizedFragment}`;
20
+ const prefix = useWindowsStyle ? source.slice(0, 2) : '';
21
+ const body = useWindowsStyle ? source.slice(2) : source;
22
+ const stack = [];
23
+ for (const token of body.split('/')) {
24
+ if (token === '' || token === '.') {
25
+ continue;
26
+ }
27
+ if (token === '..') {
28
+ if (stack.length > 0) {
29
+ stack.pop();
30
+ }
31
+ continue;
32
+ }
33
+ stack.push(token);
34
+ }
35
+ if (useWindowsStyle) {
36
+ const resolved = `${prefix}/${stack.join('/')}`;
37
+ return resolved.endsWith('/') ? resolved.slice(0, -1) : resolved;
38
+ }
39
+ return `/${stack.join('/')}`;
40
+ }
41
+ function createUnsupportedFsError(operation) {
42
+ return new Error(`runtime does not support file-system operation: ${operation}`);
43
+ }
44
+ function getFallbackCwd() {
45
+ const proc = globalThis.process;
46
+ if (proc && typeof proc.cwd === 'function') {
47
+ return proc.cwd();
48
+ }
49
+ return '/';
50
+ }
51
+ function createBrowserCommandRuntime() {
52
+ return {
53
+ cwd: () => getFallbackCwd(),
54
+ isAbsolute: filepath => isAbsolutePath(filepath),
55
+ resolve: (...paths) => {
56
+ if (paths.length === 0) {
57
+ return getFallbackCwd();
58
+ }
59
+ let resolved = getFallbackCwd();
60
+ for (const path of paths) {
61
+ if (path.length === 0) {
62
+ continue;
63
+ }
64
+ resolved = resolvePathFrom(resolved, path);
65
+ }
66
+ return resolved;
67
+ },
68
+ readFile: async () => {
69
+ throw createUnsupportedFsError('readFile');
70
+ },
71
+ stat: async () => {
72
+ throw createUnsupportedFsError('stat');
73
+ },
74
+ };
75
+ }
76
+
77
+ let defaultRuntime = createBrowserCommandRuntime();
78
+ function getDefaultCommandRuntime() {
79
+ return defaultRuntime;
80
+ }
81
+ function setDefaultCommandRuntime(runtime) {
82
+ defaultRuntime = runtime;
83
+ }
84
+
85
+ function createNodeCommandRuntime() {
86
+ return {
87
+ cwd: () => process.cwd(),
88
+ isAbsolute: filepath => path.isAbsolute(filepath),
89
+ resolve: (...paths) => path.resolve(...paths),
90
+ readFile: filepath => readFile(filepath, 'utf8'),
91
+ stat: filepath => stat(filepath),
92
+ };
93
+ }
4
94
 
5
95
  const TERMINAL_STYLE = {
6
96
  bold: '\x1b[1m',
@@ -14,22 +104,35 @@ function styleText(text, ...styles) {
14
104
  return `${styles.join('')}${text}${TERMINAL_STYLE.reset}`;
15
105
  }
16
106
 
107
+ const BUILTIN_LOG_LEVELS = ['debug', 'info', 'hint', 'warn', 'error'];
108
+ function resolveReporterLogLevel(raw) {
109
+ const normalized = raw.trim().toLowerCase();
110
+ return BUILTIN_LOG_LEVELS.find(level => level === normalized);
111
+ }
112
+ function setReporterLevel(ctx, level) {
113
+ const reporter = ctx.reporter;
114
+ reporter?.setLevel?.(level);
115
+ }
116
+ function setReporterFlight(ctx, flight) {
117
+ const reporter = ctx.reporter;
118
+ reporter?.setFlight?.(flight);
119
+ }
17
120
  const logLevelOption = {
18
121
  long: 'logLevel',
19
122
  type: 'string',
20
123
  args: 'required',
21
124
  desc: 'Set log level',
22
125
  default: 'info',
23
- choices: LOG_LEVELS,
126
+ choices: [...BUILTIN_LOG_LEVELS],
24
127
  coerce: (raw) => {
25
- const level = resolveLogLevel(raw);
128
+ const level = resolveReporterLogLevel(raw);
26
129
  if (level === undefined) {
27
130
  throw new Error(`Invalid log level: ${raw}`);
28
131
  }
29
132
  return level;
30
133
  },
31
134
  apply: (value, ctx) => {
32
- ctx.reporter.setLevel(value);
135
+ setReporterLevel(ctx, value);
33
136
  },
34
137
  };
35
138
  const logDateOption = {
@@ -39,7 +142,7 @@ const logDateOption = {
39
142
  desc: 'Enable log timestamp',
40
143
  default: true,
41
144
  apply: (value, ctx) => {
42
- ctx.reporter.setFlight({ date: Boolean(value) });
145
+ setReporterFlight(ctx, { date: Boolean(value) });
43
146
  },
44
147
  };
45
148
  const logColorfulOption = {
@@ -49,7 +152,7 @@ const logColorfulOption = {
49
152
  desc: 'Enable colorful log output',
50
153
  default: true,
51
154
  apply: (value, ctx) => {
52
- ctx.reporter.setFlight({ color: Boolean(value) });
155
+ setReporterFlight(ctx, { color: Boolean(value) });
53
156
  },
54
157
  };
55
158
  const silentOption = {
@@ -60,7 +163,7 @@ const silentOption = {
60
163
  default: false,
61
164
  apply: (value, ctx) => {
62
165
  if (value) {
63
- ctx.reporter.setLevel('error');
166
+ setReporterLevel(ctx, 'error');
64
167
  }
65
168
  },
66
169
  };
@@ -81,6 +184,11 @@ class CommanderError extends Error {
81
184
 
82
185
  const LONG_OPTION_REGEX = /^--[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
83
186
  const NEGATIVE_OPTION_REGEX = /^--no-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
187
+ const PRESET_OPTS_FLAG = '--preset-opts';
188
+ const PRESET_ENVS_FLAG = '--preset-envs';
189
+ const PRESET_ROOT_FLAG = '--preset-root';
190
+ const DEFAULT_PRESET_OPTS_FILENAME = '.opt.local';
191
+ const DEFAULT_PRESET_ENVS_FILENAME = '.env.local';
84
192
  function kebabToCamelCase(str) {
85
193
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
86
194
  }
@@ -168,14 +276,12 @@ function tokenize(argv, commandPath) {
168
276
  }
169
277
  const BUILTIN_HELP_OPTION = {
170
278
  long: 'help',
171
- short: 'h',
172
279
  type: 'boolean',
173
280
  args: 'none',
174
281
  desc: 'Show help information',
175
282
  };
176
283
  const BUILTIN_VERSION_OPTION = {
177
284
  long: 'version',
178
- short: 'V',
179
285
  type: 'boolean',
180
286
  args: 'none',
181
287
  desc: 'Show version number',
@@ -189,6 +295,7 @@ const BUILTIN_COLOR_OPTION = {
189
295
  };
190
296
  function createBuiltinOptionState(enabled) {
191
297
  return {
298
+ version: enabled,
192
299
  color: enabled,
193
300
  logLevel: enabled,
194
301
  silent: enabled,
@@ -202,9 +309,6 @@ function isNoColorEnabled(envs) {
202
309
  function normalizeBuiltinConfig(builtin) {
203
310
  const resolved = {
204
311
  option: createBuiltinOptionState(true),
205
- command: {
206
- help: false,
207
- },
208
312
  };
209
313
  if (builtin === undefined) {
210
314
  return resolved;
@@ -212,13 +316,11 @@ function normalizeBuiltinConfig(builtin) {
212
316
  if (builtin === true) {
213
317
  return {
214
318
  option: createBuiltinOptionState(true),
215
- command: { help: true },
216
319
  };
217
320
  }
218
321
  if (builtin === false) {
219
322
  return {
220
323
  option: createBuiltinOptionState(false),
221
- command: { help: false },
222
324
  };
223
325
  }
224
326
  if (builtin.option !== undefined) {
@@ -229,6 +331,8 @@ function normalizeBuiltinConfig(builtin) {
229
331
  resolved.option = createBuiltinOptionState(true);
230
332
  }
231
333
  else {
334
+ if (builtin.option.version !== undefined)
335
+ resolved.option.version = builtin.option.version;
232
336
  if (builtin.option.color !== undefined)
233
337
  resolved.option.color = builtin.option.color;
234
338
  if (builtin.option.logLevel !== undefined) {
@@ -243,25 +347,17 @@ function normalizeBuiltinConfig(builtin) {
243
347
  }
244
348
  }
245
349
  }
246
- if (builtin.command !== undefined) {
247
- if (builtin.command === false) {
248
- resolved.command = { help: false };
249
- }
250
- else if (builtin.command === true) {
251
- resolved.command = { help: true };
252
- }
253
- else if (builtin.command.help !== undefined) {
254
- resolved.command.help = builtin.command.help;
255
- }
256
- }
257
350
  return resolved;
258
351
  }
259
352
  class Command {
260
353
  #name;
261
354
  #desc;
262
355
  #version;
356
+ #builtinConfig;
263
357
  #builtin;
358
+ #presetConfig;
264
359
  #reporter;
360
+ #runtime;
265
361
  #parent;
266
362
  #options = [];
267
363
  #arguments = [];
@@ -273,8 +369,11 @@ class Command {
273
369
  this.#name = config.name ?? '';
274
370
  this.#desc = config.desc;
275
371
  this.#version = config.version;
372
+ this.#builtinConfig = config.builtin;
276
373
  this.#builtin = normalizeBuiltinConfig(config.builtin);
374
+ this.#presetConfig = config.preset;
277
375
  this.#reporter = config.reporter;
376
+ this.#runtime = config.runtime ?? getDefaultCommandRuntime();
278
377
  }
279
378
  get name() {
280
379
  return this.#name || undefined;
@@ -285,6 +384,12 @@ class Command {
285
384
  get version() {
286
385
  return this.#version;
287
386
  }
387
+ get builtin() {
388
+ return this.#builtinConfig;
389
+ }
390
+ get preset() {
391
+ return this.#presetConfig === undefined ? undefined : { ...this.#presetConfig };
392
+ }
288
393
  get parent() {
289
394
  return this.#parent;
290
395
  }
@@ -320,14 +425,17 @@ class Command {
320
425
  return this;
321
426
  }
322
427
  subcommand(name, cmd) {
323
- if (this.#builtin.command.help && name === 'help') {
324
- throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
428
+ if (name === 'help') {
429
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name', this.#getCommandPath());
325
430
  }
326
431
  if (cmd.#parent && cmd.#parent !== this) {
327
432
  throw new CommanderError('ConfigurationError', `command "${cmd.#name}" already has a parent`, this.#getCommandPath());
328
433
  }
329
434
  const existing = this.#subcommandsList.find(e => e.command === cmd);
330
435
  if (existing) {
436
+ if (existing.aliases.includes(name)) {
437
+ return this;
438
+ }
331
439
  existing.aliases.push(name);
332
440
  this.#subcommandsMap.set(name, cmd);
333
441
  }
@@ -342,32 +450,35 @@ class Command {
342
450
  async run(params) {
343
451
  const { argv, envs, reporter } = params;
344
452
  try {
345
- const processedArgv = this.#processHelpSubcommand(argv);
346
- const routeResult = this.#route(processedArgv);
347
- const { chain, remaining } = routeResult;
453
+ const routeResult = this.#route(argv);
454
+ const { chain } = routeResult;
348
455
  const leafCommand = chain[chain.length - 1];
349
- const tokenizeResult = tokenize(remaining, leafCommand.#getCommandPath());
350
- const { optionTokens, restArgs } = tokenizeResult;
351
- const optionPolicyMap = this.#buildOptionPolicyMap(chain);
352
- const leafPolicy = this.#mustGetOptionPolicy(optionPolicyMap, leafCommand);
353
- if (leafPolicy.enableBuiltinHelp && this.#hasFlag(optionTokens, 'help', 'h')) {
354
- const helpColor = leafCommand.#resolveHelpColorOption(optionTokens, envs, leafPolicy);
355
- console.log(leafCommand.#formatHelpForDisplay({ color: helpColor }));
456
+ const ctx = this.#createContext({
457
+ chain,
458
+ cmds: routeResult.cmds,
459
+ envs,
460
+ reporter,
461
+ });
462
+ const controlScanResult = this.#controlScan(routeResult.remaining, leafCommand);
463
+ ctx.controls = controlScanResult.controls;
464
+ ctx.sources.user.argv = [...controlScanResult.remaining];
465
+ if (ctx.controls.help) {
466
+ const helpCommand = this.#resolveHelpCommand(leafCommand, controlScanResult.helpTarget);
467
+ const helpColor = helpCommand.#resolveHelpColorFromTailArgv(controlScanResult.remaining, ctx.envs);
468
+ console.log(helpCommand.#formatHelpForDisplay({ color: helpColor }));
356
469
  return;
357
470
  }
358
- if (leafPolicy.enableBuiltinVersion) {
359
- if (this.#hasFlag(optionTokens, 'version', 'V')) {
360
- console.log(leafCommand.#version);
361
- return;
362
- }
471
+ if (ctx.controls.version) {
472
+ console.log(leafCommand.#version);
473
+ return;
363
474
  }
475
+ const optionPolicyMap = this.#buildOptionPolicyMap(chain);
476
+ const presetResult = await this.#preset(controlScanResult.remaining, ctx, optionPolicyMap);
477
+ ctx.sources = presetResult.sources;
478
+ ctx.envs = presetResult.envs;
479
+ const tokenizeResult = tokenize(presetResult.tailArgv, leafCommand.#getCommandPath());
480
+ const { optionTokens, restArgs } = tokenizeResult;
364
481
  const resolveResult = this.#resolve(chain, optionTokens, optionPolicyMap);
365
- const ctx = {
366
- cmd: leafCommand,
367
- envs,
368
- reporter: reporter ?? this.#reporter ?? new Reporter(),
369
- argv,
370
- };
371
482
  const parseResult = this.#parse(chain, resolveResult, optionPolicyMap, ctx, restArgs);
372
483
  const actionParams = {
373
484
  ctx: parseResult.ctx,
@@ -379,7 +490,7 @@ class Command {
379
490
  await leafCommand.#runAction(actionParams);
380
491
  }
381
492
  else if (leafCommand.#subcommandsList.length > 0) {
382
- const helpColor = leafCommand.#resolveHelpColorOption(optionTokens, envs, leafPolicy);
493
+ const helpColor = leafCommand.#resolveHelpColorFromTailArgv(presetResult.tailArgv, ctx.envs);
383
494
  console.log(leafCommand.#formatHelpForDisplay({ color: helpColor }));
384
495
  }
385
496
  else {
@@ -395,22 +506,27 @@ class Command {
395
506
  throw err;
396
507
  }
397
508
  }
398
- parse(params) {
509
+ async parse(params) {
399
510
  const { argv, envs, reporter } = params;
400
- const processedArgv = this.#processHelpSubcommand(argv);
401
- const routeResult = this.#route(processedArgv);
402
- const { chain, remaining } = routeResult;
511
+ const routeResult = this.#route(argv);
512
+ const { chain } = routeResult;
403
513
  const leafCommand = chain[chain.length - 1];
404
- const tokenizeResult = tokenize(remaining, leafCommand.#getCommandPath());
405
- const { optionTokens, restArgs } = tokenizeResult;
514
+ const ctx = this.#createContext({
515
+ chain,
516
+ cmds: routeResult.cmds,
517
+ envs,
518
+ reporter,
519
+ });
520
+ const controlScanResult = this.#controlScan(routeResult.remaining, leafCommand);
521
+ ctx.controls = controlScanResult.controls;
522
+ ctx.sources.user.argv = [...controlScanResult.remaining];
406
523
  const optionPolicyMap = this.#buildOptionPolicyMap(chain);
524
+ const presetResult = await this.#preset(controlScanResult.remaining, ctx, optionPolicyMap);
525
+ ctx.sources = presetResult.sources;
526
+ ctx.envs = presetResult.envs;
527
+ const tokenizeResult = tokenize(presetResult.tailArgv, leafCommand.#getCommandPath());
528
+ const { optionTokens, restArgs } = tokenizeResult;
407
529
  const resolveResult = this.#resolve(chain, optionTokens, optionPolicyMap);
408
- const ctx = {
409
- cmd: leafCommand,
410
- envs,
411
- reporter: reporter ?? this.#reporter ?? new Reporter(),
412
- argv,
413
- };
414
530
  return this.#parse(chain, resolveResult, optionPolicyMap, ctx, restArgs);
415
531
  }
416
532
  formatHelp() {
@@ -428,7 +544,11 @@ class Command {
428
544
  return color && process.stdout.isTTY === true;
429
545
  }
430
546
  #buildHelpData() {
431
- const allOptions = this.#resolveOptionPolicy().mergedOptions;
547
+ const parseOptions = this.#resolveOptionPolicy().mergedOptions;
548
+ const allOptions = [...parseOptions, BUILTIN_HELP_OPTION];
549
+ if (this.#supportsBuiltinVersion()) {
550
+ allOptions.push(BUILTIN_VERSION_OPTION);
551
+ }
432
552
  const commandPath = this.#getCommandPath();
433
553
  let usage = `Usage: ${commandPath}`;
434
554
  if (allOptions.length > 0)
@@ -462,7 +582,10 @@ class Command {
462
582
  desc += ` [choices: ${opt.choices.join(', ')}]`;
463
583
  }
464
584
  options.push({ sig, desc });
465
- if (opt.type === 'boolean' && opt.args === 'none') {
585
+ if (opt.type === 'boolean' &&
586
+ opt.args === 'none' &&
587
+ opt.long !== 'help' &&
588
+ opt.long !== 'version') {
466
589
  options.push({
467
590
  sig: ` --no-${kebabLong}`,
468
591
  desc: `Negate --${kebabLong}`,
@@ -470,8 +593,7 @@ class Command {
470
593
  }
471
594
  }
472
595
  const commands = [];
473
- const showHelpSubcommand = this.#builtin.command.help && this.#subcommandsList.length > 0;
474
- if (showHelpSubcommand) {
596
+ if (this.#subcommandsList.length > 0) {
475
597
  commands.push({ name: 'help', desc: 'Show help for a command' });
476
598
  }
477
599
  for (const entry of this.#subcommandsList) {
@@ -594,50 +716,9 @@ class Command {
594
716
  #findSubcommandEntry(token) {
595
717
  return this.#subcommandsList.find(e => e.name === token || e.aliases.includes(token));
596
718
  }
597
- #createUnknownSubcommandError(subcommand) {
598
- const commandPath = this.#getCommandPath();
599
- return new CommanderError('UnknownSubcommand', `unknown subcommand "${subcommand}" for command "${commandPath}"`, commandPath);
600
- }
601
- #processHelpSubcommand(argv) {
602
- let current = this;
603
- for (let i = 0; i < argv.length; ++i) {
604
- const token = argv[i];
605
- if (token.startsWith('-')) {
606
- return argv;
607
- }
608
- if (token === 'help') {
609
- if (!current.#builtin.command.help) {
610
- if (current.#subcommandsList.length > 0) {
611
- throw current.#createUnknownSubcommandError('help');
612
- }
613
- return argv;
614
- }
615
- if (current.#subcommandsList.length === 0) {
616
- return argv;
617
- }
618
- const target = argv[i + 1];
619
- if (target === undefined) {
620
- return [...argv.slice(0, i), '--help'];
621
- }
622
- const targetEntry = current.#findSubcommandEntry(target);
623
- if (targetEntry === undefined) {
624
- throw current.#createUnknownSubcommandError(target);
625
- }
626
- if (argv[i + 2] !== undefined) {
627
- throw new CommanderError('UnexpectedArgument', 'help subcommand accepts at most one subcommand argument', current.#getCommandPath());
628
- }
629
- return [...argv.slice(0, i), target, '--help'];
630
- }
631
- const entry = current.#findSubcommandEntry(token);
632
- if (entry === undefined) {
633
- return argv;
634
- }
635
- current = entry.command;
636
- }
637
- return argv;
638
- }
639
719
  #route(argv) {
640
720
  const chain = [this];
721
+ const cmds = [];
641
722
  let current = this;
642
723
  let idx = 0;
643
724
  while (idx < argv.length) {
@@ -648,10 +729,382 @@ class Command {
648
729
  if (!entry)
649
730
  break;
650
731
  current = entry.command;
732
+ cmds.push(token);
651
733
  chain.push(current);
652
734
  idx += 1;
653
735
  }
654
- return { chain, remaining: argv.slice(idx) };
736
+ return { chain, remaining: argv.slice(idx), cmds };
737
+ }
738
+ #controlScan(tailArgv, leafCommand) {
739
+ const controls = { help: false, version: false };
740
+ const separatorIndex = tailArgv.indexOf('--');
741
+ const beforeSeparator = separatorIndex === -1 ? tailArgv : tailArgv.slice(0, separatorIndex);
742
+ const afterSeparator = separatorIndex === -1 ? [] : tailArgv.slice(separatorIndex + 1);
743
+ let helpTarget;
744
+ let scanStartIndex = 0;
745
+ if (beforeSeparator[0] === 'help') {
746
+ controls.help = true;
747
+ scanStartIndex = 1;
748
+ const candidate = beforeSeparator[1];
749
+ if (candidate !== undefined && !candidate.startsWith('-')) {
750
+ helpTarget = candidate;
751
+ scanStartIndex = 2;
752
+ }
753
+ }
754
+ const remainingBeforeSeparator = [];
755
+ for (let i = scanStartIndex; i < beforeSeparator.length; i += 1) {
756
+ const token = beforeSeparator[i];
757
+ if (token === '--help') {
758
+ controls.help = true;
759
+ continue;
760
+ }
761
+ if (token === '--version' && leafCommand.#supportsBuiltinVersion()) {
762
+ controls.version = true;
763
+ continue;
764
+ }
765
+ remainingBeforeSeparator.push(token);
766
+ }
767
+ const remaining = separatorIndex === -1
768
+ ? remainingBeforeSeparator
769
+ : [...remainingBeforeSeparator, '--', ...afterSeparator];
770
+ return {
771
+ controls,
772
+ remaining,
773
+ helpTarget,
774
+ };
775
+ }
776
+ #createContext(params) {
777
+ const { chain, cmds, envs, reporter } = params;
778
+ const leafCommand = chain[chain.length - 1];
779
+ const envSnapshot = { ...envs };
780
+ return {
781
+ cmd: leafCommand,
782
+ chain,
783
+ envs: envSnapshot,
784
+ controls: { help: false, version: false },
785
+ sources: {
786
+ preset: {
787
+ argv: [],
788
+ envs: {},
789
+ },
790
+ user: {
791
+ cmds: [...cmds],
792
+ argv: [],
793
+ envs: envSnapshot,
794
+ },
795
+ },
796
+ reporter: reporter ?? this.#reporter ?? new Reporter(),
797
+ };
798
+ }
799
+ #resolveHelpCommand(leafCommand, helpTarget) {
800
+ if (helpTarget === undefined) {
801
+ return leafCommand;
802
+ }
803
+ const target = leafCommand.#findSubcommandEntry(helpTarget);
804
+ if (target === undefined) {
805
+ return leafCommand;
806
+ }
807
+ return target.command;
808
+ }
809
+ async #preset(controlTailArgv, ctx, optionPolicyMap) {
810
+ const commandPath = ctx.chain[ctx.chain.length - 1].#getCommandPath();
811
+ const separatorIndex = controlTailArgv.indexOf('--');
812
+ const beforeSeparator = separatorIndex === -1 ? controlTailArgv : controlTailArgv.slice(0, separatorIndex);
813
+ const afterSeparator = separatorIndex === -1 ? [] : controlTailArgv.slice(separatorIndex + 1);
814
+ const rootScanResult = this.#scanPresetRootDirectives(beforeSeparator, commandPath);
815
+ const commandPreset = this.#resolveCommandPresetFromChain(ctx.chain);
816
+ const presetRoot = await this.#resolveEffectivePresetRoot(rootScanResult.cliPresetRoots, commandPreset, commandPath);
817
+ const fileScanResult = this.#scanPresetFileDirectives(rootScanResult.cleanArgv, commandPath);
818
+ const cleanArgv = separatorIndex === -1
819
+ ? fileScanResult.cleanArgv
820
+ : [...fileScanResult.cleanArgv, '--', ...afterSeparator];
821
+ const presetOptsFiles = this.#resolvePresetFileSources({
822
+ cliFiles: fileScanResult.cliPresetOptsFiles,
823
+ commandPresetFile: this.#normalizeCommandPresetFile(commandPreset?.opt),
824
+ presetRoot,
825
+ defaultFilename: DEFAULT_PRESET_OPTS_FILENAME,
826
+ });
827
+ const presetEnvsFiles = this.#resolvePresetFileSources({
828
+ cliFiles: fileScanResult.cliPresetEnvsFiles,
829
+ commandPresetFile: this.#normalizeCommandPresetFile(commandPreset?.env),
830
+ presetRoot,
831
+ defaultFilename: DEFAULT_PRESET_ENVS_FILENAME,
832
+ });
833
+ const userSources = {
834
+ cmds: [...ctx.sources.user.cmds],
835
+ argv: [...cleanArgv],
836
+ envs: { ...ctx.sources.user.envs },
837
+ };
838
+ const presetArgv = [];
839
+ for (const file of presetOptsFiles) {
840
+ const content = await this.#readPresetFile(file, commandPath);
841
+ if (content === undefined) {
842
+ continue;
843
+ }
844
+ const tokens = this.#tokenizePresetOptions(content);
845
+ this.#validatePresetOptionTokens(tokens, file.displayPath, commandPath);
846
+ this.#assertPresetOptionFragments(tokens, file.displayPath, ctx.chain, optionPolicyMap);
847
+ presetArgv.push(...tokens);
848
+ }
849
+ const presetEnvs = {};
850
+ for (const file of presetEnvsFiles) {
851
+ const content = await this.#readPresetFile(file, commandPath);
852
+ if (content === undefined) {
853
+ continue;
854
+ }
855
+ let parsed;
856
+ try {
857
+ parsed = parse(content);
858
+ }
859
+ catch (error) {
860
+ throw new CommanderError('ConfigurationError', `failed to parse preset envs file "${file.displayPath}": ${error.message}`, commandPath);
861
+ }
862
+ Object.assign(presetEnvs, parsed);
863
+ }
864
+ const sources = {
865
+ user: userSources,
866
+ preset: {
867
+ argv: presetArgv,
868
+ envs: presetEnvs,
869
+ },
870
+ };
871
+ const envs = { ...sources.user.envs, ...sources.preset.envs };
872
+ const tailArgv = [...sources.preset.argv, ...sources.user.argv];
873
+ return { tailArgv, envs, sources };
874
+ }
875
+ #resolveCommandPresetFromChain(chain) {
876
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
877
+ const preset = chain[index].#presetConfig;
878
+ if (preset?.root !== undefined) {
879
+ return preset;
880
+ }
881
+ }
882
+ return undefined;
883
+ }
884
+ async #resolveEffectivePresetRoot(cliPresetRoots, commandPreset, commandPath) {
885
+ if (cliPresetRoots.length > 0) {
886
+ const root = cliPresetRoots[cliPresetRoots.length - 1];
887
+ return await this.#assertPresetRoot(root, PRESET_ROOT_FLAG, commandPath);
888
+ }
889
+ if (commandPreset?.root === undefined) {
890
+ return undefined;
891
+ }
892
+ return await this.#assertPresetRoot(commandPreset.root, 'command.preset.root', commandPath);
893
+ }
894
+ async #assertPresetRoot(root, sourceName, commandPath) {
895
+ if (!this.#runtime.isAbsolute(root)) {
896
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" is not an absolute directory`, commandPath);
897
+ }
898
+ let stats;
899
+ try {
900
+ stats = await this.#runtime.stat(root);
901
+ }
902
+ catch (error) {
903
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" cannot be accessed (${error.message})`, commandPath);
904
+ }
905
+ if (!stats.isDirectory()) {
906
+ throw new CommanderError('ConfigurationError', `invalid preset root from "${sourceName}": "${root}" is not a directory`, commandPath);
907
+ }
908
+ return root;
909
+ }
910
+ #normalizeCommandPresetFile(filepath) {
911
+ if (filepath === undefined) {
912
+ return undefined;
913
+ }
914
+ if (!this.#isValidPresetFileValue(filepath)) {
915
+ return undefined;
916
+ }
917
+ return filepath;
918
+ }
919
+ #resolvePresetFileSources(params) {
920
+ const { cliFiles, commandPresetFile, presetRoot, defaultFilename } = params;
921
+ if (cliFiles.length > 0) {
922
+ return cliFiles.map(filepath => ({
923
+ displayPath: filepath,
924
+ absolutePath: this.#resolvePresetFileAbsolutePath(filepath, presetRoot),
925
+ explicit: true,
926
+ }));
927
+ }
928
+ if (presetRoot === undefined) {
929
+ return [];
930
+ }
931
+ if (commandPresetFile !== undefined) {
932
+ return [
933
+ {
934
+ displayPath: commandPresetFile,
935
+ absolutePath: this.#resolvePresetFileAbsolutePath(commandPresetFile, presetRoot),
936
+ explicit: true,
937
+ },
938
+ ];
939
+ }
940
+ const absolutePath = this.#runtime.resolve(presetRoot, defaultFilename);
941
+ return [
942
+ {
943
+ displayPath: absolutePath,
944
+ absolutePath,
945
+ explicit: false,
946
+ },
947
+ ];
948
+ }
949
+ #resolvePresetFileAbsolutePath(filepath, presetRoot) {
950
+ if (this.#runtime.isAbsolute(filepath)) {
951
+ return filepath;
952
+ }
953
+ if (presetRoot !== undefined) {
954
+ return this.#runtime.resolve(presetRoot, filepath);
955
+ }
956
+ return this.#runtime.resolve(this.#runtime.cwd(), filepath);
957
+ }
958
+ #assertPresetOptionFragments(tokens, filepath, chain, optionPolicyMap) {
959
+ if (tokens.length === 0) {
960
+ return;
961
+ }
962
+ const commandPath = chain[chain.length - 1].#getCommandPath();
963
+ try {
964
+ const { optionTokens, restArgs } = tokenize(tokens, commandPath);
965
+ void restArgs;
966
+ const { argTokens } = this.#resolve(chain, optionTokens, optionPolicyMap);
967
+ if (argTokens.length > 0) {
968
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": token "${argTokens[0].original}" cannot be resolved as an option fragment`, commandPath);
969
+ }
970
+ }
971
+ catch (error) {
972
+ if (error instanceof CommanderError) {
973
+ if (error.kind === 'ConfigurationError') {
974
+ throw error;
975
+ }
976
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": ${error.message}`, commandPath);
977
+ }
978
+ throw error;
979
+ }
980
+ }
981
+ #scanPresetRootDirectives(argv, commandPath) {
982
+ const cleanArgv = [];
983
+ const cliPresetRoots = [];
984
+ let index = 0;
985
+ while (index < argv.length) {
986
+ const token = argv[index];
987
+ if (token === PRESET_ROOT_FLAG) {
988
+ const value = argv[index + 1];
989
+ if (value === undefined || value.length === 0) {
990
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ROOT_FLAG}"`, commandPath);
991
+ }
992
+ cliPresetRoots.push(value);
993
+ index += 2;
994
+ continue;
995
+ }
996
+ if (token.startsWith(`${PRESET_ROOT_FLAG}=`)) {
997
+ const value = token.slice(PRESET_ROOT_FLAG.length + 1);
998
+ if (value.length === 0) {
999
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ROOT_FLAG}"`, commandPath);
1000
+ }
1001
+ cliPresetRoots.push(value);
1002
+ index += 1;
1003
+ continue;
1004
+ }
1005
+ cleanArgv.push(token);
1006
+ index += 1;
1007
+ }
1008
+ return { cleanArgv, cliPresetRoots };
1009
+ }
1010
+ #scanPresetFileDirectives(argv, commandPath) {
1011
+ const cleanArgv = [];
1012
+ const cliPresetOptsFiles = [];
1013
+ const cliPresetEnvsFiles = [];
1014
+ const assertAndPush = (flag, value) => {
1015
+ this.#assertPresetFileValue(value, flag, commandPath);
1016
+ if (flag === PRESET_OPTS_FLAG) {
1017
+ cliPresetOptsFiles.push(value);
1018
+ }
1019
+ else {
1020
+ cliPresetEnvsFiles.push(value);
1021
+ }
1022
+ };
1023
+ let index = 0;
1024
+ while (index < argv.length) {
1025
+ const token = argv[index];
1026
+ if (token === PRESET_OPTS_FLAG || token === PRESET_ENVS_FLAG) {
1027
+ const value = argv[index + 1];
1028
+ if (value === undefined || value.length === 0) {
1029
+ throw new CommanderError('ConfigurationError', `missing value for "${token}"`, commandPath);
1030
+ }
1031
+ assertAndPush(token, value);
1032
+ index += 2;
1033
+ continue;
1034
+ }
1035
+ if (token.startsWith(`${PRESET_OPTS_FLAG}=`)) {
1036
+ const value = token.slice(PRESET_OPTS_FLAG.length + 1);
1037
+ if (value.length === 0) {
1038
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_OPTS_FLAG}"`, commandPath);
1039
+ }
1040
+ assertAndPush(PRESET_OPTS_FLAG, value);
1041
+ index += 1;
1042
+ continue;
1043
+ }
1044
+ if (token.startsWith(`${PRESET_ENVS_FLAG}=`)) {
1045
+ const value = token.slice(PRESET_ENVS_FLAG.length + 1);
1046
+ if (value.length === 0) {
1047
+ throw new CommanderError('ConfigurationError', `missing value for "${PRESET_ENVS_FLAG}"`, commandPath);
1048
+ }
1049
+ assertAndPush(PRESET_ENVS_FLAG, value);
1050
+ index += 1;
1051
+ continue;
1052
+ }
1053
+ cleanArgv.push(token);
1054
+ index += 1;
1055
+ }
1056
+ return { cleanArgv, cliPresetOptsFiles, cliPresetEnvsFiles };
1057
+ }
1058
+ #isValidPresetFileValue(filepath) {
1059
+ return filepath.length > 0 && !filepath.startsWith('..');
1060
+ }
1061
+ #assertPresetFileValue(filepath, directive, commandPath) {
1062
+ if (this.#isValidPresetFileValue(filepath)) {
1063
+ return;
1064
+ }
1065
+ throw new CommanderError('ConfigurationError', `invalid value for "${directive}": "${filepath}" (must be non-empty and must not start with "..")`, commandPath);
1066
+ }
1067
+ async #readPresetFile(file, commandPath) {
1068
+ try {
1069
+ return await this.#runtime.readFile(file.absolutePath);
1070
+ }
1071
+ catch (error) {
1072
+ const ioError = error;
1073
+ if (!file.explicit && ioError.code === 'ENOENT') {
1074
+ return undefined;
1075
+ }
1076
+ throw new CommanderError('ConfigurationError', `failed to read preset file "${file.displayPath}": ${error.message}`, commandPath);
1077
+ }
1078
+ }
1079
+ #tokenizePresetOptions(content) {
1080
+ return content
1081
+ .split(/\s+/)
1082
+ .map(token => token.trim())
1083
+ .filter(token => token.length > 0);
1084
+ }
1085
+ #validatePresetOptionTokens(tokens, filepath, commandPath) {
1086
+ if (tokens.length === 0) {
1087
+ return;
1088
+ }
1089
+ if (!tokens[0].startsWith('-')) {
1090
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": bare token "${tokens[0]}" cannot appear before any option token`, commandPath);
1091
+ }
1092
+ for (const token of tokens) {
1093
+ if (token === '--') {
1094
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": "--" is not allowed`, commandPath);
1095
+ }
1096
+ if (token === 'help' || token === '--help' || token === '--version') {
1097
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": control token "${token}" is not allowed`, commandPath);
1098
+ }
1099
+ if (token === PRESET_ROOT_FLAG ||
1100
+ token.startsWith(`${PRESET_ROOT_FLAG}=`) ||
1101
+ token === PRESET_OPTS_FLAG ||
1102
+ token.startsWith(`${PRESET_OPTS_FLAG}=`) ||
1103
+ token === PRESET_ENVS_FLAG ||
1104
+ token.startsWith(`${PRESET_ENVS_FLAG}=`)) {
1105
+ throw new CommanderError('ConfigurationError', `invalid preset options in "${filepath}": preset directive "${token}" is not allowed`, commandPath);
1106
+ }
1107
+ }
655
1108
  }
656
1109
  #resolve(chain, tokens, optionPolicyMap) {
657
1110
  const consumedTokens = new Map();
@@ -761,13 +1214,20 @@ class Command {
761
1214
  }
762
1215
  }
763
1216
  }
764
- const mergedOpts = {};
765
- for (const cmd of chain) {
766
- Object.assign(mergedOpts, optsMap.get(cmd) ?? {});
1217
+ const leafLocalOpts = {};
1218
+ const leafParsedOpts = optsMap.get(leafCommand) ?? {};
1219
+ for (const opt of leafCommand.#options) {
1220
+ if (Object.prototype.hasOwnProperty.call(leafParsedOpts, opt.long)) {
1221
+ leafLocalOpts[opt.long] = leafParsedOpts[opt.long];
1222
+ }
767
1223
  }
768
1224
  const rawArgStrings = [...argTokens.map(t => t.original), ...restArgs];
769
1225
  const { args, rawArgs } = leafCommand.#parseArguments(rawArgStrings);
770
- return { ctx, opts: mergedOpts, args, rawArgs };
1226
+ const parseCtx = {
1227
+ ...ctx,
1228
+ sources: this.#freezeInputSources(ctx.sources),
1229
+ };
1230
+ return { ctx: parseCtx, opts: leafLocalOpts, args, rawArgs };
771
1231
  }
772
1232
  #parseOptions(tokens, allOptions, envs) {
773
1233
  const opts = {};
@@ -954,29 +1414,19 @@ class Command {
954
1414
  #hasUserOption(long) {
955
1415
  return this.#options.some(option => option.long === long);
956
1416
  }
957
- #canUseBuiltinVersion() {
958
- return this.#version !== undefined;
1417
+ #supportsBuiltinVersion() {
1418
+ return this.#parent === undefined && this.#version !== undefined && this.#builtin.option.version;
959
1419
  }
960
1420
  #resolveOptionPolicy() {
961
1421
  const optionMap = new Map();
962
1422
  const hasUserColor = this.#hasUserOption('color');
963
- const hasUserHelp = this.#hasUserOption('help');
964
- const hasUserVersion = this.#hasUserOption('version');
965
1423
  const hasUserLogLevel = this.#hasUserOption('logLevel');
966
1424
  const hasUserSilent = this.#hasUserOption('silent');
967
1425
  const hasUserLogDate = this.#hasUserOption('logDate');
968
1426
  const hasUserLogColorful = this.#hasUserOption('logColorful');
969
- const enableBuiltinHelp = !hasUserHelp;
970
- const enableBuiltinVersion = !hasUserVersion && this.#canUseBuiltinVersion();
971
1427
  if (this.#builtin.option.color && !hasUserColor) {
972
1428
  optionMap.set('color', BUILTIN_COLOR_OPTION);
973
1429
  }
974
- if (enableBuiltinHelp) {
975
- optionMap.set('help', BUILTIN_HELP_OPTION);
976
- }
977
- if (enableBuiltinVersion) {
978
- optionMap.set('version', BUILTIN_VERSION_OPTION);
979
- }
980
1430
  if (this.#builtin.option.logLevel && !hasUserLogLevel) {
981
1431
  optionMap.set('logLevel', logLevelOption);
982
1432
  }
@@ -994,8 +1444,6 @@ class Command {
994
1444
  }
995
1445
  return {
996
1446
  mergedOptions: Array.from(optionMap.values()),
997
- enableBuiltinHelp,
998
- enableBuiltinVersion,
999
1447
  };
1000
1448
  }
1001
1449
  #buildOptionPolicyMap(chain) {
@@ -1032,6 +1480,9 @@ class Command {
1032
1480
  }
1033
1481
  }
1034
1482
  #validateOptionConfig(opt) {
1483
+ if (opt.long === 'help' || opt.long === 'version') {
1484
+ throw new CommanderError('ConfigurationError', `option long name "${opt.long}" is reserved`, this.#getCommandPath());
1485
+ }
1035
1486
  if (opt.type === 'boolean' && opt.args !== 'none') {
1036
1487
  throw new CommanderError('ConfigurationError', `boolean option "--${opt.long}" must have args: 'none'`, this.#getCommandPath());
1037
1488
  }
@@ -1112,22 +1563,27 @@ class Command {
1112
1563
  process.exit(1);
1113
1564
  }
1114
1565
  }
1115
- #resolveHelpColorOption(tokens, envs, policy = this.#resolveOptionPolicy()) {
1566
+ #resolveHelpColorFromTailArgv(tailArgv, envs, policy = this.#resolveOptionPolicy()) {
1116
1567
  const colorOption = policy.mergedOptions.find(opt => opt.long === 'color');
1117
1568
  let color = !isNoColorEnabled(envs);
1118
1569
  if (!colorOption || colorOption.type !== 'boolean' || colorOption.args !== 'none') {
1119
1570
  return color;
1120
1571
  }
1121
- for (const token of tokens) {
1122
- if (token.type !== 'long' || token.name !== 'color') {
1572
+ const separatorIndex = tailArgv.indexOf('--');
1573
+ const scanTokens = separatorIndex === -1 ? tailArgv : tailArgv.slice(0, separatorIndex);
1574
+ for (const token of scanTokens) {
1575
+ if (token === '--color') {
1576
+ color = true;
1123
1577
  continue;
1124
1578
  }
1125
- const eqIdx = token.resolved.indexOf('=');
1126
- if (eqIdx === -1) {
1127
- color = true;
1579
+ if (token === '--no-color') {
1580
+ color = false;
1128
1581
  continue;
1129
1582
  }
1130
- const value = token.resolved.slice(eqIdx + 1);
1583
+ if (!token.startsWith('--color=')) {
1584
+ continue;
1585
+ }
1586
+ const value = token.slice('--color='.length);
1131
1587
  if (value === 'true') {
1132
1588
  color = true;
1133
1589
  }
@@ -1140,16 +1596,18 @@ class Command {
1140
1596
  }
1141
1597
  return color;
1142
1598
  }
1143
- #hasFlag(tokens, longName, shortName) {
1144
- for (const token of tokens) {
1145
- if (token.type === 'long' && token.name === longName) {
1146
- return true;
1147
- }
1148
- if (token.type === 'short' && token.name === shortName) {
1149
- return true;
1150
- }
1151
- }
1152
- return false;
1599
+ #freezeInputSources(sources) {
1600
+ return Object.freeze({
1601
+ preset: Object.freeze({
1602
+ argv: Object.freeze([...sources.preset.argv]),
1603
+ envs: Object.freeze({ ...sources.preset.envs }),
1604
+ }),
1605
+ user: Object.freeze({
1606
+ cmds: Object.freeze([...sources.user.cmds]),
1607
+ argv: Object.freeze([...sources.user.argv]),
1608
+ envs: Object.freeze({ ...sources.user.envs }),
1609
+ }),
1610
+ });
1153
1611
  }
1154
1612
  #getCommandPath() {
1155
1613
  const parts = [];
@@ -1318,9 +1776,12 @@ function camelToKebabCase(str) {
1318
1776
  return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
1319
1777
  }
1320
1778
  class CompletionCommand extends Command {
1321
- constructor(root, config) {
1322
- const paths = config.paths;
1779
+ constructor(root, config = {}) {
1323
1780
  const programName = config.programName ?? root.name ?? 'program';
1781
+ const paths = {
1782
+ ...createDefaultCompletionPaths(programName),
1783
+ ...config.paths,
1784
+ };
1324
1785
  super({ desc: 'Generate shell completion script' });
1325
1786
  this.option({
1326
1787
  long: 'bash',
@@ -1395,6 +1856,13 @@ class CompletionCommand extends Command {
1395
1856
  });
1396
1857
  }
1397
1858
  }
1859
+ function createDefaultCompletionPaths(programName) {
1860
+ return {
1861
+ bash: `~/.local/share/bash-completion/completions/${programName}`,
1862
+ fish: `~/.config/fish/completions/${programName}.fish`,
1863
+ pwsh: '~/.config/powershell/Microsoft.PowerShell_profile.ps1',
1864
+ };
1865
+ }
1398
1866
  function expandHome(filepath) {
1399
1867
  if (filepath.startsWith('~/') || filepath === '~') {
1400
1868
  const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
@@ -1672,4 +2140,6 @@ class PwshCompletion {
1672
2140
  }
1673
2141
  }
1674
2142
 
1675
- export { BashCompletion, Coerce, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion, isDomain, isIp, isIpv4, isIpv6, logColorfulOption, logDateOption, logLevelOption, silentOption };
2143
+ setDefaultCommandRuntime(createNodeCommandRuntime());
2144
+
2145
+ export { BashCompletion, Coerce, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion, createBrowserCommandRuntime, createNodeCommandRuntime, getDefaultCommandRuntime, isDomain, isIp, isIpv4, isIpv6, logColorfulOption, logDateOption, logLevelOption, setDefaultCommandRuntime, silentOption };