@vmz/commander 0.3.5 → 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 +32 -1
- package/dist/index.d.ts +22 -5
- package/dist/index.js +562 -147
- package/dist/locales.js +24 -3
- package/dist/types.d.ts +96 -0
- package/dist/types.js +41 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@v
|
|
|
6
6
|
- **Help is derived** from the command tree — do not paste Usage walls into catalogs
|
|
7
7
|
- **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
|
|
8
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
|
|
9
10
|
- **No product language packs** in this package
|
|
10
11
|
|
|
11
12
|
## Quick start
|
|
@@ -41,10 +42,11 @@ Locales layout (any product):
|
|
|
41
42
|
| API | Role |
|
|
42
43
|
|-----|------|
|
|
43
44
|
| `createCli(name)` | Program name; help `{name}` |
|
|
44
|
-
| `.command` / `.option` / `.action` / `.passthrough` | Command tree |
|
|
45
|
+
| `.command` / `.option` / `.action` / `.actionCtx` / `.passthrough` | Command tree |
|
|
45
46
|
| `.intro(id)` | Short banner only |
|
|
46
47
|
| `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
|
|
47
48
|
| `.option` on CLI | Global flags (merged into action options) |
|
|
49
|
+
| `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
|
|
48
50
|
| `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
|
|
49
51
|
| `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
|
|
50
52
|
| `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
|
|
@@ -52,6 +54,35 @@ Locales layout (any product):
|
|
|
52
54
|
|
|
53
55
|
`t` resolution: product catalog → `commander` English fallback → `{{id}}`.
|
|
54
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
|
+
|
|
55
86
|
## Layering
|
|
56
87
|
|
|
57
88
|
```text
|
package/dist/index.d.ts
CHANGED
|
@@ -5,16 +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, LocaleCatalog, LocalizePlugin } from './types.js';
|
|
8
|
+
export type { CatalogLoader, CommandContext, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
|
|
9
|
+
export { CliError } from './types.js';
|
|
9
10
|
export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
|
|
10
11
|
export type { CreateLocalizeFromLocalesOptions, LocalesManifest } from './locales.js';
|
|
11
|
-
import type { CatalogLoader, LocaleCatalog, LocalizePlugin } from './types.js';
|
|
12
|
+
import type { CatalogLoader, CommandContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
|
|
12
13
|
/** Options bag passed to actions (`_` = positionals). */
|
|
13
14
|
export type ParsedOptions = Record<string, string | boolean | string[]> & {
|
|
14
15
|
_: string[];
|
|
15
16
|
};
|
|
16
|
-
/** Command action after argv is parsed. */
|
|
17
|
+
/** Command action after argv is parsed (legacy API — no context). */
|
|
17
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>;
|
|
18
21
|
/** Registered option (structure only). */
|
|
19
22
|
export type OptionDef = {
|
|
20
23
|
rawName: string;
|
|
@@ -29,6 +32,8 @@ export type OptionDef = {
|
|
|
29
32
|
optionalValue: boolean;
|
|
30
33
|
/** True when the option may repeat (`--dirty <path>...` ⇒ `string[]`). */
|
|
31
34
|
repeatable: boolean;
|
|
35
|
+
/** Runtime constraints (enum / required / default / coerce / deprecated). */
|
|
36
|
+
config?: OptionConfig;
|
|
32
37
|
};
|
|
33
38
|
/** Registered command node. */
|
|
34
39
|
export type CommandDef = {
|
|
@@ -37,14 +42,20 @@ export type CommandDef = {
|
|
|
37
42
|
/** Match tokens from `rawName` split on `|`. */
|
|
38
43
|
names: string[];
|
|
39
44
|
options: OptionDef[];
|
|
45
|
+
/** Legacy action handler (no ctx). */
|
|
40
46
|
action?: ActionHandler;
|
|
47
|
+
/** Phase 3 action handler (with ctx — preferred). */
|
|
48
|
+
actionCtx?: ActionCtxHandler;
|
|
41
49
|
children: CommandDef[];
|
|
42
50
|
/** If true, do not parse options; remaining argv is positionals / raw rest. */
|
|
43
51
|
passthrough: boolean;
|
|
44
52
|
};
|
|
45
53
|
export interface Command {
|
|
46
|
-
option(rawName: string, helpId: string): this;
|
|
54
|
+
option(rawName: string, helpId: string, config?: OptionConfig): this;
|
|
55
|
+
/** Legacy API — handler receives `(options, ...args)` without ctx. */
|
|
47
56
|
action(handler: ActionHandler): this;
|
|
57
|
+
/** Phase 3 API — handler receives `(options, args[], ctx)` with full command context. */
|
|
58
|
+
actionCtx(handler: ActionCtxHandler): this;
|
|
48
59
|
command(rawName: string, helpId: string): Command;
|
|
49
60
|
/** Leave remaining argv unparsed (for nested domain CLIs). */
|
|
50
61
|
passthrough(): this;
|
|
@@ -71,6 +82,8 @@ export interface Cli {
|
|
|
71
82
|
*/
|
|
72
83
|
help(helpId: string): this;
|
|
73
84
|
command(rawName: string, helpId: string): Command;
|
|
85
|
+
/** Route structured action failures to a product-owned renderer. */
|
|
86
|
+
onDiagnostic(handler: DiagnosticHandler): this;
|
|
74
87
|
parse(argv?: string[]): Promise<number>;
|
|
75
88
|
}
|
|
76
89
|
/** Derive command help from one node (children + options). */
|
|
@@ -96,6 +109,10 @@ export declare function normalizeArgv(argv: string[]): string[];
|
|
|
96
109
|
/** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
|
|
97
110
|
export declare function parseOptionDef(rawName: string, helpId: string): OptionDef;
|
|
98
111
|
/**
|
|
99
|
-
* Parse argv against registered options
|
|
112
|
+
* Parse argv against registered options (strict mode).
|
|
113
|
+
*
|
|
114
|
+
* Throws {@link CliError} with stable code `commander::unknown_option` /
|
|
115
|
+
* `commander::missing_option_value` (exit code 2) on validation failure.
|
|
116
|
+
* Non-option tokens become positionals under `options._`.
|
|
100
117
|
*/
|
|
101
118
|
export declare function parseOptions(argv: string[], optionDefs: OptionDef[]): ParsedOptions;
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,147 @@
|
|
|
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 { CliError } from './types.js';
|
|
8
9
|
export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
|
|
10
|
+
import { CliError } from './types.js';
|
|
9
11
|
import { COMMANDER_FALLBACK_EN_US, createLocalizeFromLocales, translate, translateWithFallback } from './locales.js';
|
|
12
|
+
const ANSI = {
|
|
13
|
+
bold: '\u001b[1m',
|
|
14
|
+
dim: '\u001b[2m',
|
|
15
|
+
cyan: '\u001b[36m',
|
|
16
|
+
blue: '\u001b[34m',
|
|
17
|
+
green: '\u001b[32m',
|
|
18
|
+
yellow: '\u001b[33m',
|
|
19
|
+
red: '\u001b[31m',
|
|
20
|
+
};
|
|
21
|
+
const ANSI_RESET = '\u001b[0m';
|
|
22
|
+
/** Keep CI and redirected output plain while making interactive help scannable. */
|
|
23
|
+
function colorEnabled() {
|
|
24
|
+
if (process.env.NO_COLOR !== undefined)
|
|
25
|
+
return false;
|
|
26
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0')
|
|
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;
|
|
47
|
+
return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
|
|
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
|
+
}
|
|
59
|
+
function paint(value, ...colors) {
|
|
60
|
+
return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
|
|
61
|
+
}
|
|
62
|
+
function errorText(value) {
|
|
63
|
+
return paint(value, 'bold', 'red');
|
|
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
|
+
}
|
|
10
149
|
class CommandBuilder {
|
|
11
150
|
def;
|
|
12
151
|
constructor(rawName, helpId) {
|
|
@@ -20,15 +159,26 @@ class CommandBuilder {
|
|
|
20
159
|
passthrough: false,
|
|
21
160
|
};
|
|
22
161
|
}
|
|
23
|
-
option(rawName, helpId) {
|
|
162
|
+
option(rawName, helpId, config) {
|
|
24
163
|
assertHelpId(helpId, 'option');
|
|
25
|
-
|
|
164
|
+
const def = parseOptionDef(rawName, helpId);
|
|
165
|
+
if (config)
|
|
166
|
+
def.config = config;
|
|
167
|
+
this.def.options.push(def);
|
|
26
168
|
return this;
|
|
27
169
|
}
|
|
28
170
|
action(handler) {
|
|
171
|
+
if (this.def.actionCtx)
|
|
172
|
+
throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
|
|
29
173
|
this.def.action = handler;
|
|
30
174
|
return this;
|
|
31
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
|
+
}
|
|
32
182
|
passthrough() {
|
|
33
183
|
this.def.passthrough = true;
|
|
34
184
|
return this;
|
|
@@ -45,6 +195,7 @@ class CliBuilder {
|
|
|
45
195
|
/** When set, `parse` rebuilds localize from this root (argv/env/`--locale`). */
|
|
46
196
|
localesRoot = null;
|
|
47
197
|
localesEnvKeys;
|
|
198
|
+
diagnosticHandler;
|
|
48
199
|
introId = null;
|
|
49
200
|
roots = [];
|
|
50
201
|
rootOptions = [];
|
|
@@ -53,7 +204,7 @@ class CliBuilder {
|
|
|
53
204
|
}
|
|
54
205
|
use(plugin) {
|
|
55
206
|
if (!plugin || typeof plugin.t !== 'function') {
|
|
56
|
-
throw
|
|
207
|
+
throw CliError.configError('commander::config::invalid_localize_plugin', { detail: 'LocalizePlugin.t is required' });
|
|
57
208
|
}
|
|
58
209
|
this.localesRoot = null;
|
|
59
210
|
this.localesEnvKeys = undefined;
|
|
@@ -62,13 +213,13 @@ class CliBuilder {
|
|
|
62
213
|
}
|
|
63
214
|
catalog(loader) {
|
|
64
215
|
if (typeof loader !== 'function') {
|
|
65
|
-
throw
|
|
216
|
+
throw CliError.configError('commander::config::catalog_loader_not_function');
|
|
66
217
|
}
|
|
67
218
|
return this.use({
|
|
68
219
|
t: (id, args) => {
|
|
69
220
|
const table = loader('en-US');
|
|
70
221
|
if (table && typeof table.then === 'function') {
|
|
71
|
-
throw
|
|
222
|
+
throw CliError.configError('commander::config::async_catalog_unsupported');
|
|
72
223
|
}
|
|
73
224
|
return translateWithFallback(id, args, table);
|
|
74
225
|
},
|
|
@@ -76,7 +227,7 @@ class CliBuilder {
|
|
|
76
227
|
}
|
|
77
228
|
locales(root, opts = {}) {
|
|
78
229
|
if (!root || typeof root !== 'string') {
|
|
79
|
-
throw
|
|
230
|
+
throw CliError.configError('commander::config::invalid_locales_root');
|
|
80
231
|
}
|
|
81
232
|
this.localesRoot = root;
|
|
82
233
|
this.localesEnvKeys = opts.envKeys;
|
|
@@ -105,6 +256,12 @@ class CliBuilder {
|
|
|
105
256
|
this.roots.push(cmd);
|
|
106
257
|
return cmd;
|
|
107
258
|
}
|
|
259
|
+
onDiagnostic(handler) {
|
|
260
|
+
if (typeof handler !== 'function')
|
|
261
|
+
throw CliError.configError('commander::config::diagnostic_handler_not_function');
|
|
262
|
+
this.diagnosticHandler = handler;
|
|
263
|
+
return this;
|
|
264
|
+
}
|
|
108
265
|
/** Help ids registered on this CLI (for {@link assertCatalogCoverage}). */
|
|
109
266
|
collectHelpIds() {
|
|
110
267
|
const ids = [];
|
|
@@ -125,7 +282,11 @@ class CliBuilder {
|
|
|
125
282
|
}
|
|
126
283
|
async parse(argvInput = process.argv) {
|
|
127
284
|
const argv0 = normalizeArgv(argvInput);
|
|
128
|
-
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;
|
|
129
290
|
let localize = this.localize;
|
|
130
291
|
if (this.localesRoot) {
|
|
131
292
|
const localeFlag = typeof globalOpts.locale === 'string' && globalOpts.locale ? globalOpts.locale : undefined;
|
|
@@ -138,21 +299,91 @@ class CliBuilder {
|
|
|
138
299
|
}));
|
|
139
300
|
}
|
|
140
301
|
if (!localize) {
|
|
141
|
-
throw
|
|
302
|
+
throw CliError.configError('commander::config::localize_required');
|
|
142
303
|
}
|
|
143
304
|
const t = localize.t.bind(localize);
|
|
144
|
-
|
|
305
|
+
const diagnosticContext = {
|
|
306
|
+
locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
|
|
307
|
+
t,
|
|
308
|
+
};
|
|
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)) {
|
|
145
315
|
console.log(this.formatRootHelp(t));
|
|
146
316
|
return 0;
|
|
147
317
|
}
|
|
148
318
|
const cmdToken = argv[0];
|
|
149
319
|
const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
|
|
150
320
|
if (!matched) {
|
|
151
|
-
|
|
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;
|
|
330
|
+
}
|
|
331
|
+
return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
|
|
332
|
+
}
|
|
333
|
+
/** 路由 `help <cmd> [subcmd] ...` 到对应命令的帮助文本。 */
|
|
334
|
+
routeHelp(pathTokens, t, _ctx) {
|
|
335
|
+
if (!pathTokens.length) {
|
|
152
336
|
console.log(this.formatRootHelp(t));
|
|
153
|
-
return
|
|
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. */
|
|
360
|
+
emitDiagnostic(diag, ctx, t) {
|
|
361
|
+
if (this.diagnosticHandler) {
|
|
362
|
+
this.diagnosticHandler(diag, ctx);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
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'));
|
|
154
386
|
}
|
|
155
|
-
return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts);
|
|
156
387
|
}
|
|
157
388
|
/** Derived root help: optional intro + commands/options from the registration tree. */
|
|
158
389
|
formatRootHelp(t) {
|
|
@@ -160,62 +391,81 @@ class CliBuilder {
|
|
|
160
391
|
if (this.introId) {
|
|
161
392
|
lines.push(t(this.introId), '');
|
|
162
393
|
}
|
|
163
|
-
lines.push(t('commander.ui.usage', { name: this.name }), '',
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
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'));
|
|
167
397
|
const opts = [
|
|
168
398
|
...this.rootOptions,
|
|
169
399
|
...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
|
|
170
400
|
];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
401
|
+
const optionSection = renderOptionSections(t, opts, 'yellow');
|
|
402
|
+
if (optionSection.length) {
|
|
403
|
+
lines.push('');
|
|
404
|
+
lines.push(...optionSection);
|
|
176
405
|
}
|
|
177
406
|
return lines.join('\n');
|
|
178
407
|
}
|
|
179
|
-
async dispatch(def, rest, t, path, globalOpts) {
|
|
180
|
-
|
|
181
|
-
|
|
408
|
+
async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
|
|
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();
|
|
429
|
+
}
|
|
430
|
+
// 扫描 --help / -h(只扫命令路径之后的 head,`--` 之后的 tail 不参与)
|
|
431
|
+
if (hasHelpToken(remaining)) {
|
|
432
|
+
console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
|
|
182
433
|
return 0;
|
|
183
434
|
}
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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;
|
|
189
449
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
193
|
-
return 1;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (!def.action) {
|
|
197
|
-
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
198
|
-
return rest.length ? 1 : 0;
|
|
450
|
+
console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
|
|
451
|
+
return 0;
|
|
199
452
|
}
|
|
200
453
|
let options;
|
|
454
|
+
let usedOptions = new Set();
|
|
201
455
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
catch (err) {
|
|
205
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
206
|
-
if (msg.startsWith('unknown_option:')) {
|
|
207
|
-
console.error(t('commander.err.unknown_option', { option: msg.slice('unknown_option:'.length) }));
|
|
208
|
-
}
|
|
209
|
-
else if (msg.startsWith('missing_value:')) {
|
|
210
|
-
console.error(t('commander.err.missing_option_value', {
|
|
211
|
-
option: msg.slice('missing_value:'.length),
|
|
212
|
-
}));
|
|
456
|
+
if (target.passthrough) {
|
|
457
|
+
options = { _: [...remaining, ...tail] };
|
|
213
458
|
}
|
|
214
459
|
else {
|
|
215
|
-
|
|
460
|
+
({ options, used: usedOptions } = parseOptionsDetailed(remaining, target.options));
|
|
461
|
+
options._.push(...tail);
|
|
216
462
|
}
|
|
217
|
-
|
|
218
|
-
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
const cliErr = CliError.from(err, 'commander::parse_failed');
|
|
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;
|
|
219
469
|
}
|
|
220
470
|
for (const [k, v] of Object.entries(globalOpts)) {
|
|
221
471
|
if (k === '_')
|
|
@@ -223,28 +473,81 @@ class CliBuilder {
|
|
|
223
473
|
if (options[k] === undefined)
|
|
224
474
|
options[k] = v;
|
|
225
475
|
}
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|
|
486
|
+
try {
|
|
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
|
+
}
|
|
499
|
+
if (typeof result === 'number')
|
|
500
|
+
return result;
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
const diagnostic = normalizeDiagnostic(err);
|
|
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;
|
|
509
|
+
return 1;
|
|
510
|
+
}
|
|
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
|
+
};
|
|
230
537
|
}
|
|
231
538
|
}
|
|
232
539
|
/** Derive command help from one node (children + options). */
|
|
233
540
|
export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
|
|
234
|
-
const lines = [`${cliName} ${path.join(' ')} — ${t(def.helpId)}
|
|
541
|
+
const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`];
|
|
235
542
|
if (def.children.length) {
|
|
236
|
-
lines.push(t('commander.ui.commands'));
|
|
237
|
-
for (const c of def.children) {
|
|
238
|
-
lines.push(` ${c.rawName.padEnd(28)} ${t(c.helpId)}`);
|
|
239
|
-
}
|
|
240
543
|
lines.push('');
|
|
544
|
+
lines.push(...renderHelpSection(t, 'commander.ui.commands', def.children.map((c) => ({ rawName: c.rawName, helpId: c.helpId })), 'green'));
|
|
241
545
|
}
|
|
242
546
|
const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
547
|
+
const optionSection = renderOptionSections(t, opts, 'yellow');
|
|
548
|
+
if (optionSection.length) {
|
|
549
|
+
lines.push('');
|
|
550
|
+
lines.push(...optionSection);
|
|
248
551
|
}
|
|
249
552
|
return lines.join('\n').trimEnd();
|
|
250
553
|
}
|
|
@@ -267,7 +570,7 @@ function collectRootOptions(roots) {
|
|
|
267
570
|
*/
|
|
268
571
|
export function createCli(name) {
|
|
269
572
|
if (!name || typeof name !== 'string') {
|
|
270
|
-
throw
|
|
573
|
+
throw CliError.configError('commander::config::invalid_cli_name', { detail: String(name) });
|
|
271
574
|
}
|
|
272
575
|
return new CliBuilder(name);
|
|
273
576
|
}
|
|
@@ -278,7 +581,7 @@ export function assertCatalogCoverage(cli, catalog) {
|
|
|
278
581
|
const ids = typeof cli.collectHelpIds === 'function' ? cli.collectHelpIds() : [];
|
|
279
582
|
const missing = ids.filter((id) => !Object.prototype.hasOwnProperty.call(catalog, id) && !Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id));
|
|
280
583
|
if (missing.length) {
|
|
281
|
-
throw
|
|
584
|
+
throw CliError.configError('commander::config::catalog_missing_help_ids', { ids: missing.sort().join(', ') });
|
|
282
585
|
}
|
|
283
586
|
}
|
|
284
587
|
function wrapWithCommanderFallback(plugin) {
|
|
@@ -296,10 +599,15 @@ function wrapWithCommanderFallback(plugin) {
|
|
|
296
599
|
};
|
|
297
600
|
}
|
|
298
601
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
602
|
+
* Shared argv → { options, rest } parser used by both strict and permissive modes.
|
|
603
|
+
*
|
|
604
|
+
* - `strict: true` — throws {@link CliError} (exit code 2) on unknown option or
|
|
605
|
+
* missing required value. Used by {@link parseOptions} before dispatching to an action.
|
|
606
|
+
* - `strict: false` — unknown flags / bare tokens stay in `rest`. Used by
|
|
607
|
+
* {@link peelKnownOptions} so `.locales(root)` / other early-bound global options
|
|
608
|
+
* never reject argv that belongs to a later subcommand.
|
|
301
609
|
*/
|
|
302
|
-
|
|
610
|
+
function parseArgvCore(argv, optionDefs, strict) {
|
|
303
611
|
const byAlias = new Map();
|
|
304
612
|
for (const def of optionDefs) {
|
|
305
613
|
for (const a of def.aliases)
|
|
@@ -307,70 +615,108 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
307
615
|
}
|
|
308
616
|
const options = { _: [] };
|
|
309
617
|
const rest = [];
|
|
618
|
+
/** Options explicitly supplied on the command line (defaults are not "used"). */
|
|
619
|
+
const used = new Set();
|
|
620
|
+
const handleMissing = (flagName) => {
|
|
621
|
+
if (strict) {
|
|
622
|
+
throw CliError.parseError('commander::missing_option_value', { option: flagName });
|
|
623
|
+
}
|
|
624
|
+
rest.push(flagName);
|
|
625
|
+
return true;
|
|
626
|
+
};
|
|
627
|
+
const handleUnknown = (token) => {
|
|
628
|
+
if (strict) {
|
|
629
|
+
throw CliError.parseError('commander::unknown_option', { option: token });
|
|
630
|
+
}
|
|
631
|
+
rest.push(token);
|
|
632
|
+
return true;
|
|
633
|
+
};
|
|
310
634
|
for (let i = 0; i < argv.length; i++) {
|
|
311
635
|
const a = argv[i];
|
|
312
636
|
if (a === '--') {
|
|
313
|
-
|
|
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));
|
|
314
644
|
break;
|
|
315
645
|
}
|
|
646
|
+
// help token 不在这里 skip:让它进 rest,由上层 dispatch 的 hasHelpToken 路由
|
|
316
647
|
if (a.startsWith('--')) {
|
|
317
648
|
const eq = a.indexOf('=');
|
|
318
649
|
const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
319
650
|
const def = byAlias.get(long);
|
|
320
651
|
if (!def) {
|
|
321
|
-
|
|
652
|
+
if (!handleUnknown(a))
|
|
653
|
+
continue;
|
|
322
654
|
continue;
|
|
323
655
|
}
|
|
324
656
|
if (def.takesValue) {
|
|
325
657
|
if (eq !== -1) {
|
|
326
|
-
assignOption(options, def, a.slice(eq + 1));
|
|
658
|
+
assignOption(options, def, used, a.slice(eq + 1));
|
|
327
659
|
}
|
|
328
660
|
else {
|
|
329
661
|
const next = argv[i + 1];
|
|
330
662
|
if (next != null && !next.startsWith('-')) {
|
|
331
|
-
assignOption(options, def, next);
|
|
663
|
+
assignOption(options, def, used, next);
|
|
332
664
|
i += 1;
|
|
333
665
|
}
|
|
334
666
|
else if (def.optionalValue) {
|
|
335
|
-
assignOption(options, def, true);
|
|
667
|
+
assignOption(options, def, used, true);
|
|
336
668
|
}
|
|
337
669
|
else {
|
|
338
|
-
|
|
670
|
+
if (!handleMissing(`--${long}`))
|
|
671
|
+
continue;
|
|
672
|
+
continue;
|
|
339
673
|
}
|
|
340
674
|
}
|
|
341
675
|
}
|
|
342
676
|
else {
|
|
343
|
-
assignOption(options, def, true);
|
|
677
|
+
assignOption(options, def, used, true);
|
|
344
678
|
}
|
|
345
679
|
continue;
|
|
346
680
|
}
|
|
347
|
-
|
|
681
|
+
// 短参数:-x 或 -xyz(多字符),只要不以 -- 开头且以 - 开头且长度 > 1 就认作短选项
|
|
682
|
+
if (a.startsWith('-') && a.length > 1) {
|
|
348
683
|
const short = a.slice(1);
|
|
349
684
|
const def = byAlias.get(short);
|
|
350
685
|
if (!def) {
|
|
351
|
-
|
|
686
|
+
if (!handleUnknown(a))
|
|
687
|
+
continue;
|
|
352
688
|
continue;
|
|
353
689
|
}
|
|
354
690
|
if (def.takesValue) {
|
|
355
691
|
const next = argv[i + 1];
|
|
356
692
|
if (next != null && !next.startsWith('-')) {
|
|
357
|
-
assignOption(options, def, next);
|
|
693
|
+
assignOption(options, def, used, next);
|
|
358
694
|
i += 1;
|
|
359
695
|
}
|
|
360
696
|
else if (def.optionalValue) {
|
|
361
|
-
assignOption(options, def, true);
|
|
697
|
+
assignOption(options, def, used, true);
|
|
362
698
|
}
|
|
363
699
|
else {
|
|
364
|
-
|
|
700
|
+
if (!handleMissing(`-${short}`))
|
|
701
|
+
continue;
|
|
702
|
+
continue;
|
|
365
703
|
}
|
|
366
704
|
}
|
|
367
705
|
else {
|
|
368
|
-
assignOption(options, def, true);
|
|
706
|
+
assignOption(options, def, used, true);
|
|
369
707
|
}
|
|
370
708
|
continue;
|
|
371
709
|
}
|
|
372
710
|
rest.push(a);
|
|
373
711
|
}
|
|
712
|
+
return { options, rest, used };
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Consume known root options; leave unknown flags and positionals in `rest`
|
|
716
|
+
* (unlike {@link parseOptions}, which throws on unknown options).
|
|
717
|
+
*/
|
|
718
|
+
export function peelKnownOptions(argv, optionDefs) {
|
|
719
|
+
const { options, rest } = parseArgvCore(argv, optionDefs, false);
|
|
374
720
|
return { options, rest };
|
|
375
721
|
}
|
|
376
722
|
/** Strip `node` + script when callers pass full `process.argv`. */
|
|
@@ -398,10 +744,10 @@ function splitNames(rawName) {
|
|
|
398
744
|
}
|
|
399
745
|
function assertHelpId(helpId, kind) {
|
|
400
746
|
if (!helpId || typeof helpId !== 'string' || !helpId.trim()) {
|
|
401
|
-
throw
|
|
747
|
+
throw CliError.configError('commander::config::help_id_empty', { kind });
|
|
402
748
|
}
|
|
403
749
|
if (/\s/.test(helpId)) {
|
|
404
|
-
throw
|
|
750
|
+
throw CliError.configError('commander::config::help_id_has_spaces', { kind, id: helpId });
|
|
405
751
|
}
|
|
406
752
|
}
|
|
407
753
|
/** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
|
|
@@ -425,84 +771,98 @@ export function parseOptionDef(rawName, helpId) {
|
|
|
425
771
|
aliases.push(p.slice(1));
|
|
426
772
|
}
|
|
427
773
|
if (!aliases.length) {
|
|
428
|
-
throw
|
|
774
|
+
throw CliError.configError('commander::config::invalid_option_raw', { raw: rawName });
|
|
429
775
|
}
|
|
430
776
|
const key = aliases.find((a) => a.length > 1) ?? aliases[0];
|
|
431
777
|
return { rawName, helpId, key, aliases, takesValue, optionalValue, repeatable };
|
|
432
778
|
}
|
|
779
|
+
function normalizeDiagnostic(error) {
|
|
780
|
+
if (error && typeof error === 'object') {
|
|
781
|
+
const value = error;
|
|
782
|
+
const candidate = value.diagnostic && typeof value.diagnostic === 'object' ? value.diagnostic : value;
|
|
783
|
+
if (typeof candidate.code === 'string' || typeof candidate.severity === 'string' || typeof candidate.path === 'string') {
|
|
784
|
+
return {
|
|
785
|
+
code: typeof candidate.code === 'string' ? candidate.code : undefined,
|
|
786
|
+
severity: typeof candidate.severity === 'string' ? candidate.severity : 'error',
|
|
787
|
+
args: candidate.args && typeof candidate.args === 'object' ? candidate.args : undefined,
|
|
788
|
+
path: typeof candidate.path === 'string' ? candidate.path : undefined,
|
|
789
|
+
span: candidate.span && typeof candidate.span === 'object' ? candidate.span : undefined,
|
|
790
|
+
message: typeof candidate.message === 'string' ? candidate.message : undefined,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
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 };
|
|
797
|
+
}
|
|
433
798
|
/**
|
|
434
|
-
* Parse argv against registered options
|
|
799
|
+
* Parse argv against registered options (strict mode).
|
|
800
|
+
*
|
|
801
|
+
* Throws {@link CliError} with stable code `commander::unknown_option` /
|
|
802
|
+
* `commander::missing_option_value` (exit code 2) on validation failure.
|
|
803
|
+
* Non-option tokens become positionals under `options._`.
|
|
435
804
|
*/
|
|
436
805
|
export function parseOptions(argv, optionDefs) {
|
|
437
|
-
|
|
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);
|
|
811
|
+
options._.push(...rest);
|
|
812
|
+
applyOptionConfig(options, optionDefs, used);
|
|
813
|
+
return { options, used };
|
|
814
|
+
}
|
|
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) {
|
|
438
822
|
for (const def of optionDefs) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (a === '--') {
|
|
446
|
-
out._.push(...argv.slice(i + 1));
|
|
447
|
-
break;
|
|
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;
|
|
448
829
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
assignOption(out, def, true);
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
throw new Error(`missing_value:--${long}`);
|
|
470
|
-
}
|
|
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
|
+
});
|
|
471
847
|
}
|
|
472
848
|
}
|
|
473
|
-
else {
|
|
474
|
-
assignOption(out, def, true);
|
|
475
|
-
}
|
|
476
|
-
continue;
|
|
477
849
|
}
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (!def)
|
|
482
|
-
throw new Error(`unknown_option:-${short}`);
|
|
483
|
-
if (def.takesValue) {
|
|
484
|
-
const next = argv[i + 1];
|
|
485
|
-
if (next != null && !next.startsWith('-')) {
|
|
486
|
-
assignOption(out, def, next);
|
|
487
|
-
i += 1;
|
|
488
|
-
}
|
|
489
|
-
else if (def.optionalValue) {
|
|
490
|
-
assignOption(out, def, true);
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
throw new Error(`missing_value:-${short}`);
|
|
494
|
-
}
|
|
850
|
+
if (config.coerce && provided) {
|
|
851
|
+
try {
|
|
852
|
+
options[def.key] = config.coerce(value);
|
|
495
853
|
}
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
});
|
|
498
860
|
}
|
|
499
|
-
continue;
|
|
500
861
|
}
|
|
501
|
-
out._.push(a);
|
|
502
862
|
}
|
|
503
|
-
return out;
|
|
504
863
|
}
|
|
505
|
-
function assignOption(out, def, value) {
|
|
864
|
+
function assignOption(out, def, used, value) {
|
|
865
|
+
used.add(def);
|
|
506
866
|
if (def.repeatable) {
|
|
507
867
|
const cur = out[def.key];
|
|
508
868
|
const list = Array.isArray(cur) ? cur : [];
|
|
@@ -512,3 +872,58 @@ function assignOption(out, def, value) {
|
|
|
512
872
|
}
|
|
513
873
|
out[def.key] = value;
|
|
514
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
|
@@ -6,16 +6,37 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { CliError } from './types.js';
|
|
9
10
|
/** Framework chrome / errors — products may override the same ids in their locales/. */
|
|
10
11
|
export const COMMANDER_FALLBACK_EN_US = {
|
|
11
12
|
'commander.ui.usage': 'Usage: {name} <command> [options]',
|
|
12
13
|
'commander.ui.commands': 'Commands:',
|
|
13
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}'?",
|
|
14
19
|
'commander.opt.locale': 'Locale id',
|
|
20
|
+
// Legacy dotted ids (product catalogs may override these).
|
|
15
21
|
'commander.err.unknown_command': 'unknown command `{cmd}`',
|
|
16
22
|
'commander.err.unknown_option': 'unknown option `{option}`',
|
|
17
23
|
'commander.err.missing_option_value': 'missing value for `{option}`',
|
|
18
24
|
'commander.err.localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
|
|
25
|
+
// New stable `::` codes — used when the framework itself emits diagnostics.
|
|
26
|
+
'commander::unknown_command': 'unknown command `{cmd}`',
|
|
27
|
+
'commander::unknown_option': 'unknown option `{option}`',
|
|
28
|
+
'commander::missing_option_value': 'missing value for `{option}`',
|
|
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',
|
|
34
|
+
'commander::action_failed': 'action failed: {detail}',
|
|
35
|
+
'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
|
|
36
|
+
'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
|
|
37
|
+
'commander::config::missing_locales_manifest': 'missing locales manifest at {file}',
|
|
38
|
+
'commander::config::invalid_locales_manifest': 'invalid locales manifest at {file}',
|
|
39
|
+
'commander::config::empty_locale_catalog': 'empty catalog for locale {locale} under {root}',
|
|
19
40
|
};
|
|
20
41
|
const catalogCache = new Map();
|
|
21
42
|
const manifestCache = new Map();
|
|
@@ -25,11 +46,11 @@ export function loadLocalesManifest(root) {
|
|
|
25
46
|
return cached;
|
|
26
47
|
const file = path.join(root, 'locales.json');
|
|
27
48
|
if (!existsSync(file)) {
|
|
28
|
-
throw
|
|
49
|
+
throw CliError.configError('commander::config::missing_locales_manifest', { file });
|
|
29
50
|
}
|
|
30
51
|
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
52
|
if (!raw?.defaultLocale || !Array.isArray(raw.locales) || !raw.locales.length) {
|
|
32
|
-
throw
|
|
53
|
+
throw CliError.configError('commander::config::invalid_locales_manifest', { file });
|
|
33
54
|
}
|
|
34
55
|
manifestCache.set(root, raw);
|
|
35
56
|
return raw;
|
|
@@ -81,7 +102,7 @@ export function loadCatalog(locale, root) {
|
|
|
81
102
|
Object.assign(merged, loadLocaleDir(root, id));
|
|
82
103
|
}
|
|
83
104
|
if (!Object.keys(merged).length) {
|
|
84
|
-
throw
|
|
105
|
+
throw CliError.configError('commander::config::empty_locale_catalog', { locale, root });
|
|
85
106
|
}
|
|
86
107
|
catalogCache.set(cacheKey, merged);
|
|
87
108
|
return merged;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* Structured error thrown by `@vmz/commander` itself.
|
|
6
|
+
*
|
|
7
|
+
* Exit code contract (POSIX CLI conventions):
|
|
8
|
+
* - 2: parse / validation failure (unknown command, unknown/missing option).
|
|
9
|
+
* - 1: config / factory / action-handler failure.
|
|
10
|
+
*/
|
|
11
|
+
export declare class CliError extends Error {
|
|
12
|
+
/** Stable code (`commander::*`) consumed by callers; also doubles as catalog id. */
|
|
13
|
+
readonly code: string;
|
|
14
|
+
/** Exit code a framework user should return for this failure. */
|
|
15
|
+
readonly exitCode: number;
|
|
16
|
+
/** Structured catalog args (`{ name: value }`) for message interpolation. */
|
|
17
|
+
readonly args: Record<string, string>;
|
|
18
|
+
/** Optional root cause. */
|
|
19
|
+
readonly cause?: unknown;
|
|
20
|
+
private constructor();
|
|
21
|
+
/** Parse / validation failures (exit code 2). */
|
|
22
|
+
static parseError(code: string, args?: Record<string, string>): CliError;
|
|
23
|
+
/** Factory / configuration failures (exit code 1). */
|
|
24
|
+
static configError(code: string, args?: Record<string, string>): CliError;
|
|
25
|
+
/** Wrap an arbitrary exception as a CliError (exit code 1). */
|
|
26
|
+
static from(err: unknown, fallbackCode?: string): CliError;
|
|
27
|
+
}
|
|
4
28
|
/** Message id → template. Owned by the localize plugin / product, not this package. */
|
|
5
29
|
export type LocaleCatalog = Record<string, string>;
|
|
6
30
|
/** Load messages for one locale (sugar for building a {@link LocalizePlugin}). */
|
|
@@ -16,3 +40,75 @@ export type LocalizePlugin = {
|
|
|
16
40
|
}) => string;
|
|
17
41
|
t: (id: string, args?: Record<string, string>) => string;
|
|
18
42
|
};
|
|
43
|
+
/** Language-neutral diagnostic row accepted at a command boundary. */
|
|
44
|
+
export type CommandDiagnostic = {
|
|
45
|
+
severity?: 'error' | 'warning' | 'advice' | string;
|
|
46
|
+
code?: string;
|
|
47
|
+
args?: Record<string, string>;
|
|
48
|
+
path?: string;
|
|
49
|
+
span?: {
|
|
50
|
+
start: number;
|
|
51
|
+
end: number;
|
|
52
|
+
};
|
|
53
|
+
message?: string;
|
|
54
|
+
};
|
|
55
|
+
/** Product-owned sink. Formatting remains in the product diagnostic package. */
|
|
56
|
+
export type DiagnosticContext = {
|
|
57
|
+
locale: string;
|
|
58
|
+
t: LocalizePlugin['t'];
|
|
59
|
+
};
|
|
60
|
+
/** Product-owned sink. Formatting remains outside commander. */
|
|
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
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
|
|
3
3
|
*/
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Structured error thrown by `@vmz/commander` itself.
|
|
6
|
+
*
|
|
7
|
+
* Exit code contract (POSIX CLI conventions):
|
|
8
|
+
* - 2: parse / validation failure (unknown command, unknown/missing option).
|
|
9
|
+
* - 1: config / factory / action-handler failure.
|
|
10
|
+
*/
|
|
11
|
+
export class CliError extends Error {
|
|
12
|
+
/** Stable code (`commander::*`) consumed by callers; also doubles as catalog id. */
|
|
13
|
+
code;
|
|
14
|
+
/** Exit code a framework user should return for this failure. */
|
|
15
|
+
exitCode;
|
|
16
|
+
/** Structured catalog args (`{ name: value }`) for message interpolation. */
|
|
17
|
+
args;
|
|
18
|
+
/** Optional root cause. */
|
|
19
|
+
cause;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
super(opts.cause instanceof Error ? opts.cause.message : opts.code);
|
|
22
|
+
this.name = 'CliError';
|
|
23
|
+
this.code = opts.code;
|
|
24
|
+
this.exitCode = opts.exitCode;
|
|
25
|
+
this.args = opts.args ?? {};
|
|
26
|
+
this.cause = opts.cause;
|
|
27
|
+
}
|
|
28
|
+
/** Parse / validation failures (exit code 2). */
|
|
29
|
+
static parseError(code, args = {}) {
|
|
30
|
+
return new CliError({ code, exitCode: 2, args });
|
|
31
|
+
}
|
|
32
|
+
/** Factory / configuration failures (exit code 1). */
|
|
33
|
+
static configError(code, args = {}) {
|
|
34
|
+
return new CliError({ code, exitCode: 1, args });
|
|
35
|
+
}
|
|
36
|
+
/** Wrap an arbitrary exception as a CliError (exit code 1). */
|
|
37
|
+
static from(err, fallbackCode = 'commander::action_failed') {
|
|
38
|
+
if (err instanceof CliError)
|
|
39
|
+
return err;
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
// 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
|
|
42
|
+
return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message, message } });
|
|
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
|
}
|