@vmz/commander 0.0.0 → 0.1.19

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 CHANGED
@@ -1,3 +1,63 @@
1
- # @vmz/commander
1
+ # `@vmz/commander`
2
2
 
3
- Placeholder package (0.0.0). Reserved for the VMZ project.
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
+ - **No product language packs** in this package
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createCli } from '@vmz/commander';
15
+
16
+ const cli = createCli('my-cli')
17
+ .locales(new URL('./locales', import.meta.url).pathname) // or absolute path
18
+ .intro('cli.intro')
19
+ .command('build', 'cli.cmd.build')
20
+ .option('--out-dir, -o <dir>', 'cli.opt.out-dir')
21
+ .action(async (options) => {
22
+ /* options._ positionals; options['out-dir'] … */
23
+ });
24
+
25
+ await cli.parse(process.argv);
26
+ ```
27
+
28
+ Locales layout (any product):
29
+
30
+ ```text
31
+ <localesRoot>/
32
+ locales.json # { defaultLocale, locales: [{ id }], fallback? }
33
+ en-US/*.json # flat or nested → dotted ids
34
+ zh-CN/*.json # optional
35
+ ```
36
+
37
+ `.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
38
+
39
+ ## API surface
40
+
41
+ | API | Role |
42
+ |-----|------|
43
+ | `createCli(name)` | Program name; help `{name}` |
44
+ | `.command` / `.option` / `.action` / `.passthrough` | Command tree |
45
+ | `.intro(id)` | Short banner only |
46
+ | `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
47
+ | `.option` on CLI | Global flags (merged into action options) |
48
+ | `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
49
+ | `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
50
+ | `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
51
+ | `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
52
+
53
+ `t` resolution: product catalog → `commander` English fallback → `{{id}}`.
54
+
55
+ ## Layering
56
+
57
+ ```text
58
+ @vmz/commander = CLI tree + locales loader + commander.* chrome
59
+ @vmz/vmz = product bin + command registration + locales/ content
60
+ @vmz/diagnostic = diagnostic layout (caller injects catalog / t)
61
+ ```
62
+
63
+ Do **not** create a separate `@vmz/i18n` package for official product strings.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `@vmz/commander` — i18n-first TypeScript CLI framework.
3
+ *
4
+ * Command / option second args are message ids. Natural language comes from a
5
+ * pluggable Localize plugin or `.locales(root)`. Framework chrome/err ids are
6
+ * `commander.*` with tiny English fallbacks — no product language packs here.
7
+ */
8
+ export type { CatalogLoader, LocaleCatalog, LocalizePlugin, } from './types.js';
9
+ export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
10
+ export type { CreateLocalizeFromLocalesOptions, LocalesManifest } from './locales.js';
11
+ import type { CatalogLoader, LocaleCatalog, LocalizePlugin } from './types.js';
12
+ /** Options bag passed to actions (`_` = positionals). */
13
+ export type ParsedOptions = Record<string, string | boolean | string[]> & {
14
+ _: string[];
15
+ };
16
+ /** Command action after argv is parsed. */
17
+ export type ActionHandler = (options: ParsedOptions, ...args: string[]) => void | number | Promise<void | number>;
18
+ /** Registered option (structure only). */
19
+ export type OptionDef = {
20
+ rawName: string;
21
+ helpId: string;
22
+ /** Canonical key in options bag (e.g. `out-dir`). */
23
+ key: string;
24
+ /** Long/short tokens without leading dashes (e.g. `out-dir`, `o`). */
25
+ aliases: string[];
26
+ /** True when the option takes a value (`<x>` / `[x]`). */
27
+ takesValue: boolean;
28
+ /** True when value is optional (`[x]`): bare flag ⇒ `true`. */
29
+ optionalValue: boolean;
30
+ /** True when the option may repeat (`--dirty <path>...` ⇒ `string[]`). */
31
+ repeatable: boolean;
32
+ };
33
+ /** Registered command node. */
34
+ export type CommandDef = {
35
+ rawName: string;
36
+ helpId: string;
37
+ /** Match tokens from `rawName` split on `|`. */
38
+ names: string[];
39
+ options: OptionDef[];
40
+ action?: ActionHandler;
41
+ children: CommandDef[];
42
+ /** If true, do not parse options; remaining argv is positionals / raw rest. */
43
+ passthrough: boolean;
44
+ };
45
+ export interface Command {
46
+ option(rawName: string, helpId: string): this;
47
+ action(handler: ActionHandler): this;
48
+ command(rawName: string, helpId: string): Command;
49
+ /** Leave remaining argv unparsed (for nested domain CLIs). */
50
+ passthrough(): this;
51
+ }
52
+ export interface Cli {
53
+ use(plugin: LocalizePlugin): this;
54
+ catalog(loader: CatalogLoader): this;
55
+ /**
56
+ * Load catalogs from a locales directory (`locales.json` + `<locale>/*.json`).
57
+ * Later `.use` overrides. Rebuilds at `parse` so `--locale` / env apply.
58
+ */
59
+ locales(root: string, opts?: {
60
+ envKeys?: string[];
61
+ }): this;
62
+ /** Global option (parsed before the command; merged into action options). */
63
+ option(rawName: string, helpId: string): this;
64
+ /**
65
+ * Optional short intro line(s) before derived command/option lists.
66
+ * Do **not** paste full usage here — help is derived from the command tree.
67
+ */
68
+ intro(introId: string): this;
69
+ /**
70
+ * @deprecated Use {@link intro}. Kept as alias so older call sites compile.
71
+ */
72
+ help(helpId: string): this;
73
+ command(rawName: string, helpId: string): Command;
74
+ parse(argv?: string[]): Promise<number>;
75
+ }
76
+ /** Derive command help from one node (children + options). */
77
+ export declare function formatCommandHelp(cliName: string, def: CommandDef, t: LocalizePlugin['t'], path: string[], rootOptions?: OptionDef[]): string;
78
+ /**
79
+ * Create a CLI named `name` (shown in usage).
80
+ */
81
+ export declare function createCli(name: string): Cli;
82
+ /**
83
+ * Dev/CI: every registered helpId must exist in `catalog` or commander English fallbacks.
84
+ */
85
+ export declare function assertCatalogCoverage(cli: Cli, catalog: LocaleCatalog): void;
86
+ /**
87
+ * Consume known root options; leave unknown flags and positionals in `rest`
88
+ * (unlike {@link parseOptions}, which throws on unknown options).
89
+ */
90
+ export declare function peelKnownOptions(argv: string[], optionDefs: OptionDef[]): {
91
+ options: ParsedOptions;
92
+ rest: string[];
93
+ };
94
+ /** Strip `node` + script when callers pass full `process.argv`. */
95
+ export declare function normalizeArgv(argv: string[]): string[];
96
+ /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
97
+ export declare function parseOptionDef(rawName: string, helpId: string): OptionDef;
98
+ /**
99
+ * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
100
+ */
101
+ export declare function parseOptions(argv: string[], optionDefs: OptionDef[]): ParsedOptions;
package/dist/index.js ADDED
@@ -0,0 +1,514 @@
1
+ /**
2
+ * `@vmz/commander` — i18n-first TypeScript CLI framework.
3
+ *
4
+ * Command / option second args are message ids. Natural language comes from a
5
+ * pluggable Localize plugin or `.locales(root)`. Framework chrome/err ids are
6
+ * `commander.*` with tiny English fallbacks — no product language packs here.
7
+ */
8
+ export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
9
+ import { COMMANDER_FALLBACK_EN_US, createLocalizeFromLocales, translate, translateWithFallback } from './locales.js';
10
+ class CommandBuilder {
11
+ def;
12
+ constructor(rawName, helpId) {
13
+ assertHelpId(helpId, 'command');
14
+ this.def = {
15
+ rawName,
16
+ helpId,
17
+ names: splitNames(rawName),
18
+ options: [],
19
+ children: [],
20
+ passthrough: false,
21
+ };
22
+ }
23
+ option(rawName, helpId) {
24
+ assertHelpId(helpId, 'option');
25
+ this.def.options.push(parseOptionDef(rawName, helpId));
26
+ return this;
27
+ }
28
+ action(handler) {
29
+ this.def.action = handler;
30
+ return this;
31
+ }
32
+ passthrough() {
33
+ this.def.passthrough = true;
34
+ return this;
35
+ }
36
+ command(rawName, helpId) {
37
+ const child = new CommandBuilder(rawName, helpId);
38
+ this.def.children.push(child.def);
39
+ return child;
40
+ }
41
+ }
42
+ class CliBuilder {
43
+ name;
44
+ localize = null;
45
+ /** When set, `parse` rebuilds localize from this root (argv/env/`--locale`). */
46
+ localesRoot = null;
47
+ localesEnvKeys;
48
+ introId = null;
49
+ roots = [];
50
+ rootOptions = [];
51
+ constructor(name) {
52
+ this.name = name;
53
+ }
54
+ use(plugin) {
55
+ if (!plugin || typeof plugin.t !== 'function') {
56
+ throw new Error('@vmz/commander: LocalizePlugin.t is required');
57
+ }
58
+ this.localesRoot = null;
59
+ this.localesEnvKeys = undefined;
60
+ this.localize = wrapWithCommanderFallback(plugin);
61
+ return this;
62
+ }
63
+ catalog(loader) {
64
+ if (typeof loader !== 'function') {
65
+ throw new Error('@vmz/commander: catalog(loader) requires a function');
66
+ }
67
+ return this.use({
68
+ t: (id, args) => {
69
+ const table = loader('en-US');
70
+ if (table && typeof table.then === 'function') {
71
+ throw new Error('@vmz/commander: async CatalogLoader via .catalog() is not supported; use .use({ t })');
72
+ }
73
+ return translateWithFallback(id, args, table);
74
+ },
75
+ });
76
+ }
77
+ locales(root, opts = {}) {
78
+ if (!root || typeof root !== 'string') {
79
+ throw new Error('@vmz/commander: locales(root) requires a non-empty path');
80
+ }
81
+ this.localesRoot = root;
82
+ this.localesEnvKeys = opts.envKeys;
83
+ if (!this.rootOptions.some((o) => o.key === 'locale')) {
84
+ this.rootOptions.push(parseOptionDef('--locale <id>', 'commander.opt.locale'));
85
+ }
86
+ // Eager plugin so missing-manifest fails early; parse rebuilds with argv.
87
+ this.localize = wrapWithCommanderFallback(createLocalizeFromLocales({ root, envKeys: opts.envKeys }));
88
+ return this;
89
+ }
90
+ option(rawName, helpId) {
91
+ assertHelpId(helpId, 'option');
92
+ this.rootOptions.push(parseOptionDef(rawName, helpId));
93
+ return this;
94
+ }
95
+ intro(introId) {
96
+ assertHelpId(introId, 'intro');
97
+ this.introId = introId;
98
+ return this;
99
+ }
100
+ help(helpId) {
101
+ return this.intro(helpId);
102
+ }
103
+ command(rawName, helpId) {
104
+ const cmd = new CommandBuilder(rawName, helpId);
105
+ this.roots.push(cmd);
106
+ return cmd;
107
+ }
108
+ /** Help ids registered on this CLI (for {@link assertCatalogCoverage}). */
109
+ collectHelpIds() {
110
+ const ids = [];
111
+ if (this.introId)
112
+ ids.push(this.introId);
113
+ for (const o of this.rootOptions)
114
+ ids.push(o.helpId);
115
+ const walk = (def) => {
116
+ ids.push(def.helpId);
117
+ for (const o of def.options)
118
+ ids.push(o.helpId);
119
+ for (const c of def.children)
120
+ walk(c);
121
+ };
122
+ for (const r of this.roots)
123
+ walk(r.def);
124
+ return [...new Set(ids)];
125
+ }
126
+ async parse(argvInput = process.argv) {
127
+ const argv0 = normalizeArgv(argvInput);
128
+ const { options: globalOpts, rest: argv } = peelKnownOptions(argv0, this.rootOptions);
129
+ let localize = this.localize;
130
+ if (this.localesRoot) {
131
+ const localeFlag = typeof globalOpts.locale === 'string' && globalOpts.locale ? globalOpts.locale : undefined;
132
+ localize = wrapWithCommanderFallback(createLocalizeFromLocales({
133
+ root: this.localesRoot,
134
+ locale: localeFlag,
135
+ argv: argv0,
136
+ env: process.env,
137
+ envKeys: this.localesEnvKeys,
138
+ }));
139
+ }
140
+ if (!localize) {
141
+ throw new Error(translate('commander.err.localize_required', undefined, COMMANDER_FALLBACK_EN_US));
142
+ }
143
+ const t = localize.t.bind(localize);
144
+ if (argv.length === 0 || isHelpToken(argv[0])) {
145
+ console.log(this.formatRootHelp(t));
146
+ return 0;
147
+ }
148
+ const cmdToken = argv[0];
149
+ const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
150
+ if (!matched) {
151
+ console.error(t('commander.err.unknown_command', { cmd: cmdToken }));
152
+ console.log(this.formatRootHelp(t));
153
+ return 1;
154
+ }
155
+ return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts);
156
+ }
157
+ /** Derived root help: optional intro + commands/options from the registration tree. */
158
+ formatRootHelp(t) {
159
+ const lines = [];
160
+ if (this.introId) {
161
+ lines.push(t(this.introId), '');
162
+ }
163
+ lines.push(t('commander.ui.usage', { name: this.name }), '', t('commander.ui.commands'));
164
+ for (const c of this.roots) {
165
+ lines.push(` ${c.def.rawName.padEnd(28)} ${t(c.def.helpId)}`);
166
+ }
167
+ const opts = [
168
+ ...this.rootOptions,
169
+ ...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
170
+ ];
171
+ if (opts.length) {
172
+ lines.push('', t('commander.ui.options'));
173
+ for (const o of opts) {
174
+ lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
175
+ }
176
+ }
177
+ return lines.join('\n');
178
+ }
179
+ async dispatch(def, rest, t, path, globalOpts) {
180
+ if (rest.length && isHelpToken(rest[0])) {
181
+ console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
182
+ return 0;
183
+ }
184
+ if (def.children.length && rest.length && !rest[0].startsWith('-')) {
185
+ const childTok = rest[0];
186
+ const child = def.children.find((c) => c.names.includes(childTok));
187
+ if (child) {
188
+ return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts);
189
+ }
190
+ if (!def.action) {
191
+ console.error(t('commander.err.unknown_command', { cmd: [...path, childTok].join(' ') }));
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;
199
+ }
200
+ let options;
201
+ try {
202
+ options = def.passthrough ? { _: rest.slice() } : parseOptions(rest, def.options);
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
+ }));
213
+ }
214
+ else {
215
+ console.error(msg);
216
+ }
217
+ console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
218
+ return 1;
219
+ }
220
+ for (const [k, v] of Object.entries(globalOpts)) {
221
+ if (k === '_')
222
+ continue;
223
+ if (options[k] === undefined)
224
+ options[k] = v;
225
+ }
226
+ const result = await def.action(options, ...options._);
227
+ if (typeof result === 'number')
228
+ return result;
229
+ return 0;
230
+ }
231
+ }
232
+ /** Derive command help from one node (children + options). */
233
+ export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
234
+ const lines = [`${cliName} ${path.join(' ')} — ${t(def.helpId)}`, ''];
235
+ 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
+ lines.push('');
241
+ }
242
+ const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
243
+ if (opts.length) {
244
+ lines.push(t('commander.ui.options'));
245
+ for (const o of opts) {
246
+ lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
247
+ }
248
+ }
249
+ return lines.join('\n').trimEnd();
250
+ }
251
+ /** Union options declared on root commands (dedupe by key, stable first-seen order). */
252
+ function collectRootOptions(roots) {
253
+ const seen = new Set();
254
+ const out = [];
255
+ for (const root of roots) {
256
+ for (const o of root.options) {
257
+ if (seen.has(o.key))
258
+ continue;
259
+ seen.add(o.key);
260
+ out.push(o);
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+ /**
266
+ * Create a CLI named `name` (shown in usage).
267
+ */
268
+ export function createCli(name) {
269
+ if (!name || typeof name !== 'string') {
270
+ throw new Error('@vmz/commander: createCli(name) requires a non-empty program name');
271
+ }
272
+ return new CliBuilder(name);
273
+ }
274
+ /**
275
+ * Dev/CI: every registered helpId must exist in `catalog` or commander English fallbacks.
276
+ */
277
+ export function assertCatalogCoverage(cli, catalog) {
278
+ const ids = typeof cli.collectHelpIds === 'function' ? cli.collectHelpIds() : [];
279
+ const missing = ids.filter((id) => !Object.prototype.hasOwnProperty.call(catalog, id) && !Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id));
280
+ if (missing.length) {
281
+ throw new Error(`@vmz/commander: catalog missing help ids:\n ${missing.sort().join('\n ')}`);
282
+ }
283
+ }
284
+ function wrapWithCommanderFallback(plugin) {
285
+ return {
286
+ resolveLocale: plugin.resolveLocale?.bind(plugin),
287
+ t: (id, args) => {
288
+ const fromPlugin = plugin.t(id, args);
289
+ if (fromPlugin !== `{{${id}}}`)
290
+ return fromPlugin;
291
+ if (Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id)) {
292
+ return translate(id, args, COMMANDER_FALLBACK_EN_US);
293
+ }
294
+ return fromPlugin;
295
+ },
296
+ };
297
+ }
298
+ /**
299
+ * Consume known root options; leave unknown flags and positionals in `rest`
300
+ * (unlike {@link parseOptions}, which throws on unknown options).
301
+ */
302
+ export function peelKnownOptions(argv, optionDefs) {
303
+ const byAlias = new Map();
304
+ for (const def of optionDefs) {
305
+ for (const a of def.aliases)
306
+ byAlias.set(a, def);
307
+ }
308
+ const options = { _: [] };
309
+ const rest = [];
310
+ for (let i = 0; i < argv.length; i++) {
311
+ const a = argv[i];
312
+ if (a === '--') {
313
+ rest.push(...argv.slice(i));
314
+ break;
315
+ }
316
+ if (a.startsWith('--')) {
317
+ const eq = a.indexOf('=');
318
+ const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
319
+ const def = byAlias.get(long);
320
+ if (!def) {
321
+ rest.push(a);
322
+ continue;
323
+ }
324
+ if (def.takesValue) {
325
+ if (eq !== -1) {
326
+ assignOption(options, def, a.slice(eq + 1));
327
+ }
328
+ else {
329
+ const next = argv[i + 1];
330
+ if (next != null && !next.startsWith('-')) {
331
+ assignOption(options, def, next);
332
+ i += 1;
333
+ }
334
+ else if (def.optionalValue) {
335
+ assignOption(options, def, true);
336
+ }
337
+ else {
338
+ throw new Error(`missing_value:--${long}`);
339
+ }
340
+ }
341
+ }
342
+ else {
343
+ assignOption(options, def, true);
344
+ }
345
+ continue;
346
+ }
347
+ if (a.startsWith('-') && a.length === 2) {
348
+ const short = a.slice(1);
349
+ const def = byAlias.get(short);
350
+ if (!def) {
351
+ rest.push(a);
352
+ continue;
353
+ }
354
+ if (def.takesValue) {
355
+ const next = argv[i + 1];
356
+ if (next != null && !next.startsWith('-')) {
357
+ assignOption(options, def, next);
358
+ i += 1;
359
+ }
360
+ else if (def.optionalValue) {
361
+ assignOption(options, def, true);
362
+ }
363
+ else {
364
+ throw new Error(`missing_value:-${short}`);
365
+ }
366
+ }
367
+ else {
368
+ assignOption(options, def, true);
369
+ }
370
+ continue;
371
+ }
372
+ rest.push(a);
373
+ }
374
+ return { options, rest };
375
+ }
376
+ /** Strip `node` + script when callers pass full `process.argv`. */
377
+ export function normalizeArgv(argv) {
378
+ if (argv.length >= 2 && looksLikeNode(argv[0]) && looksLikeScript(argv[1])) {
379
+ return argv.slice(2);
380
+ }
381
+ return argv.slice();
382
+ }
383
+ function looksLikeNode(token) {
384
+ const base = token.replace(/\\/g, '/').split('/').pop() || '';
385
+ return base === 'node' || base === 'node.exe' || base.startsWith('node');
386
+ }
387
+ function looksLikeScript(token) {
388
+ return /\.(c?js|mjs|ts)$/i.test(token) || token.includes(`${'node_modules'}`) || token.endsWith('vmz');
389
+ }
390
+ function isHelpToken(token) {
391
+ return token === 'help' || token === '-h' || token === '--help';
392
+ }
393
+ function splitNames(rawName) {
394
+ return rawName
395
+ .split('|')
396
+ .map((s) => s.trim())
397
+ .filter(Boolean);
398
+ }
399
+ function assertHelpId(helpId, kind) {
400
+ if (!helpId || typeof helpId !== 'string' || !helpId.trim()) {
401
+ throw new Error(`@vmz/commander: ${kind} helpId must be a non-empty catalog key`);
402
+ }
403
+ if (/\s/.test(helpId)) {
404
+ throw new Error(`@vmz/commander: ${kind} helpId must be a catalog key (no spaces); got ${JSON.stringify(helpId)}`);
405
+ }
406
+ }
407
+ /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
408
+ export function parseOptionDef(rawName, helpId) {
409
+ const repeatable = /\.\.\.\s*$/.test(rawName) || /\.\.\.>/.test(rawName) || /\.\.\.]/.test(rawName);
410
+ const optionalValue = /\[[^\]]+\]/.test(rawName);
411
+ const takesValue = optionalValue || /<[^>]+>/.test(rawName) || repeatable;
412
+ const cleaned = rawName
413
+ .replace(/\.\.\./g, '')
414
+ .replace(/<[^>]+>|\[[^\]]+\]/g, '')
415
+ .trim();
416
+ const parts = cleaned
417
+ .split(/[,\s]+/)
418
+ .map((p) => p.trim())
419
+ .filter(Boolean);
420
+ const aliases = [];
421
+ for (const p of parts) {
422
+ if (p.startsWith('--'))
423
+ aliases.push(p.slice(2));
424
+ else if (p.startsWith('-') && p.length > 1)
425
+ aliases.push(p.slice(1));
426
+ }
427
+ if (!aliases.length) {
428
+ throw new Error(`@vmz/commander: invalid option rawName ${JSON.stringify(rawName)}`);
429
+ }
430
+ const key = aliases.find((a) => a.length > 1) ?? aliases[0];
431
+ return { rawName, helpId, key, aliases, takesValue, optionalValue, repeatable };
432
+ }
433
+ /**
434
+ * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
435
+ */
436
+ export function parseOptions(argv, optionDefs) {
437
+ const byAlias = new Map();
438
+ for (const def of optionDefs) {
439
+ for (const a of def.aliases)
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;
504
+ }
505
+ function assignOption(out, def, value) {
506
+ if (def.repeatable) {
507
+ const cur = out[def.key];
508
+ const list = Array.isArray(cur) ? cur : [];
509
+ list.push(String(value));
510
+ out[def.key] = list;
511
+ return;
512
+ }
513
+ out[def.key] = value;
514
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Filesystem locales loading for `@vmz/commander`.
3
+ *
4
+ * Generic: any product passes a `localesRoot`. This package does **not** ship
5
+ * product language packs — only tiny English fallbacks for `commander.*` chrome/err ids.
6
+ */
7
+ import type { LocaleCatalog, LocalizePlugin } from './types.js';
8
+ export type LocalesManifest = {
9
+ defaultLocale: string;
10
+ locales: Array<{
11
+ id: string;
12
+ label?: string;
13
+ direction?: string;
14
+ }>;
15
+ fallback?: Record<string, string[]>;
16
+ };
17
+ /** Framework chrome / errors — products may override the same ids in their locales/. */
18
+ export declare const COMMANDER_FALLBACK_EN_US: LocaleCatalog;
19
+ export declare function loadLocalesManifest(root: string): LocalesManifest;
20
+ /**
21
+ * Flatten nested JSON (`{ a: { b: "x" } }` → `a.b`) or pass through flat catalogs.
22
+ */
23
+ export declare function flattenCatalog(node: unknown, prefix?: string): LocaleCatalog;
24
+ /**
25
+ * Load (and cache) a locale catalog. Missing locales fall back via manifest.fallback
26
+ * then `defaultLocale`.
27
+ */
28
+ export declare function loadCatalog(locale: string, root: string): LocaleCatalog;
29
+ /** Clear caches (tests). */
30
+ export declare function clearLocalesCache(): void;
31
+ export declare function translate(id: string, args: Record<string, string> | undefined, catalog: LocaleCatalog): string;
32
+ /**
33
+ * Resolve locale from `--locale` / env / manifest.
34
+ */
35
+ export declare function resolveLocale(opts: {
36
+ argv?: string[];
37
+ env?: NodeJS.ProcessEnv;
38
+ manifest: LocalesManifest;
39
+ /** Env keys checked in order (default: LOCALE, LANG, LC_ALL). */
40
+ envKeys?: string[];
41
+ }): string;
42
+ /**
43
+ * Catalog lookup with commander framework English fallbacks.
44
+ */
45
+ export declare function translateWithFallback(id: string, args: Record<string, string> | undefined, catalog: LocaleCatalog): string;
46
+ export type CreateLocalizeFromLocalesOptions = {
47
+ root: string;
48
+ locale?: string;
49
+ env?: NodeJS.ProcessEnv;
50
+ argv?: string[];
51
+ /** Extra env keys prepended (e.g. `VMZ_LOCALE` for products). */
52
+ envKeys?: string[];
53
+ catalog?: LocaleCatalog;
54
+ };
55
+ /**
56
+ * Build a LocalizePlugin from a locales directory.
57
+ */
58
+ export declare function createLocalizeFromLocales(opts: CreateLocalizeFromLocalesOptions): LocalizePlugin;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Filesystem locales loading for `@vmz/commander`.
3
+ *
4
+ * Generic: any product passes a `localesRoot`. This package does **not** ship
5
+ * product language packs — only tiny English fallbacks for `commander.*` chrome/err ids.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ /** Framework chrome / errors — products may override the same ids in their locales/. */
10
+ export const COMMANDER_FALLBACK_EN_US = {
11
+ 'commander.ui.usage': 'Usage: {name} <command> [options]',
12
+ 'commander.ui.commands': 'Commands:',
13
+ 'commander.ui.options': 'Options:',
14
+ 'commander.opt.locale': 'Locale id',
15
+ 'commander.err.unknown_command': 'unknown command `{cmd}`',
16
+ 'commander.err.unknown_option': 'unknown option `{option}`',
17
+ 'commander.err.missing_option_value': 'missing value for `{option}`',
18
+ 'commander.err.localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
19
+ };
20
+ const catalogCache = new Map();
21
+ const manifestCache = new Map();
22
+ export function loadLocalesManifest(root) {
23
+ const cached = manifestCache.get(root);
24
+ if (cached)
25
+ return cached;
26
+ const file = path.join(root, 'locales.json');
27
+ if (!existsSync(file)) {
28
+ throw new Error(`@vmz/commander: missing locales manifest at ${file}`);
29
+ }
30
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
31
+ if (!raw?.defaultLocale || !Array.isArray(raw.locales) || !raw.locales.length) {
32
+ throw new Error(`@vmz/commander: invalid locales manifest ${file}`);
33
+ }
34
+ manifestCache.set(root, raw);
35
+ return raw;
36
+ }
37
+ /**
38
+ * Flatten nested JSON (`{ a: { b: "x" } }` → `a.b`) or pass through flat catalogs.
39
+ */
40
+ export function flattenCatalog(node, prefix = '') {
41
+ const out = {};
42
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
43
+ return out;
44
+ for (const [key, value] of Object.entries(node)) {
45
+ const id = prefix ? `${prefix}.${key}` : key;
46
+ if (typeof value === 'string') {
47
+ out[id] = value;
48
+ }
49
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
50
+ Object.assign(out, flattenCatalog(value, id));
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function loadLocaleDir(root, localeId) {
56
+ const dir = path.join(root, localeId);
57
+ if (!existsSync(dir))
58
+ return {};
59
+ const out = {};
60
+ for (const name of readdirSync(dir).sort()) {
61
+ if (!name.endsWith('.json'))
62
+ continue;
63
+ const raw = JSON.parse(readFileSync(path.join(dir, name), 'utf8'));
64
+ Object.assign(out, flattenCatalog(raw));
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Load (and cache) a locale catalog. Missing locales fall back via manifest.fallback
70
+ * then `defaultLocale`.
71
+ */
72
+ export function loadCatalog(locale, root) {
73
+ const cacheKey = `${root}::${locale}`;
74
+ const hit = catalogCache.get(cacheKey);
75
+ if (hit)
76
+ return hit;
77
+ const manifest = loadLocalesManifest(root);
78
+ const chain = [locale, ...(manifest.fallback?.[locale] ?? []), manifest.defaultLocale];
79
+ const merged = {};
80
+ for (const id of [...new Set(chain)].reverse()) {
81
+ Object.assign(merged, loadLocaleDir(root, id));
82
+ }
83
+ if (!Object.keys(merged).length) {
84
+ throw new Error(`@vmz/commander: empty catalog for locale ${JSON.stringify(locale)} under ${root}`);
85
+ }
86
+ catalogCache.set(cacheKey, merged);
87
+ return merged;
88
+ }
89
+ /** Clear caches (tests). */
90
+ export function clearLocalesCache() {
91
+ catalogCache.clear();
92
+ manifestCache.clear();
93
+ }
94
+ export function translate(id, args, catalog) {
95
+ const template = catalog[id];
96
+ if (template == null)
97
+ return `{{${id}}}`;
98
+ return template.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (_m, name) => {
99
+ if (args && Object.prototype.hasOwnProperty.call(args, name)) {
100
+ return args[name] ?? '';
101
+ }
102
+ return `{${name}}`;
103
+ });
104
+ }
105
+ /**
106
+ * Resolve locale from `--locale` / env / manifest.
107
+ */
108
+ export function resolveLocale(opts) {
109
+ const env = opts.env ?? process.env;
110
+ const argv = opts.argv ?? [];
111
+ let fromFlag = '';
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const a = argv[i];
114
+ if (a === '--locale' && argv[i + 1] && !argv[i + 1].startsWith('-')) {
115
+ fromFlag = argv[i + 1];
116
+ break;
117
+ }
118
+ if (a.startsWith('--locale=')) {
119
+ fromFlag = a.slice('--locale='.length);
120
+ break;
121
+ }
122
+ }
123
+ const keys = opts.envKeys ?? ['LOCALE', 'LANG', 'LC_ALL'];
124
+ let fromEnv = '';
125
+ for (const k of keys) {
126
+ const v = env[k];
127
+ if (v) {
128
+ fromEnv = String(v).split('.')[0]?.replace(/_/g, '-') || '';
129
+ if (fromEnv)
130
+ break;
131
+ }
132
+ }
133
+ const raw = fromFlag || fromEnv;
134
+ if (!raw)
135
+ return opts.manifest.defaultLocale;
136
+ const lower = raw.toLowerCase();
137
+ const known = opts.manifest.locales.map((l) => l.id);
138
+ const exact = known.find((id) => id.toLowerCase() === lower);
139
+ if (exact)
140
+ return exact;
141
+ const lang = lower.split('-')[0];
142
+ const prefix = known.find((id) => id.toLowerCase().startsWith(lang));
143
+ if (prefix)
144
+ return prefix;
145
+ return opts.manifest.defaultLocale;
146
+ }
147
+ /**
148
+ * Catalog lookup with commander framework English fallbacks.
149
+ */
150
+ export function translateWithFallback(id, args, catalog) {
151
+ if (Object.prototype.hasOwnProperty.call(catalog, id)) {
152
+ return translate(id, args, catalog);
153
+ }
154
+ if (Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id)) {
155
+ return translate(id, args, COMMANDER_FALLBACK_EN_US);
156
+ }
157
+ return `{{${id}}}`;
158
+ }
159
+ /**
160
+ * Build a LocalizePlugin from a locales directory.
161
+ */
162
+ export function createLocalizeFromLocales(opts) {
163
+ const env = opts.env ?? process.env;
164
+ const argv = opts.argv ?? [];
165
+ const manifest = loadLocalesManifest(opts.root);
166
+ const locale = typeof opts.locale === 'string' && opts.locale ? opts.locale : resolveLocale({ argv, env, manifest, envKeys: opts.envKeys });
167
+ const catalog = opts.catalog ?? loadCatalog(locale, opts.root);
168
+ return {
169
+ resolveLocale: () => locale,
170
+ t: (id, args) => translateWithFallback(id, args, catalog),
171
+ };
172
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
3
+ */
4
+ /** Message id → template. Owned by the localize plugin / product, not this package. */
5
+ export type LocaleCatalog = Record<string, string>;
6
+ /** Load messages for one locale (sugar for building a {@link LocalizePlugin}). */
7
+ export type CatalogLoader = (locale: string) => LocaleCatalog | Promise<LocaleCatalog>;
8
+ /**
9
+ * Pluggable localization. Products and end users supply their own `t` / locale policy.
10
+ * This package never ships official product language packs.
11
+ */
12
+ export type LocalizePlugin = {
13
+ resolveLocale?: (ctx: {
14
+ argv: string[];
15
+ env: NodeJS.ProcessEnv;
16
+ }) => string;
17
+ t: (id: string, args?: Record<string, string>) => string;
18
+ };
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
3
+ */
4
+ export {};
package/package.json CHANGED
@@ -1,10 +1,38 @@
1
1
  {
2
2
  "name": "@vmz/commander",
3
- "version": "0.0.0",
4
- "description": "VMZ placeholder ?not for production use.",
3
+ "version": "0.1.19",
4
+ "type": "module",
5
+ "description": "i18n-first TypeScript CLI framework (command tree, locales loading, commander.* chrome)",
5
6
  "license": "MIT",
6
- "private": false,
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
7
15
  "files": [
16
+ "dist",
8
17
  "README.md"
9
- ]
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test": "node --import ../../../scripts/test/resolve-ts-from-js.mjs --test --experimental-strip-types ./tests/locales.test.ts"
25
+ },
26
+ "keywords": [
27
+ "vmz",
28
+ "cli",
29
+ "i18n"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/doki-land/vmz-framework.git"
37
+ }
10
38
  }