@vmz/commander 0.3.5 → 0.3.6
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 +67 -63
- package/dist/index.d.ts +10 -3
- package/dist/index.js +158 -122
- package/dist/locales.js +16 -3
- package/dist/types.d.ts +43 -0
- package/dist/types.js +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,63 +1,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
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
|
45
|
-
| `.
|
|
46
|
-
| `.
|
|
47
|
-
| `.
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
|
|
53
|
-
`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
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 prints the legacy message or code. Plain `Error` exceptions are wrapped as `commander::action_failed` during migration. Commander does not own diagnostic formatting; products should connect `@vmz/diagnostic`.
|
|
58
|
+
|
|
59
|
+
## Layering
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
@vmz/commander = CLI tree + locales loader + commander.* chrome
|
|
63
|
+
@vmz/vmz = product bin + command registration + locales/ content
|
|
64
|
+
@vmz/diagnostic = diagnostic layout (caller injects catalog / t)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Do **not** create a separate `@vmz/i18n` package for official product strings.
|
package/dist/index.d.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
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, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin } 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, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
|
|
12
13
|
/** Options bag passed to actions (`_` = positionals). */
|
|
13
14
|
export type ParsedOptions = Record<string, string | boolean | string[]> & {
|
|
14
15
|
_: string[];
|
|
@@ -71,6 +72,8 @@ export interface Cli {
|
|
|
71
72
|
*/
|
|
72
73
|
help(helpId: string): this;
|
|
73
74
|
command(rawName: string, helpId: string): Command;
|
|
75
|
+
/** Route structured action failures to a product-owned renderer. */
|
|
76
|
+
onDiagnostic(handler: DiagnosticHandler): this;
|
|
74
77
|
parse(argv?: string[]): Promise<number>;
|
|
75
78
|
}
|
|
76
79
|
/** Derive command help from one node (children + options). */
|
|
@@ -96,6 +99,10 @@ export declare function normalizeArgv(argv: string[]): string[];
|
|
|
96
99
|
/** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
|
|
97
100
|
export declare function parseOptionDef(rawName: string, helpId: string): OptionDef;
|
|
98
101
|
/**
|
|
99
|
-
* Parse argv against registered options
|
|
102
|
+
* Parse argv against registered options (strict mode).
|
|
103
|
+
*
|
|
104
|
+
* Throws {@link CliError} with stable code `commander::unknown_option` /
|
|
105
|
+
* `commander::missing_option_value` (exit code 2) on validation failure.
|
|
106
|
+
* Non-option tokens become positionals under `options._`.
|
|
100
107
|
*/
|
|
101
108
|
export declare function parseOptions(argv: string[], optionDefs: OptionDef[]): ParsedOptions;
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,34 @@
|
|
|
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
|
+
return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
|
|
29
|
+
}
|
|
30
|
+
function paint(value, ...colors) {
|
|
31
|
+
return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
|
|
32
|
+
}
|
|
33
|
+
function errorText(value) {
|
|
34
|
+
return paint(value, 'bold', 'red');
|
|
35
|
+
}
|
|
10
36
|
class CommandBuilder {
|
|
11
37
|
def;
|
|
12
38
|
constructor(rawName, helpId) {
|
|
@@ -45,6 +71,7 @@ class CliBuilder {
|
|
|
45
71
|
/** When set, `parse` rebuilds localize from this root (argv/env/`--locale`). */
|
|
46
72
|
localesRoot = null;
|
|
47
73
|
localesEnvKeys;
|
|
74
|
+
diagnosticHandler;
|
|
48
75
|
introId = null;
|
|
49
76
|
roots = [];
|
|
50
77
|
rootOptions = [];
|
|
@@ -53,7 +80,7 @@ class CliBuilder {
|
|
|
53
80
|
}
|
|
54
81
|
use(plugin) {
|
|
55
82
|
if (!plugin || typeof plugin.t !== 'function') {
|
|
56
|
-
throw
|
|
83
|
+
throw CliError.configError('commander::config::invalid_localize_plugin', { detail: 'LocalizePlugin.t is required' });
|
|
57
84
|
}
|
|
58
85
|
this.localesRoot = null;
|
|
59
86
|
this.localesEnvKeys = undefined;
|
|
@@ -62,13 +89,13 @@ class CliBuilder {
|
|
|
62
89
|
}
|
|
63
90
|
catalog(loader) {
|
|
64
91
|
if (typeof loader !== 'function') {
|
|
65
|
-
throw
|
|
92
|
+
throw CliError.configError('commander::config::catalog_loader_not_function');
|
|
66
93
|
}
|
|
67
94
|
return this.use({
|
|
68
95
|
t: (id, args) => {
|
|
69
96
|
const table = loader('en-US');
|
|
70
97
|
if (table && typeof table.then === 'function') {
|
|
71
|
-
throw
|
|
98
|
+
throw CliError.configError('commander::config::async_catalog_unsupported');
|
|
72
99
|
}
|
|
73
100
|
return translateWithFallback(id, args, table);
|
|
74
101
|
},
|
|
@@ -76,7 +103,7 @@ class CliBuilder {
|
|
|
76
103
|
}
|
|
77
104
|
locales(root, opts = {}) {
|
|
78
105
|
if (!root || typeof root !== 'string') {
|
|
79
|
-
throw
|
|
106
|
+
throw CliError.configError('commander::config::invalid_locales_root');
|
|
80
107
|
}
|
|
81
108
|
this.localesRoot = root;
|
|
82
109
|
this.localesEnvKeys = opts.envKeys;
|
|
@@ -105,6 +132,12 @@ class CliBuilder {
|
|
|
105
132
|
this.roots.push(cmd);
|
|
106
133
|
return cmd;
|
|
107
134
|
}
|
|
135
|
+
onDiagnostic(handler) {
|
|
136
|
+
if (typeof handler !== 'function')
|
|
137
|
+
throw CliError.configError('commander::config::diagnostic_handler_not_function');
|
|
138
|
+
this.diagnosticHandler = handler;
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
108
141
|
/** Help ids registered on this CLI (for {@link assertCatalogCoverage}). */
|
|
109
142
|
collectHelpIds() {
|
|
110
143
|
const ids = [];
|
|
@@ -138,9 +171,13 @@ class CliBuilder {
|
|
|
138
171
|
}));
|
|
139
172
|
}
|
|
140
173
|
if (!localize) {
|
|
141
|
-
throw
|
|
174
|
+
throw CliError.configError('commander::config::localize_required');
|
|
142
175
|
}
|
|
143
176
|
const t = localize.t.bind(localize);
|
|
177
|
+
const diagnosticContext = {
|
|
178
|
+
locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
|
|
179
|
+
t,
|
|
180
|
+
};
|
|
144
181
|
if (argv.length === 0 || isHelpToken(argv[0])) {
|
|
145
182
|
console.log(this.formatRootHelp(t));
|
|
146
183
|
return 0;
|
|
@@ -148,11 +185,25 @@ class CliBuilder {
|
|
|
148
185
|
const cmdToken = argv[0];
|
|
149
186
|
const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
|
|
150
187
|
if (!matched) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: cmdToken } }, diagnosticContext, t, this.formatRootHelp(t), 2);
|
|
189
|
+
}
|
|
190
|
+
return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
|
|
191
|
+
}
|
|
192
|
+
/** Emit a diagnostic through `onDiagnostic` when registered, else console.error. */
|
|
193
|
+
emitDiagnostic(diag, ctx, t) {
|
|
194
|
+
if (this.diagnosticHandler) {
|
|
195
|
+
this.diagnosticHandler(diag, ctx);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const msg = diag.message ?? (diag.code ? t(diag.code, diag.args) : '');
|
|
199
|
+
console.error(errorText(msg || diag.code || String(diag)));
|
|
154
200
|
}
|
|
155
|
-
|
|
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;
|
|
156
207
|
}
|
|
157
208
|
/** Derived root help: optional intro + commands/options from the registration tree. */
|
|
158
209
|
formatRootHelp(t) {
|
|
@@ -160,23 +211,23 @@ class CliBuilder {
|
|
|
160
211
|
if (this.introId) {
|
|
161
212
|
lines.push(t(this.introId), '');
|
|
162
213
|
}
|
|
163
|
-
lines.push(t('commander.ui.usage', { name: this.name }), '', t('commander.ui.commands'));
|
|
214
|
+
lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan'), '', paint(t('commander.ui.commands'), 'bold', 'blue'));
|
|
164
215
|
for (const c of this.roots) {
|
|
165
|
-
lines.push(` ${c.def.rawName.padEnd(28)} ${t(c.def.helpId)}`);
|
|
216
|
+
lines.push(` ${paint(c.def.rawName.padEnd(28), 'green')} ${t(c.def.helpId)}`);
|
|
166
217
|
}
|
|
167
218
|
const opts = [
|
|
168
219
|
...this.rootOptions,
|
|
169
220
|
...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
|
|
170
221
|
];
|
|
171
222
|
if (opts.length) {
|
|
172
|
-
lines.push('', t('commander.ui.options'));
|
|
223
|
+
lines.push('', paint(t('commander.ui.options'), 'bold', 'blue'));
|
|
173
224
|
for (const o of opts) {
|
|
174
|
-
lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
|
|
225
|
+
lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
|
|
175
226
|
}
|
|
176
227
|
}
|
|
177
228
|
return lines.join('\n');
|
|
178
229
|
}
|
|
179
|
-
async dispatch(def, rest, t, path, globalOpts) {
|
|
230
|
+
async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
|
|
180
231
|
if (rest.length && isHelpToken(rest[0])) {
|
|
181
232
|
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
182
233
|
return 0;
|
|
@@ -185,37 +236,26 @@ class CliBuilder {
|
|
|
185
236
|
const childTok = rest[0];
|
|
186
237
|
const child = def.children.find((c) => c.names.includes(childTok));
|
|
187
238
|
if (child) {
|
|
188
|
-
return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts);
|
|
239
|
+
return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts, diagnosticContext);
|
|
189
240
|
}
|
|
190
241
|
if (!def.action) {
|
|
191
|
-
|
|
192
|
-
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
193
|
-
return 1;
|
|
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);
|
|
194
243
|
}
|
|
195
244
|
}
|
|
196
245
|
if (!def.action) {
|
|
246
|
+
if (rest.length) {
|
|
247
|
+
return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: [...path, ...rest].join(' ') } }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), 2);
|
|
248
|
+
}
|
|
197
249
|
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
198
|
-
return
|
|
250
|
+
return 0;
|
|
199
251
|
}
|
|
200
252
|
let options;
|
|
201
253
|
try {
|
|
202
254
|
options = def.passthrough ? { _: rest.slice() } : parseOptions(rest, def.options);
|
|
203
255
|
}
|
|
204
256
|
catch (err) {
|
|
205
|
-
const
|
|
206
|
-
|
|
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
|
-
}));
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
console.error(msg);
|
|
216
|
-
}
|
|
217
|
-
console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
|
|
218
|
-
return 1;
|
|
257
|
+
const cliErr = CliError.from(err, 'commander::parse_failed');
|
|
258
|
+
return this.emitAndHelp({ code: cliErr.code, severity: 'error', args: cliErr.args, message: cliErr.message }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), cliErr.exitCode);
|
|
219
259
|
}
|
|
220
260
|
for (const [k, v] of Object.entries(globalOpts)) {
|
|
221
261
|
if (k === '_')
|
|
@@ -223,27 +263,34 @@ class CliBuilder {
|
|
|
223
263
|
if (options[k] === undefined)
|
|
224
264
|
options[k] = v;
|
|
225
265
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
266
|
+
try {
|
|
267
|
+
const result = await def.action(options, ...options._);
|
|
268
|
+
if (typeof result === 'number')
|
|
269
|
+
return result;
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
const diagnostic = normalizeDiagnostic(err);
|
|
274
|
+
this.emitDiagnostic(diagnostic, diagnosticContext, t);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
230
277
|
}
|
|
231
278
|
}
|
|
232
279
|
/** Derive command help from one node (children + options). */
|
|
233
280
|
export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
|
|
234
|
-
const lines = [`${cliName} ${path.join(' ')} — ${t(def.helpId)}`, ''];
|
|
281
|
+
const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`, ''];
|
|
235
282
|
if (def.children.length) {
|
|
236
|
-
lines.push(t('commander.ui.commands'));
|
|
283
|
+
lines.push(paint(t('commander.ui.commands'), 'bold', 'blue'));
|
|
237
284
|
for (const c of def.children) {
|
|
238
|
-
lines.push(` ${c.rawName.padEnd(28)} ${t(c.helpId)}`);
|
|
285
|
+
lines.push(` ${paint(c.rawName.padEnd(28), 'green')} ${t(c.helpId)}`);
|
|
239
286
|
}
|
|
240
287
|
lines.push('');
|
|
241
288
|
}
|
|
242
289
|
const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
|
|
243
290
|
if (opts.length) {
|
|
244
|
-
lines.push(t('commander.ui.options'));
|
|
291
|
+
lines.push(paint(t('commander.ui.options'), 'bold', 'blue'));
|
|
245
292
|
for (const o of opts) {
|
|
246
|
-
lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
|
|
293
|
+
lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
|
|
247
294
|
}
|
|
248
295
|
}
|
|
249
296
|
return lines.join('\n').trimEnd();
|
|
@@ -267,7 +314,7 @@ function collectRootOptions(roots) {
|
|
|
267
314
|
*/
|
|
268
315
|
export function createCli(name) {
|
|
269
316
|
if (!name || typeof name !== 'string') {
|
|
270
|
-
throw
|
|
317
|
+
throw CliError.configError('commander::config::invalid_cli_name', { detail: String(name) });
|
|
271
318
|
}
|
|
272
319
|
return new CliBuilder(name);
|
|
273
320
|
}
|
|
@@ -278,7 +325,7 @@ export function assertCatalogCoverage(cli, catalog) {
|
|
|
278
325
|
const ids = typeof cli.collectHelpIds === 'function' ? cli.collectHelpIds() : [];
|
|
279
326
|
const missing = ids.filter((id) => !Object.prototype.hasOwnProperty.call(catalog, id) && !Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id));
|
|
280
327
|
if (missing.length) {
|
|
281
|
-
throw
|
|
328
|
+
throw CliError.configError('commander::config::catalog_missing_help_ids', { ids: missing.sort().join(', ') });
|
|
282
329
|
}
|
|
283
330
|
}
|
|
284
331
|
function wrapWithCommanderFallback(plugin) {
|
|
@@ -296,10 +343,15 @@ function wrapWithCommanderFallback(plugin) {
|
|
|
296
343
|
};
|
|
297
344
|
}
|
|
298
345
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
346
|
+
* Shared argv → { options, rest } parser used by both strict and permissive modes.
|
|
347
|
+
*
|
|
348
|
+
* - `strict: true` — throws {@link CliError} (exit code 2) on unknown option or
|
|
349
|
+
* missing required value. Used by {@link parseOptions} before dispatching to an action.
|
|
350
|
+
* - `strict: false` — unknown flags / bare tokens stay in `rest`. Used by
|
|
351
|
+
* {@link peelKnownOptions} so `.locales(root)` / other early-bound global options
|
|
352
|
+
* never reject argv that belongs to a later subcommand.
|
|
301
353
|
*/
|
|
302
|
-
|
|
354
|
+
function parseArgvCore(argv, optionDefs, strict) {
|
|
303
355
|
const byAlias = new Map();
|
|
304
356
|
for (const def of optionDefs) {
|
|
305
357
|
for (const a of def.aliases)
|
|
@@ -307,6 +359,20 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
307
359
|
}
|
|
308
360
|
const options = { _: [] };
|
|
309
361
|
const rest = [];
|
|
362
|
+
const handleMissing = (flagName) => {
|
|
363
|
+
if (strict) {
|
|
364
|
+
throw CliError.parseError('commander::missing_option_value', { option: flagName });
|
|
365
|
+
}
|
|
366
|
+
rest.push(flagName);
|
|
367
|
+
return true;
|
|
368
|
+
};
|
|
369
|
+
const handleUnknown = (token) => {
|
|
370
|
+
if (strict) {
|
|
371
|
+
throw CliError.parseError('commander::unknown_option', { option: token });
|
|
372
|
+
}
|
|
373
|
+
rest.push(token);
|
|
374
|
+
return true;
|
|
375
|
+
};
|
|
310
376
|
for (let i = 0; i < argv.length; i++) {
|
|
311
377
|
const a = argv[i];
|
|
312
378
|
if (a === '--') {
|
|
@@ -318,7 +384,8 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
318
384
|
const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
319
385
|
const def = byAlias.get(long);
|
|
320
386
|
if (!def) {
|
|
321
|
-
|
|
387
|
+
if (!handleUnknown(`--${long}`))
|
|
388
|
+
continue;
|
|
322
389
|
continue;
|
|
323
390
|
}
|
|
324
391
|
if (def.takesValue) {
|
|
@@ -335,7 +402,9 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
335
402
|
assignOption(options, def, true);
|
|
336
403
|
}
|
|
337
404
|
else {
|
|
338
|
-
|
|
405
|
+
if (!handleMissing(`--${long}`))
|
|
406
|
+
continue;
|
|
407
|
+
continue;
|
|
339
408
|
}
|
|
340
409
|
}
|
|
341
410
|
}
|
|
@@ -348,7 +417,8 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
348
417
|
const short = a.slice(1);
|
|
349
418
|
const def = byAlias.get(short);
|
|
350
419
|
if (!def) {
|
|
351
|
-
|
|
420
|
+
if (!handleUnknown(`-${short}`))
|
|
421
|
+
continue;
|
|
352
422
|
continue;
|
|
353
423
|
}
|
|
354
424
|
if (def.takesValue) {
|
|
@@ -361,7 +431,9 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
361
431
|
assignOption(options, def, true);
|
|
362
432
|
}
|
|
363
433
|
else {
|
|
364
|
-
|
|
434
|
+
if (!handleMissing(`-${short}`))
|
|
435
|
+
continue;
|
|
436
|
+
continue;
|
|
365
437
|
}
|
|
366
438
|
}
|
|
367
439
|
else {
|
|
@@ -373,6 +445,13 @@ export function peelKnownOptions(argv, optionDefs) {
|
|
|
373
445
|
}
|
|
374
446
|
return { options, rest };
|
|
375
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Consume known root options; leave unknown flags and positionals in `rest`
|
|
450
|
+
* (unlike {@link parseOptions}, which throws on unknown options).
|
|
451
|
+
*/
|
|
452
|
+
export function peelKnownOptions(argv, optionDefs) {
|
|
453
|
+
return parseArgvCore(argv, optionDefs, false);
|
|
454
|
+
}
|
|
376
455
|
/** Strip `node` + script when callers pass full `process.argv`. */
|
|
377
456
|
export function normalizeArgv(argv) {
|
|
378
457
|
if (argv.length >= 2 && looksLikeNode(argv[0]) && looksLikeScript(argv[1])) {
|
|
@@ -398,10 +477,10 @@ function splitNames(rawName) {
|
|
|
398
477
|
}
|
|
399
478
|
function assertHelpId(helpId, kind) {
|
|
400
479
|
if (!helpId || typeof helpId !== 'string' || !helpId.trim()) {
|
|
401
|
-
throw
|
|
480
|
+
throw CliError.configError('commander::config::help_id_empty', { kind });
|
|
402
481
|
}
|
|
403
482
|
if (/\s/.test(helpId)) {
|
|
404
|
-
throw
|
|
483
|
+
throw CliError.configError('commander::config::help_id_has_spaces', { kind, id: helpId });
|
|
405
484
|
}
|
|
406
485
|
}
|
|
407
486
|
/** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
|
|
@@ -425,82 +504,39 @@ export function parseOptionDef(rawName, helpId) {
|
|
|
425
504
|
aliases.push(p.slice(1));
|
|
426
505
|
}
|
|
427
506
|
if (!aliases.length) {
|
|
428
|
-
throw
|
|
507
|
+
throw CliError.configError('commander::config::invalid_option_raw', { raw: rawName });
|
|
429
508
|
}
|
|
430
509
|
const key = aliases.find((a) => a.length > 1) ?? aliases[0];
|
|
431
510
|
return { rawName, helpId, key, aliases, takesValue, optionalValue, repeatable };
|
|
432
511
|
}
|
|
512
|
+
function normalizeDiagnostic(error) {
|
|
513
|
+
if (error && typeof error === 'object') {
|
|
514
|
+
const value = error;
|
|
515
|
+
const candidate = value.diagnostic && typeof value.diagnostic === 'object' ? value.diagnostic : value;
|
|
516
|
+
if (typeof candidate.code === 'string' || typeof candidate.severity === 'string' || typeof candidate.path === 'string') {
|
|
517
|
+
return {
|
|
518
|
+
code: typeof candidate.code === 'string' ? candidate.code : undefined,
|
|
519
|
+
severity: typeof candidate.severity === 'string' ? candidate.severity : 'error',
|
|
520
|
+
args: candidate.args && typeof candidate.args === 'object' ? candidate.args : undefined,
|
|
521
|
+
path: typeof candidate.path === 'string' ? candidate.path : undefined,
|
|
522
|
+
span: candidate.span && typeof candidate.span === 'object' ? candidate.span : undefined,
|
|
523
|
+
message: typeof candidate.message === 'string' ? candidate.message : undefined,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { severity: 'error', code: 'commander::action_failed', message: error instanceof Error ? error.message : String(error) };
|
|
528
|
+
}
|
|
433
529
|
/**
|
|
434
|
-
* Parse argv against registered options
|
|
530
|
+
* Parse argv against registered options (strict mode).
|
|
531
|
+
*
|
|
532
|
+
* Throws {@link CliError} with stable code `commander::unknown_option` /
|
|
533
|
+
* `commander::missing_option_value` (exit code 2) on validation failure.
|
|
534
|
+
* Non-option tokens become positionals under `options._`.
|
|
435
535
|
*/
|
|
436
536
|
export function parseOptions(argv, optionDefs) {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
byAlias.set(a, def);
|
|
441
|
-
}
|
|
442
|
-
const out = { _: [] };
|
|
443
|
-
for (let i = 0; i < argv.length; i++) {
|
|
444
|
-
const a = argv[i];
|
|
445
|
-
if (a === '--') {
|
|
446
|
-
out._.push(...argv.slice(i + 1));
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
if (a.startsWith('--')) {
|
|
450
|
-
const eq = a.indexOf('=');
|
|
451
|
-
const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
452
|
-
const def = byAlias.get(long);
|
|
453
|
-
if (!def)
|
|
454
|
-
throw new Error(`unknown_option:--${long}`);
|
|
455
|
-
if (def.takesValue) {
|
|
456
|
-
if (eq !== -1) {
|
|
457
|
-
assignOption(out, def, a.slice(eq + 1));
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
const next = argv[i + 1];
|
|
461
|
-
if (next != null && !next.startsWith('-')) {
|
|
462
|
-
assignOption(out, def, next);
|
|
463
|
-
i += 1;
|
|
464
|
-
}
|
|
465
|
-
else if (def.optionalValue) {
|
|
466
|
-
assignOption(out, def, true);
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
throw new Error(`missing_value:--${long}`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
assignOption(out, def, true);
|
|
475
|
-
}
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
478
|
-
if (a.startsWith('-') && a.length === 2) {
|
|
479
|
-
const short = a.slice(1);
|
|
480
|
-
const def = byAlias.get(short);
|
|
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
|
-
}
|
|
495
|
-
}
|
|
496
|
-
else {
|
|
497
|
-
assignOption(out, def, true);
|
|
498
|
-
}
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
out._.push(a);
|
|
502
|
-
}
|
|
503
|
-
return out;
|
|
537
|
+
const { options, rest } = parseArgvCore(argv, optionDefs, true);
|
|
538
|
+
options._.push(...rest);
|
|
539
|
+
return options;
|
|
504
540
|
}
|
|
505
541
|
function assignOption(out, def, value) {
|
|
506
542
|
if (def.repeatable) {
|
package/dist/locales.js
CHANGED
|
@@ -6,16 +6,29 @@
|
|
|
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:',
|
|
14
15
|
'commander.opt.locale': 'Locale id',
|
|
16
|
+
// Legacy dotted ids (product catalogs may override these).
|
|
15
17
|
'commander.err.unknown_command': 'unknown command `{cmd}`',
|
|
16
18
|
'commander.err.unknown_option': 'unknown option `{option}`',
|
|
17
19
|
'commander.err.missing_option_value': 'missing value for `{option}`',
|
|
18
20
|
'commander.err.localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
|
|
21
|
+
// New stable `::` codes — used when the framework itself emits diagnostics.
|
|
22
|
+
'commander::unknown_command': 'unknown command `{cmd}`',
|
|
23
|
+
'commander::unknown_option': 'unknown option `{option}`',
|
|
24
|
+
'commander::missing_option_value': 'missing value for `{option}`',
|
|
25
|
+
'commander::parse_failed': 'parse failed: {detail}',
|
|
26
|
+
'commander::action_failed': 'action failed: {detail}',
|
|
27
|
+
'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
|
|
28
|
+
'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
|
|
29
|
+
'commander::config::missing_locales_manifest': 'missing locales manifest at {file}',
|
|
30
|
+
'commander::config::invalid_locales_manifest': 'invalid locales manifest at {file}',
|
|
31
|
+
'commander::config::empty_locale_catalog': 'empty catalog for locale {locale} under {root}',
|
|
19
32
|
};
|
|
20
33
|
const catalogCache = new Map();
|
|
21
34
|
const manifestCache = new Map();
|
|
@@ -25,11 +38,11 @@ export function loadLocalesManifest(root) {
|
|
|
25
38
|
return cached;
|
|
26
39
|
const file = path.join(root, 'locales.json');
|
|
27
40
|
if (!existsSync(file)) {
|
|
28
|
-
throw
|
|
41
|
+
throw CliError.configError('commander::config::missing_locales_manifest', { file });
|
|
29
42
|
}
|
|
30
43
|
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
44
|
if (!raw?.defaultLocale || !Array.isArray(raw.locales) || !raw.locales.length) {
|
|
32
|
-
throw
|
|
45
|
+
throw CliError.configError('commander::config::invalid_locales_manifest', { file });
|
|
33
46
|
}
|
|
34
47
|
manifestCache.set(root, raw);
|
|
35
48
|
return raw;
|
|
@@ -81,7 +94,7 @@ export function loadCatalog(locale, root) {
|
|
|
81
94
|
Object.assign(merged, loadLocaleDir(root, id));
|
|
82
95
|
}
|
|
83
96
|
if (!Object.keys(merged).length) {
|
|
84
|
-
throw
|
|
97
|
+
throw CliError.configError('commander::config::empty_locale_catalog', { locale, root });
|
|
85
98
|
}
|
|
86
99
|
catalogCache.set(cacheKey, merged);
|
|
87
100
|
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,22 @@ 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;
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
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
|
+
return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message } });
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED