@vmz/commander 0.3.6 → 0.3.7
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/README.md +94 -67
- package/dist/index.d.ts +14 -4
- package/dist/index.js +448 -69
- package/dist/locales.js +8 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,67 +1,94 @@
|
|
|
1
|
-
# `@vmz/commander`
|
|
2
|
-
|
|
3
|
-
i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@vmz/vmz`.
|
|
4
|
-
|
|
5
|
-
- Fluent registration; command / option second args are **message ids**
|
|
6
|
-
- **Help is derived** from the command tree — do not paste Usage walls into catalogs
|
|
7
|
-
- **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
|
|
8
|
-
- Framework chrome/err ids are `commander.*` with tiny English fallbacks (products may override)
|
|
9
|
-
- Interactive help uses a small ANSI theme for headings, commands, options, and errors; `NO_COLOR` disables it, `FORCE_COLOR` enables it outside a TTY
|
|
10
|
-
- **No product language packs** in this package
|
|
11
|
-
|
|
12
|
-
## Quick start
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
import { createCli } from '@vmz/commander';
|
|
16
|
-
|
|
17
|
-
const cli = createCli('my-cli')
|
|
18
|
-
.locales(new URL('./locales', import.meta.url).pathname) // or absolute path
|
|
19
|
-
.intro('cli.intro')
|
|
20
|
-
.command('build', 'cli.cmd.build')
|
|
21
|
-
.option('--out-dir, -o <dir>', 'cli.opt.out-dir')
|
|
22
|
-
.action(async (options) => {
|
|
23
|
-
/* options._ positionals; options['out-dir'] … */
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
await cli.parse(process.argv);
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Locales layout (any product):
|
|
30
|
-
|
|
31
|
-
```text
|
|
32
|
-
<localesRoot>/
|
|
33
|
-
locales.json # { defaultLocale, locales: [{ id }], fallback? }
|
|
34
|
-
en-US/*.json # flat or nested → dotted ids
|
|
35
|
-
zh-CN/*.json # optional
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
`.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
|
|
39
|
-
|
|
40
|
-
## API surface
|
|
41
|
-
|
|
42
|
-
| API | Role |
|
|
43
|
-
|-----|------|
|
|
44
|
-
| `createCli(name)` | Program name; help `{name}` |
|
|
45
|
-
| `.command` / `.option` / `.action` / `.passthrough` | Command tree |
|
|
46
|
-
| `.intro(id)` | Short banner only |
|
|
47
|
-
| `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
|
|
48
|
-
| `.option` on CLI | Global flags (merged into action options) |
|
|
49
|
-
| `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
|
|
50
|
-
| `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
|
|
51
|
-
| `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
|
|
52
|
-
| `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
|
|
53
|
-
| `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
|
|
54
|
-
|
|
55
|
-
`t` resolution: product catalog → `commander` English fallback → `{{id}}`.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
1
|
+
# `@vmz/commander`
|
|
2
|
+
|
|
3
|
+
i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@vmz/vmz`.
|
|
4
|
+
|
|
5
|
+
- Fluent registration; command / option second args are **message ids**
|
|
6
|
+
- **Help is derived** from the command tree — do not paste Usage walls into catalogs
|
|
7
|
+
- **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
|
|
8
|
+
- Framework chrome/err ids are `commander.*` with tiny English fallbacks (products may override)
|
|
9
|
+
- Interactive help uses a small ANSI theme for headings, commands, options, and errors; `NO_COLOR` disables it, `FORCE_COLOR` enables it outside a TTY
|
|
10
|
+
- **No product language packs** in this package
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createCli } from '@vmz/commander';
|
|
16
|
+
|
|
17
|
+
const cli = createCli('my-cli')
|
|
18
|
+
.locales(new URL('./locales', import.meta.url).pathname) // or absolute path
|
|
19
|
+
.intro('cli.intro')
|
|
20
|
+
.command('build', 'cli.cmd.build')
|
|
21
|
+
.option('--out-dir, -o <dir>', 'cli.opt.out-dir')
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
/* options._ positionals; options['out-dir'] … */
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await cli.parse(process.argv);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Locales layout (any product):
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
<localesRoot>/
|
|
33
|
+
locales.json # { defaultLocale, locales: [{ id }], fallback? }
|
|
34
|
+
en-US/*.json # flat or nested → dotted ids
|
|
35
|
+
zh-CN/*.json # optional
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
|
|
39
|
+
|
|
40
|
+
## API surface
|
|
41
|
+
|
|
42
|
+
| API | Role |
|
|
43
|
+
|-----|------|
|
|
44
|
+
| `createCli(name)` | Program name; help `{name}` |
|
|
45
|
+
| `.command` / `.option` / `.action` / `.actionCtx` / `.passthrough` | Command tree |
|
|
46
|
+
| `.intro(id)` | Short banner only |
|
|
47
|
+
| `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
|
|
48
|
+
| `.option` on CLI | Global flags (merged into action options) |
|
|
49
|
+
| `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
|
|
50
|
+
| `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
|
|
51
|
+
| `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
|
|
52
|
+
| `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
|
|
53
|
+
| `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
|
|
54
|
+
|
|
55
|
+
`t` resolution: product catalog → `commander` English fallback → `{{id}}`.
|
|
56
|
+
|
|
57
|
+
## Option config
|
|
58
|
+
|
|
59
|
+
`.option(rawName, helpId, config?)` accepts runtime constraints in addition to the `<value>` / `[value]` / `...` syntax:
|
|
60
|
+
|
|
61
|
+
| Field | Behavior |
|
|
62
|
+
|-------|----------|
|
|
63
|
+
| `required` | Missing value before the action → structured parse error, exit code 2 |
|
|
64
|
+
| `enum` | Value outside the set → structured parse error, exit code 2 (repeatable values are checked per item) |
|
|
65
|
+
| `default` | Filled into `options[key]` when omitted (boolean, string, or array) |
|
|
66
|
+
| `coerce` | Runs on the supplied raw value, thrown errors become exit code 2 |
|
|
67
|
+
| `hidden` | Omitted from help, still parseable |
|
|
68
|
+
| `example` | Rendered after the option in help |
|
|
69
|
+
| `deprecated` | Annotated in help and emits a `commander::option_deprecated` warning when used |
|
|
70
|
+
| `group` | Rendered under its own help section while ungrouped options keep the default section |
|
|
71
|
+
|
|
72
|
+
Validation happens before the action handler runs. Failures carry stable `commander::*` codes plus structured `args`.
|
|
73
|
+
|
|
74
|
+
## Command boundaries
|
|
75
|
+
|
|
76
|
+
- `.actionCtx(handler)` receives `(options, args, ctx)`, with `ctx.help`, `ctx.stdout`, `ctx.stderr`, `ctx.fail`, `ctx.warn`, and `ctx.note`. `.action` and `.actionCtx` are mutually exclusive.
|
|
77
|
+
- `--` ends option parsing. Every token after it is a raw positional and is merged into `options._` — options and help markers in the tail are never reinterpreted.
|
|
78
|
+
- `--help` binds to the deepest command path reached before the marker, so `demo document check --help` and `help document check` show the same node.
|
|
79
|
+
|
|
80
|
+
## Diagnostics
|
|
81
|
+
|
|
82
|
+
Action failures may throw a language-neutral diagnostic object with `code`, `args`, `path`, `span`, `severity`, and optional legacy `message`. The configured handler receives the structured row and resolved locale context. Without a handler, commander renders the `code` through `t` with `args` interpolation, falling back to a real business message or the code. Plain `Error` exceptions are wrapped as `commander::action_failed` during migration. Commander does not own diagnostic formatting: products should connect `@vmz/diagnostic`.
|
|
83
|
+
|
|
84
|
+
Framework hints use `commander.ui.hint.available_options` (`{command}`) and `commander.ui.hint.did_you_mean` (`{suggest}`), so product catalogs can translate them. The available-options hint names the full executable command (`demo run --help`) and falls back to the root command for unknown commands.
|
|
85
|
+
|
|
86
|
+
## Layering
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
@vmz/commander = CLI tree + locales loader + commander.* chrome
|
|
90
|
+
@vmz/vmz = product bin + command registration + locales/ content
|
|
91
|
+
@vmz/diagnostic = diagnostic layout (caller injects catalog / t)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Do **not** create a separate `@vmz/i18n` package for official product strings.
|
package/dist/index.d.ts
CHANGED
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
* pluggable Localize plugin or `.locales(root)`. Framework chrome/err ids are
|
|
6
6
|
* `commander.*` with tiny English fallbacks — no product language packs here.
|
|
7
7
|
*/
|
|
8
|
-
export type { CatalogLoader, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
|
|
8
|
+
export type { CatalogLoader, CommandContext, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
|
|
9
9
|
export { CliError } from './types.js';
|
|
10
10
|
export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
|
|
11
11
|
export type { CreateLocalizeFromLocalesOptions, LocalesManifest } from './locales.js';
|
|
12
|
-
import type { CatalogLoader, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
|
|
12
|
+
import type { CatalogLoader, CommandContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
|
|
13
13
|
/** Options bag passed to actions (`_` = positionals). */
|
|
14
14
|
export type ParsedOptions = Record<string, string | boolean | string[]> & {
|
|
15
15
|
_: string[];
|
|
16
16
|
};
|
|
17
|
-
/** Command action after argv is parsed. */
|
|
17
|
+
/** Command action after argv is parsed (legacy API — no context). */
|
|
18
18
|
export type ActionHandler = (options: ParsedOptions, ...args: string[]) => void | number | Promise<void | number>;
|
|
19
|
+
/** Command action with full framework context (preferred API — additive, no break). */
|
|
20
|
+
export type ActionCtxHandler = (options: ParsedOptions, args: string[], ctx: CommandContext) => void | number | Promise<void | number>;
|
|
19
21
|
/** Registered option (structure only). */
|
|
20
22
|
export type OptionDef = {
|
|
21
23
|
rawName: string;
|
|
@@ -30,6 +32,8 @@ export type OptionDef = {
|
|
|
30
32
|
optionalValue: boolean;
|
|
31
33
|
/** True when the option may repeat (`--dirty <path>...` ⇒ `string[]`). */
|
|
32
34
|
repeatable: boolean;
|
|
35
|
+
/** Runtime constraints (enum / required / default / coerce / deprecated). */
|
|
36
|
+
config?: OptionConfig;
|
|
33
37
|
};
|
|
34
38
|
/** Registered command node. */
|
|
35
39
|
export type CommandDef = {
|
|
@@ -38,14 +42,20 @@ export type CommandDef = {
|
|
|
38
42
|
/** Match tokens from `rawName` split on `|`. */
|
|
39
43
|
names: string[];
|
|
40
44
|
options: OptionDef[];
|
|
45
|
+
/** Legacy action handler (no ctx). */
|
|
41
46
|
action?: ActionHandler;
|
|
47
|
+
/** Phase 3 action handler (with ctx — preferred). */
|
|
48
|
+
actionCtx?: ActionCtxHandler;
|
|
42
49
|
children: CommandDef[];
|
|
43
50
|
/** If true, do not parse options; remaining argv is positionals / raw rest. */
|
|
44
51
|
passthrough: boolean;
|
|
45
52
|
};
|
|
46
53
|
export interface Command {
|
|
47
|
-
option(rawName: string, helpId: string): this;
|
|
54
|
+
option(rawName: string, helpId: string, config?: OptionConfig): this;
|
|
55
|
+
/** Legacy API — handler receives `(options, ...args)` without ctx. */
|
|
48
56
|
action(handler: ActionHandler): this;
|
|
57
|
+
/** Phase 3 API — handler receives `(options, args[], ctx)` with full command context. */
|
|
58
|
+
actionCtx(handler: ActionCtxHandler): this;
|
|
49
59
|
command(rawName: string, helpId: string): Command;
|
|
50
60
|
/** Leave remaining argv unparsed (for nested domain CLIs). */
|
|
51
61
|
passthrough(): this;
|
package/dist/index.js
CHANGED
|
@@ -25,14 +25,127 @@ function colorEnabled() {
|
|
|
25
25
|
return false;
|
|
26
26
|
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0')
|
|
27
27
|
return true;
|
|
28
|
+
// Node.js reports `isTTY` as `undefined` in many Windows shells (PowerShell,
|
|
29
|
+
// Git Bash) even when the host (Windows Terminal, ConEmu, mintty) does emit
|
|
30
|
+
// ANSI. `tty.isatty(fd)` is reliable on every platform because it asks the
|
|
31
|
+
// OS directly. On top of that we also check well-known Windows-terminal
|
|
32
|
+
// env vars (`WT_SESSION`, `ConEmuANSI`, `ANSICON`) that legacy clients use
|
|
33
|
+
// to declare ANSI support. A `TERM=dumb` redirect still falls through.
|
|
34
|
+
const tty = tryImportTty();
|
|
35
|
+
if (tty && tty.isatty(process.stdout?.fd ?? 0))
|
|
36
|
+
return true;
|
|
37
|
+
if (tty && tty.isatty(process.stderr?.fd ?? 2))
|
|
38
|
+
return true;
|
|
39
|
+
if (process.env.WT_SESSION)
|
|
40
|
+
return true;
|
|
41
|
+
if (process.env.ConEmuANSI === 'ON')
|
|
42
|
+
return true;
|
|
43
|
+
if (process.env.ANSICON)
|
|
44
|
+
return true;
|
|
45
|
+
if (process.env.TERM && process.env.TERM !== 'dumb')
|
|
46
|
+
return true;
|
|
28
47
|
return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
|
|
29
48
|
}
|
|
49
|
+
/** Dynamically resolve Node's `node:tty` module — safe in non-Node runners. */
|
|
50
|
+
function tryImportTty() {
|
|
51
|
+
try {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
+
return require('node:tty');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
30
59
|
function paint(value, ...colors) {
|
|
31
60
|
return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
|
|
32
61
|
}
|
|
33
62
|
function errorText(value) {
|
|
34
63
|
return paint(value, 'bold', 'red');
|
|
35
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Render one help section (header + padded table).
|
|
67
|
+
*
|
|
68
|
+
* Both the root help and the command-node help share the same 3-line pattern:
|
|
69
|
+
* push section header (`paint(header, 'bold', 'blue')`), then a loop of
|
|
70
|
+
* ` ${paint(label.padEnd(colWidth), color)} ${t(helpId)}`.
|
|
71
|
+
*
|
|
72
|
+
* Returns `lines[]` so callers can spread-append without trailing-empty concerns.
|
|
73
|
+
*/
|
|
74
|
+
function renderHelpSectionWithHeader(t, header, rows, color, colWidth = 28) {
|
|
75
|
+
if (!rows.length)
|
|
76
|
+
return [];
|
|
77
|
+
const out = [paint(header, 'bold', 'blue')];
|
|
78
|
+
for (const r of rows) {
|
|
79
|
+
const label = r.suffix ? `${r.rawName} ${r.suffix}` : r.rawName;
|
|
80
|
+
out.push(` ${paint(label.padEnd(colWidth), color)} ${t(r.helpId)}`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function renderHelpSection(t, headerId, rows, color, colWidth = 28) {
|
|
85
|
+
return renderHelpSectionWithHeader(t, t(headerId), rows, color, colWidth);
|
|
86
|
+
}
|
|
87
|
+
/** Parse/validation diagnostics that get the framework-level "available options" hint. */
|
|
88
|
+
const HINT_CODES = new Set([
|
|
89
|
+
'commander::unknown_command',
|
|
90
|
+
'commander::unknown_option',
|
|
91
|
+
'commander::missing_option_value',
|
|
92
|
+
'commander::parse_failed',
|
|
93
|
+
'commander::missing_required_option',
|
|
94
|
+
'commander::option_not_in_enum',
|
|
95
|
+
'commander::option_coerce_failed',
|
|
96
|
+
]);
|
|
97
|
+
/** Resolve a catalog id, treating `{{id}}` (unresolved) as absent. */
|
|
98
|
+
function localizedOrId(t, id, args) {
|
|
99
|
+
const value = t(id, args);
|
|
100
|
+
return value === `{{${id}}}` ? '' : value;
|
|
101
|
+
}
|
|
102
|
+
/** Localized suffix appended to one option row in help. */
|
|
103
|
+
function optionSuffix(t, def) {
|
|
104
|
+
const config = def.config;
|
|
105
|
+
if (!config)
|
|
106
|
+
return '';
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (config.example)
|
|
109
|
+
parts.push(t('commander.ui.option_example', { example: config.example }));
|
|
110
|
+
if (config.deprecated) {
|
|
111
|
+
const label = typeof config.deprecated === 'string' && config.deprecated
|
|
112
|
+
? (localizedOrId(t, config.deprecated) || config.deprecated)
|
|
113
|
+
: t('commander.ui.deprecated');
|
|
114
|
+
if (label)
|
|
115
|
+
parts.push(label);
|
|
116
|
+
}
|
|
117
|
+
return parts.join(' ');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Render option help, splitting grouped options into their own sections.
|
|
121
|
+
*
|
|
122
|
+
* Options without `group` keep the default `Options:` section. `hidden` options
|
|
123
|
+
* are omitted from help but remain parseable.
|
|
124
|
+
*/
|
|
125
|
+
function renderOptionSections(t, options, color) {
|
|
126
|
+
const visible = options.filter((o) => !o.config?.hidden);
|
|
127
|
+
const ungrouped = [];
|
|
128
|
+
const grouped = new Map();
|
|
129
|
+
for (const option of visible) {
|
|
130
|
+
const group = option.config?.group;
|
|
131
|
+
if (!group) {
|
|
132
|
+
ungrouped.push(option);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const list = grouped.get(group) ?? [];
|
|
136
|
+
list.push(option);
|
|
137
|
+
grouped.set(group, list);
|
|
138
|
+
}
|
|
139
|
+
const rows = (list) => list.map((o) => ({ rawName: o.rawName, helpId: o.helpId, suffix: optionSuffix(t, o) }));
|
|
140
|
+
const out = renderHelpSection(t, 'commander.ui.options', rows(ungrouped), color);
|
|
141
|
+
for (const [group, list] of grouped) {
|
|
142
|
+
const header = localizedOrId(t, group) || group;
|
|
143
|
+
if (out.length)
|
|
144
|
+
out.push('');
|
|
145
|
+
out.push(...renderHelpSectionWithHeader(t, header, rows(list), color));
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
36
149
|
class CommandBuilder {
|
|
37
150
|
def;
|
|
38
151
|
constructor(rawName, helpId) {
|
|
@@ -46,15 +159,26 @@ class CommandBuilder {
|
|
|
46
159
|
passthrough: false,
|
|
47
160
|
};
|
|
48
161
|
}
|
|
49
|
-
option(rawName, helpId) {
|
|
162
|
+
option(rawName, helpId, config) {
|
|
50
163
|
assertHelpId(helpId, 'option');
|
|
51
|
-
|
|
164
|
+
const def = parseOptionDef(rawName, helpId);
|
|
165
|
+
if (config)
|
|
166
|
+
def.config = config;
|
|
167
|
+
this.def.options.push(def);
|
|
52
168
|
return this;
|
|
53
169
|
}
|
|
54
170
|
action(handler) {
|
|
171
|
+
if (this.def.actionCtx)
|
|
172
|
+
throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
|
|
55
173
|
this.def.action = handler;
|
|
56
174
|
return this;
|
|
57
175
|
}
|
|
176
|
+
actionCtx(handler) {
|
|
177
|
+
if (this.def.action)
|
|
178
|
+
throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
|
|
179
|
+
this.def.actionCtx = handler;
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
58
182
|
passthrough() {
|
|
59
183
|
this.def.passthrough = true;
|
|
60
184
|
return this;
|
|
@@ -158,7 +282,11 @@ class CliBuilder {
|
|
|
158
282
|
}
|
|
159
283
|
async parse(argvInput = process.argv) {
|
|
160
284
|
const argv0 = normalizeArgv(argvInput);
|
|
161
|
-
const
|
|
285
|
+
const peeled = peelKnownOptions(argv0, this.rootOptions);
|
|
286
|
+
const globalOpts = peeled.options;
|
|
287
|
+
// A leading separator at program level only marks the end of global
|
|
288
|
+
// options. Keep the following command token dispatchable.
|
|
289
|
+
const argv = peeled.rest[0] === '--' ? peeled.rest.slice(1) : peeled.rest;
|
|
162
290
|
let localize = this.localize;
|
|
163
291
|
if (this.localesRoot) {
|
|
164
292
|
const localeFlag = typeof globalOpts.locale === 'string' && globalOpts.locale ? globalOpts.locale : undefined;
|
|
@@ -178,32 +306,84 @@ class CliBuilder {
|
|
|
178
306
|
locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
|
|
179
307
|
t,
|
|
180
308
|
};
|
|
181
|
-
|
|
309
|
+
// `help <cmd> ...` 路由:把 help 后面的 token 当命令路径查找帮助
|
|
310
|
+
if (argv.length && argv[0] === 'help') {
|
|
311
|
+
const pathTokens = argv.slice(1);
|
|
312
|
+
return this.routeHelp(pathTokens, t, diagnosticContext);
|
|
313
|
+
}
|
|
314
|
+
if (argv.length === 0 || argv.every(isHelpToken)) {
|
|
182
315
|
console.log(this.formatRootHelp(t));
|
|
183
316
|
return 0;
|
|
184
317
|
}
|
|
185
318
|
const cmdToken = argv[0];
|
|
186
319
|
const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
|
|
187
320
|
if (!matched) {
|
|
188
|
-
|
|
321
|
+
// 未知命令:加上 suggestion
|
|
322
|
+
const names = this.roots.flatMap((r) => r.def.names);
|
|
323
|
+
const suggest = closestMatch(cmdToken, names);
|
|
324
|
+
this.emitDiagnostic({
|
|
325
|
+
code: 'commander::unknown_command',
|
|
326
|
+
severity: 'error',
|
|
327
|
+
args: suggest ? { cmd: cmdToken, suggest } : { cmd: cmdToken },
|
|
328
|
+
}, diagnosticContext, t);
|
|
329
|
+
return 2;
|
|
189
330
|
}
|
|
190
331
|
return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
|
|
191
332
|
}
|
|
192
|
-
/**
|
|
333
|
+
/** 路由 `help <cmd> [subcmd] ...` 到对应命令的帮助文本。 */
|
|
334
|
+
routeHelp(pathTokens, t, _ctx) {
|
|
335
|
+
if (!pathTokens.length) {
|
|
336
|
+
console.log(this.formatRootHelp(t));
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
let pool = this.roots.map((r) => r.def);
|
|
340
|
+
let matched;
|
|
341
|
+
let consumed = 0;
|
|
342
|
+
for (const tok of pathTokens) {
|
|
343
|
+
const found = pool?.find((c) => c.names.includes(tok));
|
|
344
|
+
if (!found)
|
|
345
|
+
break;
|
|
346
|
+
matched = found;
|
|
347
|
+
consumed += 1;
|
|
348
|
+
pool = found.children;
|
|
349
|
+
}
|
|
350
|
+
if (!matched) {
|
|
351
|
+
// 路径里没有一个有效命令 → 仍显示根帮助
|
|
352
|
+
console.log(this.formatRootHelp(t));
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
const path = pathTokens.slice(0, consumed);
|
|
356
|
+
console.log(formatCommandHelp(this.name, matched, t, path, this.rootOptions));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
/** Emit a diagnostic through `onDiagnostic` when registered, else console.error with a short hint. */
|
|
193
360
|
emitDiagnostic(diag, ctx, t) {
|
|
194
361
|
if (this.diagnosticHandler) {
|
|
195
362
|
this.diagnosticHandler(diag, ctx);
|
|
196
363
|
}
|
|
197
364
|
else {
|
|
198
|
-
const
|
|
199
|
-
|
|
365
|
+
const code = diag.code ?? '';
|
|
366
|
+
// Catalog template wins so structured args stay interpolated (`--oops`).
|
|
367
|
+
// A business message is only a fallback for codes without a template.
|
|
368
|
+
const fromCatalog = code ? localizedOrId(t, code, diag.args) : '';
|
|
369
|
+
const businessMessage = diag.message && diag.message !== code ? diag.message : '';
|
|
370
|
+
const msg = fromCatalog || businessMessage || code || String(diag);
|
|
371
|
+
console.error(errorText(msg));
|
|
372
|
+
}
|
|
373
|
+
// hint 和 suggestion 是框架级 UI,无论 handler 是否存在都统一追加
|
|
374
|
+
if (diag.code && HINT_CODES.has(diag.code)) {
|
|
375
|
+
// unknown_command: hint 指向根命令(因为命令不存在)
|
|
376
|
+
// 其他: hint 指向完整的可执行命令路径
|
|
377
|
+
const cmd = typeof diag.args?.cmd === 'string' ? diag.args.cmd.trim() : '';
|
|
378
|
+
const hintTarget = diag.code === 'commander::unknown_command' || !cmd
|
|
379
|
+
? this.name
|
|
380
|
+
: `${this.name} ${cmd}`;
|
|
381
|
+
console.error(paint(t('commander.ui.hint.available_options', { command: hintTarget }), 'dim'));
|
|
382
|
+
}
|
|
383
|
+
const suggest = typeof diag.args?.suggest === 'string' ? diag.args.suggest : '';
|
|
384
|
+
if (suggest) {
|
|
385
|
+
console.error(paint(t('commander.ui.hint.did_you_mean', { suggest }), 'dim'));
|
|
200
386
|
}
|
|
201
|
-
}
|
|
202
|
-
/** Emit diagnostic, show help text, return exit code. Shorthand for parse/dispatch error paths. */
|
|
203
|
-
emitAndHelp(diag, ctx, t, helpText, exitCode) {
|
|
204
|
-
this.emitDiagnostic(diag, ctx, t);
|
|
205
|
-
console.log(helpText);
|
|
206
|
-
return exitCode;
|
|
207
387
|
}
|
|
208
388
|
/** Derived root help: optional intro + commands/options from the registration tree. */
|
|
209
389
|
formatRootHelp(t) {
|
|
@@ -211,51 +391,81 @@ class CliBuilder {
|
|
|
211
391
|
if (this.introId) {
|
|
212
392
|
lines.push(t(this.introId), '');
|
|
213
393
|
}
|
|
214
|
-
lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan')
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
394
|
+
lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan'));
|
|
395
|
+
lines.push('');
|
|
396
|
+
lines.push(...renderHelpSection(t, 'commander.ui.commands', this.roots.map((c) => ({ rawName: c.def.rawName, helpId: c.def.helpId })), 'green'));
|
|
218
397
|
const opts = [
|
|
219
398
|
...this.rootOptions,
|
|
220
399
|
...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
|
|
221
400
|
];
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
401
|
+
const optionSection = renderOptionSections(t, opts, 'yellow');
|
|
402
|
+
if (optionSection.length) {
|
|
403
|
+
lines.push('');
|
|
404
|
+
lines.push(...optionSection);
|
|
227
405
|
}
|
|
228
406
|
return lines.join('\n');
|
|
229
407
|
}
|
|
230
408
|
async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
409
|
+
// `--` ends option parsing. Keep head (options + command tokens) separate
|
|
410
|
+
// from tail (raw positionals) before help scanning or option parsing.
|
|
411
|
+
const separatorAt = rest.indexOf('--');
|
|
412
|
+
const head = separatorAt === -1 ? rest : rest.slice(0, separatorAt);
|
|
413
|
+
const tail = separatorAt === -1 ? [] : rest.slice(separatorAt + 1);
|
|
414
|
+
// Resolve the deepest command node before interpreting help markers, so
|
|
415
|
+
// `demo document check --help` binds to `check` instead of `document`.
|
|
416
|
+
let target = def;
|
|
417
|
+
const targetPath = path.slice();
|
|
418
|
+
const remaining = head.slice();
|
|
419
|
+
while (target.children.length && remaining.length) {
|
|
420
|
+
const token = remaining[0];
|
|
421
|
+
if (token.startsWith('-') || isHelpToken(token))
|
|
422
|
+
break;
|
|
423
|
+
const child = target.children.find((c) => c.names.includes(token));
|
|
424
|
+
if (!child)
|
|
425
|
+
break;
|
|
426
|
+
target = child;
|
|
427
|
+
targetPath.push(token);
|
|
428
|
+
remaining.shift();
|
|
234
429
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts, diagnosticContext);
|
|
240
|
-
}
|
|
241
|
-
if (!def.action) {
|
|
242
|
-
return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: [...path, childTok].join(' ') } }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), 2);
|
|
243
|
-
}
|
|
430
|
+
// 扫描 --help / -h(只扫命令路径之后的 head,`--` 之后的 tail 不参与)
|
|
431
|
+
if (hasHelpToken(remaining)) {
|
|
432
|
+
console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
|
|
433
|
+
return 0;
|
|
244
434
|
}
|
|
245
|
-
if (!
|
|
246
|
-
if (
|
|
247
|
-
|
|
435
|
+
if (!target.action && !target.actionCtx) {
|
|
436
|
+
if (remaining.length || tail.length) {
|
|
437
|
+
const childNames = target.children.flatMap((c) => c.names);
|
|
438
|
+
const unknownToken = remaining[0];
|
|
439
|
+
const suggest = unknownToken && !unknownToken.startsWith('-')
|
|
440
|
+
? closestMatch(unknownToken, childNames)
|
|
441
|
+
: '';
|
|
442
|
+
const cmd = [...targetPath, ...remaining, ...tail].join(' ');
|
|
443
|
+
this.emitDiagnostic({
|
|
444
|
+
code: 'commander::unknown_command',
|
|
445
|
+
severity: 'error',
|
|
446
|
+
args: suggest ? { cmd, suggest } : { cmd },
|
|
447
|
+
}, diagnosticContext, t);
|
|
448
|
+
return 2;
|
|
248
449
|
}
|
|
249
|
-
console.log(formatCommandHelp(this.name,
|
|
450
|
+
console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
|
|
250
451
|
return 0;
|
|
251
452
|
}
|
|
252
453
|
let options;
|
|
454
|
+
let usedOptions = new Set();
|
|
253
455
|
try {
|
|
254
|
-
|
|
456
|
+
if (target.passthrough) {
|
|
457
|
+
options = { _: [...remaining, ...tail] };
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
({ options, used: usedOptions } = parseOptionsDetailed(remaining, target.options));
|
|
461
|
+
options._.push(...tail);
|
|
462
|
+
}
|
|
255
463
|
}
|
|
256
464
|
catch (err) {
|
|
257
465
|
const cliErr = CliError.from(err, 'commander::parse_failed');
|
|
258
|
-
|
|
466
|
+
const args = { ...(cliErr.args ?? {}), cmd: targetPath.join(' ') };
|
|
467
|
+
this.emitDiagnostic({ code: cliErr.code, severity: 'error', args, message: cliErr.message }, diagnosticContext, t);
|
|
468
|
+
return cliErr.exitCode;
|
|
259
469
|
}
|
|
260
470
|
for (const [k, v] of Object.entries(globalOpts)) {
|
|
261
471
|
if (k === '_')
|
|
@@ -263,8 +473,29 @@ class CliBuilder {
|
|
|
263
473
|
if (options[k] === undefined)
|
|
264
474
|
options[k] = v;
|
|
265
475
|
}
|
|
476
|
+
for (const option of target.options) {
|
|
477
|
+
const deprecated = option.config?.deprecated;
|
|
478
|
+
if (!deprecated || !usedOptions.has(option))
|
|
479
|
+
continue;
|
|
480
|
+
const args = { option: option.key };
|
|
481
|
+
if (typeof deprecated === 'string' && deprecated) {
|
|
482
|
+
args.detail = localizedOrId(t, deprecated) || deprecated;
|
|
483
|
+
}
|
|
484
|
+
this.emitDiagnostic({ code: 'commander::option_deprecated', severity: 'warning', args }, diagnosticContext, t);
|
|
485
|
+
}
|
|
266
486
|
try {
|
|
267
|
-
|
|
487
|
+
let result;
|
|
488
|
+
if (target.actionCtx) {
|
|
489
|
+
const ctx = this.buildCommandContext(target, targetPath, diagnosticContext.locale, t);
|
|
490
|
+
result = await target.actionCtx(options, options._, ctx);
|
|
491
|
+
}
|
|
492
|
+
else if (target.action) {
|
|
493
|
+
result = await target.action(options, ...options._);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
268
499
|
if (typeof result === 'number')
|
|
269
500
|
return result;
|
|
270
501
|
return 0;
|
|
@@ -272,26 +503,51 @@ class CliBuilder {
|
|
|
272
503
|
catch (err) {
|
|
273
504
|
const diagnostic = normalizeDiagnostic(err);
|
|
274
505
|
this.emitDiagnostic(diagnostic, diagnosticContext, t);
|
|
506
|
+
// CliError.exitCode is POSIX-correct (2 for parse/validation, 1 for config/action)
|
|
507
|
+
if (err instanceof CliError)
|
|
508
|
+
return err.exitCode;
|
|
275
509
|
return 1;
|
|
276
510
|
}
|
|
277
511
|
}
|
|
512
|
+
/** Build a CommandContext for .actionCtx() handlers. */
|
|
513
|
+
buildCommandContext(def, path, locale, t) {
|
|
514
|
+
const self = this;
|
|
515
|
+
const name = path.join(' ');
|
|
516
|
+
return {
|
|
517
|
+
commandPath: [this.name, ...path],
|
|
518
|
+
commandName: name,
|
|
519
|
+
locale,
|
|
520
|
+
t,
|
|
521
|
+
help: () => console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions)),
|
|
522
|
+
stderr: (...msgs) => { for (const m of msgs)
|
|
523
|
+
console.error(m); },
|
|
524
|
+
stdout: (...msgs) => { for (const m of msgs)
|
|
525
|
+
console.log(m); },
|
|
526
|
+
fail: (code, args) => {
|
|
527
|
+
self.emitDiagnostic({ code, severity: 'error', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
|
|
528
|
+
throw CliError.configError(code, args ?? {});
|
|
529
|
+
},
|
|
530
|
+
warn: (code, args) => {
|
|
531
|
+
self.emitDiagnostic({ code, severity: 'warning', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
|
|
532
|
+
},
|
|
533
|
+
note: (code, args) => {
|
|
534
|
+
self.emitDiagnostic({ code, severity: 'advice', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
278
538
|
}
|
|
279
539
|
/** Derive command help from one node (children + options). */
|
|
280
540
|
export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
|
|
281
|
-
const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}
|
|
541
|
+
const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`];
|
|
282
542
|
if (def.children.length) {
|
|
283
|
-
lines.push(paint(t('commander.ui.commands'), 'bold', 'blue'));
|
|
284
|
-
for (const c of def.children) {
|
|
285
|
-
lines.push(` ${paint(c.rawName.padEnd(28), 'green')} ${t(c.helpId)}`);
|
|
286
|
-
}
|
|
287
543
|
lines.push('');
|
|
544
|
+
lines.push(...renderHelpSection(t, 'commander.ui.commands', def.children.map((c) => ({ rawName: c.rawName, helpId: c.helpId })), 'green'));
|
|
288
545
|
}
|
|
289
546
|
const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
547
|
+
const optionSection = renderOptionSections(t, opts, 'yellow');
|
|
548
|
+
if (optionSection.length) {
|
|
549
|
+
lines.push('');
|
|
550
|
+
lines.push(...optionSection);
|
|
295
551
|
}
|
|
296
552
|
return lines.join('\n').trimEnd();
|
|
297
553
|
}
|
|
@@ -359,6 +615,8 @@ function parseArgvCore(argv, optionDefs, strict) {
|
|
|
359
615
|
}
|
|
360
616
|
const options = { _: [] };
|
|
361
617
|
const rest = [];
|
|
618
|
+
/** Options explicitly supplied on the command line (defaults are not "used"). */
|
|
619
|
+
const used = new Set();
|
|
362
620
|
const handleMissing = (flagName) => {
|
|
363
621
|
if (strict) {
|
|
364
622
|
throw CliError.parseError('commander::missing_option_value', { option: flagName });
|
|
@@ -376,30 +634,37 @@ function parseArgvCore(argv, optionDefs, strict) {
|
|
|
376
634
|
for (let i = 0; i < argv.length; i++) {
|
|
377
635
|
const a = argv[i];
|
|
378
636
|
if (a === '--') {
|
|
379
|
-
|
|
637
|
+
// Strict parsing consumes the separator. Permissive peeling must keep
|
|
638
|
+
// both `--` and its tail so dispatch can split head/tail and never
|
|
639
|
+
// interpret tail tokens as options or help markers.
|
|
640
|
+
if (strict)
|
|
641
|
+
rest.push(...argv.slice(i + 1));
|
|
642
|
+
else
|
|
643
|
+
rest.push('--', ...argv.slice(i + 1));
|
|
380
644
|
break;
|
|
381
645
|
}
|
|
646
|
+
// help token 不在这里 skip:让它进 rest,由上层 dispatch 的 hasHelpToken 路由
|
|
382
647
|
if (a.startsWith('--')) {
|
|
383
648
|
const eq = a.indexOf('=');
|
|
384
649
|
const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
385
650
|
const def = byAlias.get(long);
|
|
386
651
|
if (!def) {
|
|
387
|
-
if (!handleUnknown(
|
|
652
|
+
if (!handleUnknown(a))
|
|
388
653
|
continue;
|
|
389
654
|
continue;
|
|
390
655
|
}
|
|
391
656
|
if (def.takesValue) {
|
|
392
657
|
if (eq !== -1) {
|
|
393
|
-
assignOption(options, def, a.slice(eq + 1));
|
|
658
|
+
assignOption(options, def, used, a.slice(eq + 1));
|
|
394
659
|
}
|
|
395
660
|
else {
|
|
396
661
|
const next = argv[i + 1];
|
|
397
662
|
if (next != null && !next.startsWith('-')) {
|
|
398
|
-
assignOption(options, def, next);
|
|
663
|
+
assignOption(options, def, used, next);
|
|
399
664
|
i += 1;
|
|
400
665
|
}
|
|
401
666
|
else if (def.optionalValue) {
|
|
402
|
-
assignOption(options, def, true);
|
|
667
|
+
assignOption(options, def, used, true);
|
|
403
668
|
}
|
|
404
669
|
else {
|
|
405
670
|
if (!handleMissing(`--${long}`))
|
|
@@ -409,26 +674,27 @@ function parseArgvCore(argv, optionDefs, strict) {
|
|
|
409
674
|
}
|
|
410
675
|
}
|
|
411
676
|
else {
|
|
412
|
-
assignOption(options, def, true);
|
|
677
|
+
assignOption(options, def, used, true);
|
|
413
678
|
}
|
|
414
679
|
continue;
|
|
415
680
|
}
|
|
416
|
-
|
|
681
|
+
// 短参数:-x 或 -xyz(多字符),只要不以 -- 开头且以 - 开头且长度 > 1 就认作短选项
|
|
682
|
+
if (a.startsWith('-') && a.length > 1) {
|
|
417
683
|
const short = a.slice(1);
|
|
418
684
|
const def = byAlias.get(short);
|
|
419
685
|
if (!def) {
|
|
420
|
-
if (!handleUnknown(
|
|
686
|
+
if (!handleUnknown(a))
|
|
421
687
|
continue;
|
|
422
688
|
continue;
|
|
423
689
|
}
|
|
424
690
|
if (def.takesValue) {
|
|
425
691
|
const next = argv[i + 1];
|
|
426
692
|
if (next != null && !next.startsWith('-')) {
|
|
427
|
-
assignOption(options, def, next);
|
|
693
|
+
assignOption(options, def, used, next);
|
|
428
694
|
i += 1;
|
|
429
695
|
}
|
|
430
696
|
else if (def.optionalValue) {
|
|
431
|
-
assignOption(options, def, true);
|
|
697
|
+
assignOption(options, def, used, true);
|
|
432
698
|
}
|
|
433
699
|
else {
|
|
434
700
|
if (!handleMissing(`-${short}`))
|
|
@@ -437,20 +703,21 @@ function parseArgvCore(argv, optionDefs, strict) {
|
|
|
437
703
|
}
|
|
438
704
|
}
|
|
439
705
|
else {
|
|
440
|
-
assignOption(options, def, true);
|
|
706
|
+
assignOption(options, def, used, true);
|
|
441
707
|
}
|
|
442
708
|
continue;
|
|
443
709
|
}
|
|
444
710
|
rest.push(a);
|
|
445
711
|
}
|
|
446
|
-
return { options, rest };
|
|
712
|
+
return { options, rest, used };
|
|
447
713
|
}
|
|
448
714
|
/**
|
|
449
715
|
* Consume known root options; leave unknown flags and positionals in `rest`
|
|
450
716
|
* (unlike {@link parseOptions}, which throws on unknown options).
|
|
451
717
|
*/
|
|
452
718
|
export function peelKnownOptions(argv, optionDefs) {
|
|
453
|
-
|
|
719
|
+
const { options, rest } = parseArgvCore(argv, optionDefs, false);
|
|
720
|
+
return { options, rest };
|
|
454
721
|
}
|
|
455
722
|
/** Strip `node` + script when callers pass full `process.argv`. */
|
|
456
723
|
export function normalizeArgv(argv) {
|
|
@@ -524,7 +791,9 @@ function normalizeDiagnostic(error) {
|
|
|
524
791
|
};
|
|
525
792
|
}
|
|
526
793
|
}
|
|
527
|
-
|
|
794
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
795
|
+
// 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
|
|
796
|
+
return { severity: 'error', code: 'commander::action_failed', args: { message: text, detail: text }, message: text };
|
|
528
797
|
}
|
|
529
798
|
/**
|
|
530
799
|
* Parse argv against registered options (strict mode).
|
|
@@ -534,11 +803,66 @@ function normalizeDiagnostic(error) {
|
|
|
534
803
|
* Non-option tokens become positionals under `options._`.
|
|
535
804
|
*/
|
|
536
805
|
export function parseOptions(argv, optionDefs) {
|
|
537
|
-
|
|
806
|
+
return parseOptionsDetailed(argv, optionDefs).options;
|
|
807
|
+
}
|
|
808
|
+
/** Strict parse plus which option defs were actually supplied (for deprecation warnings). */
|
|
809
|
+
function parseOptionsDetailed(argv, optionDefs) {
|
|
810
|
+
const { options, rest, used } = parseArgvCore(argv, optionDefs, true);
|
|
538
811
|
options._.push(...rest);
|
|
539
|
-
|
|
812
|
+
applyOptionConfig(options, optionDefs, used);
|
|
813
|
+
return { options, used };
|
|
540
814
|
}
|
|
541
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Apply `.option(..., config)` runtime constraints (default / required / enum / coerce).
|
|
817
|
+
*
|
|
818
|
+
* Runs before any action handler executes and throws {@link CliError.parseError}
|
|
819
|
+
* (exit code 2) so callers receive structured `code` + `args` diagnostics.
|
|
820
|
+
*/
|
|
821
|
+
function applyOptionConfig(options, optionDefs, used) {
|
|
822
|
+
for (const def of optionDefs) {
|
|
823
|
+
const config = def.config;
|
|
824
|
+
if (!config)
|
|
825
|
+
continue;
|
|
826
|
+
const provided = used.has(def);
|
|
827
|
+
if (!provided && config.default !== undefined) {
|
|
828
|
+
options[def.key] = Array.isArray(config.default) ? config.default.slice() : config.default;
|
|
829
|
+
}
|
|
830
|
+
const value = options[def.key];
|
|
831
|
+
if (config.required && value === undefined) {
|
|
832
|
+
throw CliError.parseError('commander::missing_required_option', { option: def.key });
|
|
833
|
+
}
|
|
834
|
+
if (value === undefined)
|
|
835
|
+
continue;
|
|
836
|
+
if (config.enum && provided) {
|
|
837
|
+
const values = Array.isArray(value) ? value : [value];
|
|
838
|
+
for (const item of values) {
|
|
839
|
+
if (typeof item !== 'string')
|
|
840
|
+
continue;
|
|
841
|
+
if (!config.enum.includes(item)) {
|
|
842
|
+
throw CliError.parseError('commander::option_not_in_enum', {
|
|
843
|
+
option: def.key,
|
|
844
|
+
value: item,
|
|
845
|
+
allowed: config.enum.join(', '),
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (config.coerce && provided) {
|
|
851
|
+
try {
|
|
852
|
+
options[def.key] = config.coerce(value);
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
throw CliError.parseError('commander::option_coerce_failed', {
|
|
856
|
+
option: def.key,
|
|
857
|
+
value: Array.isArray(value) ? value.join(', ') : String(value),
|
|
858
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function assignOption(out, def, used, value) {
|
|
865
|
+
used.add(def);
|
|
542
866
|
if (def.repeatable) {
|
|
543
867
|
const cur = out[def.key];
|
|
544
868
|
const list = Array.isArray(cur) ? cur : [];
|
|
@@ -548,3 +872,58 @@ function assignOption(out, def, value) {
|
|
|
548
872
|
}
|
|
549
873
|
out[def.key] = value;
|
|
550
874
|
}
|
|
875
|
+
/** 扫描 argv 是否包含 help token(跳过 -- 后面的内容)。 */
|
|
876
|
+
function hasHelpToken(argv) {
|
|
877
|
+
for (const tok of argv) {
|
|
878
|
+
if (tok === '--')
|
|
879
|
+
return false;
|
|
880
|
+
if (isHelpToken(tok))
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* 简单 Levenshtein 距离:找最接近的候选字符串。
|
|
887
|
+
* 返回值是建议的命令名(可能有多个用逗号连接),找不到返回空字符串。
|
|
888
|
+
*/
|
|
889
|
+
function closestMatch(input, candidates) {
|
|
890
|
+
if (!input || !candidates.length)
|
|
891
|
+
return '';
|
|
892
|
+
const lower = input.toLowerCase();
|
|
893
|
+
let best = [];
|
|
894
|
+
for (const c of candidates) {
|
|
895
|
+
const d = levenshtein(lower, c.toLowerCase());
|
|
896
|
+
// 只接受距离 ≤ 3 且不超过输入长度 60% 的候选,避免把完全不相关的命令也建议出来
|
|
897
|
+
const threshold = Math.max(2, Math.floor(input.length * 0.6) + 1);
|
|
898
|
+
if (d <= threshold) {
|
|
899
|
+
best.push({ name: c, dist: d });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (!best.length)
|
|
903
|
+
return '';
|
|
904
|
+
best.sort((a, b) => a.dist - b.dist);
|
|
905
|
+
// 只返回 top 1:多候选用逗号分隔看起来像一个命令
|
|
906
|
+
return best[0].name;
|
|
907
|
+
}
|
|
908
|
+
function levenshtein(a, b) {
|
|
909
|
+
const m = a.length;
|
|
910
|
+
const n = b.length;
|
|
911
|
+
if (m === 0)
|
|
912
|
+
return n;
|
|
913
|
+
if (n === 0)
|
|
914
|
+
return m;
|
|
915
|
+
// 只维护两行,节省内存
|
|
916
|
+
let prev = new Array(n + 1);
|
|
917
|
+
let curr = new Array(n + 1);
|
|
918
|
+
for (let j = 0; j <= n; j++)
|
|
919
|
+
prev[j] = j;
|
|
920
|
+
for (let i = 1; i <= m; i++) {
|
|
921
|
+
curr[0] = i;
|
|
922
|
+
for (let j = 1; j <= n; j++) {
|
|
923
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
924
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
925
|
+
}
|
|
926
|
+
[prev, curr] = [curr, prev];
|
|
927
|
+
}
|
|
928
|
+
return prev[n];
|
|
929
|
+
}
|
package/dist/locales.js
CHANGED
|
@@ -12,6 +12,10 @@ export const COMMANDER_FALLBACK_EN_US = {
|
|
|
12
12
|
'commander.ui.usage': 'Usage: {name} <command> [options]',
|
|
13
13
|
'commander.ui.commands': 'Commands:',
|
|
14
14
|
'commander.ui.options': 'Options:',
|
|
15
|
+
'commander.ui.option_example': '(e.g. {example})',
|
|
16
|
+
'commander.ui.deprecated': '(deprecated)',
|
|
17
|
+
'commander.ui.hint.available_options': "Run '{command} --help' for available options.",
|
|
18
|
+
'commander.ui.hint.did_you_mean': "Did you mean '{suggest}'?",
|
|
15
19
|
'commander.opt.locale': 'Locale id',
|
|
16
20
|
// Legacy dotted ids (product catalogs may override these).
|
|
17
21
|
'commander.err.unknown_command': 'unknown command `{cmd}`',
|
|
@@ -23,6 +27,10 @@ export const COMMANDER_FALLBACK_EN_US = {
|
|
|
23
27
|
'commander::unknown_option': 'unknown option `{option}`',
|
|
24
28
|
'commander::missing_option_value': 'missing value for `{option}`',
|
|
25
29
|
'commander::parse_failed': 'parse failed: {detail}',
|
|
30
|
+
'commander::missing_required_option': 'missing required option `{option}`',
|
|
31
|
+
'commander::option_not_in_enum': 'invalid value `{value}` for `{option}` (allowed: {allowed})',
|
|
32
|
+
'commander::option_coerce_failed': 'invalid value `{value}` for `{option}`: {detail}',
|
|
33
|
+
'commander::option_deprecated': 'option `{option}` is deprecated',
|
|
26
34
|
'commander::action_failed': 'action failed: {detail}',
|
|
27
35
|
'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
|
|
28
36
|
'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
|
package/dist/types.d.ts
CHANGED
|
@@ -59,3 +59,56 @@ export type DiagnosticContext = {
|
|
|
59
59
|
};
|
|
60
60
|
/** Product-owned sink. Formatting remains outside commander. */
|
|
61
61
|
export type DiagnosticHandler = (diagnostic: CommandDiagnostic, context: DiagnosticContext) => void;
|
|
62
|
+
/**
|
|
63
|
+
* Option registration config (additive — `.option(rawName, helpId, config?)`).
|
|
64
|
+
*
|
|
65
|
+
* Existing `parseOptionDef(rawName)` already extracts `<required>` / `[optional]` / `...repeatable`
|
|
66
|
+
* from the raw name string. This config adds runtime constraints that cannot be expressed in the DSL.
|
|
67
|
+
*/
|
|
68
|
+
export type OptionConfig = {
|
|
69
|
+
/** 默认值(不传时 options[key] = default) */
|
|
70
|
+
default?: string | boolean | string[];
|
|
71
|
+
/** 允许值集合;parse 时 strict 校验(失败 exit code 2) */
|
|
72
|
+
enum?: string[];
|
|
73
|
+
/** 是否必须(parse 时缺失 → CliError,exit code 2) */
|
|
74
|
+
required?: boolean;
|
|
75
|
+
/** 自定义转换(parse 后对值执行,coerce 失败 → CliError.exitCode 2) */
|
|
76
|
+
coerce?: (raw: string | boolean | string[]) => unknown;
|
|
77
|
+
/** 帮助文本中的简短示例(显示在 `--profile <name>` 后) */
|
|
78
|
+
example?: string;
|
|
79
|
+
/** 选项分组(帮助里的 section header) */
|
|
80
|
+
group?: string;
|
|
81
|
+
/** 标记 deprecated — 帮助里划线显示,调用时走 warn */
|
|
82
|
+
deprecated?: boolean | string;
|
|
83
|
+
/** 标记 hidden — 不显示在帮助(但仍可解析) */
|
|
84
|
+
hidden?: boolean;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Command action context — constructed by the framework before dispatching to `.actionCtx()`.
|
|
88
|
+
*
|
|
89
|
+
* Provides everything a command handler needs without re-parsing argv or re-discovering
|
|
90
|
+
* product internals. Error paths (`fail` / `warn` / `note`) route through the configured
|
|
91
|
+
* DiagnosticHandler (or stderr fallback) so downstream code never needs ad-hoc `console.error`.
|
|
92
|
+
*/
|
|
93
|
+
export type CommandContext = {
|
|
94
|
+
/** 完整命令路径(根命令在 [0])— e.g. `['vmz', 'application', 'check']` */
|
|
95
|
+
commandPath: string[];
|
|
96
|
+
/** 命令路径的人类可读串 — e.g. `'vmz application check'` */
|
|
97
|
+
commandName: string;
|
|
98
|
+
/** 当前 locale id — e.g. `'en-US'` */
|
|
99
|
+
locale: string;
|
|
100
|
+
/** 产品 LocalizePlugin.t — action 可以用它渲染 helpId / 错误 id */
|
|
101
|
+
t: LocalizePlugin['t'];
|
|
102
|
+
/** 打印当前命令的帮助文本(框架级,从 command tree 自动派生) */
|
|
103
|
+
help: () => void;
|
|
104
|
+
/** 写入 stderr(正常错误输出) */
|
|
105
|
+
stderr: (...msgs: string[]) => void;
|
|
106
|
+
/** 写入 stdout(正常进度 / 结果输出) */
|
|
107
|
+
stdout: (...msgs: string[]) => void;
|
|
108
|
+
/** 框架级 fail:走 onDiagnostic + 退出码 2(parse/validation 错误) */
|
|
109
|
+
fail: (code: string, args?: Record<string, string>) => never;
|
|
110
|
+
/** 框架级 warn:走 onDiagnostic(warning severity,不退出) */
|
|
111
|
+
warn: (code: string, args?: Record<string, string>) => void;
|
|
112
|
+
/** 框架级 note:走 onDiagnostic(advice severity,不退出) */
|
|
113
|
+
note: (code: string, args?: Record<string, string>) => void;
|
|
114
|
+
};
|
package/dist/types.js
CHANGED
|
@@ -38,6 +38,7 @@ export class CliError extends Error {
|
|
|
38
38
|
if (err instanceof CliError)
|
|
39
39
|
return err;
|
|
40
40
|
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
-
|
|
41
|
+
// 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
|
|
42
|
+
return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message, message } });
|
|
42
43
|
}
|
|
43
44
|
}
|
package/package.json
CHANGED
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -p tsconfig.json",
|
|
38
|
-
"test": "node --import ../../../scripts/test/resolve-ts-from-js.mjs --test --experimental-strip-types ./tests
|
|
38
|
+
"test": "node --import ../../../scripts/test/resolve-ts-from-js.mjs --test --experimental-strip-types ./tests/*.test.ts"
|
|
39
39
|
},
|
|
40
40
|
"type": "module",
|
|
41
41
|
"types": "./dist/index.d.ts",
|
|
42
|
-
"version": "0.3.
|
|
42
|
+
"version": "0.3.7"
|
|
43
43
|
}
|