@jacebenson/jsn 4.3.7-dev.43 → 4.3.7-dev.45
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/package.json +1 -1
- package/src/cli-adapter.js +67 -8
- package/src/cli.js +70 -64
package/package.json
CHANGED
package/src/cli-adapter.js
CHANGED
|
@@ -10,7 +10,11 @@ class CommandNode {
|
|
|
10
10
|
constructor(definition) {
|
|
11
11
|
this.definition = typeof definition === 'string' ? { command: definition } : definition;
|
|
12
12
|
this.name = commandName(this.definition.command || '');
|
|
13
|
-
|
|
13
|
+
// A command that lists its canonical name as its own alias publishes one
|
|
14
|
+
// token twice: the registry then has two entries for it and dispatch can
|
|
15
|
+
// resolve it through the alias table instead of the command table. An alias
|
|
16
|
+
// is an ALTERNATE name, so a self-alias is dropped at the source.
|
|
17
|
+
this.aliases = names(this.definition.aliases || this.definition.alias).filter((alias) => alias !== this.name);
|
|
14
18
|
this.children = [];
|
|
15
19
|
this.options = new Map();
|
|
16
20
|
this.positionals = new Map();
|
|
@@ -37,16 +41,34 @@ class CommandNode {
|
|
|
37
41
|
* Registration order is preserved (the slot is replaced in place) and the
|
|
38
42
|
* replaced node's aliases are carried over so layered commands keep resolving
|
|
39
43
|
* through the alias that the base registration published.
|
|
44
|
+
*
|
|
45
|
+
* A token must resolve to exactly ONE command. Two registrations can also
|
|
46
|
+
* collide without sharing a canonical name: an alias that another command
|
|
47
|
+
* already published (or already claims as its alias) is the same kind of
|
|
48
|
+
* claim, and registration order decides it — the later registration owns the
|
|
49
|
+
* token and the earlier one stops advertising it. The earlier command keeps
|
|
50
|
+
* its canonical name, so nothing becomes unreachable.
|
|
40
51
|
*/
|
|
41
52
|
register(child) {
|
|
42
53
|
const index = this.children.findIndex((existing) => existing.name === child.name
|
|
43
54
|
|| existing.aliases.includes(child.name)
|
|
44
55
|
|| child.aliases.includes(existing.name));
|
|
45
56
|
if (index === -1) {
|
|
57
|
+
for (const sibling of this.children) {
|
|
58
|
+
sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
|
|
59
|
+
}
|
|
46
60
|
this.children.push(child);
|
|
47
61
|
return child;
|
|
48
62
|
}
|
|
49
|
-
|
|
63
|
+
// Carry the replaced node's aliases over — minus any alias that is now the
|
|
64
|
+
// replacement's own canonical name, which would advertise the command as
|
|
65
|
+
// its own alias.
|
|
66
|
+
child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])]
|
|
67
|
+
.filter((alias) => alias !== child.name);
|
|
68
|
+
for (const sibling of this.children) {
|
|
69
|
+
if (sibling === this.children[index]) continue;
|
|
70
|
+
sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
|
|
71
|
+
}
|
|
50
72
|
this.children[index] = child;
|
|
51
73
|
return child;
|
|
52
74
|
}
|
|
@@ -157,14 +179,22 @@ export class LocalCLI {
|
|
|
157
179
|
return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
|
|
158
180
|
|| { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
|
|
159
181
|
}
|
|
160
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Assign one parsed value to argv.
|
|
184
|
+
*
|
|
185
|
+
* `validate: false` (tolerant mode, used by parseGlobalOptions) skips the
|
|
186
|
+
* choice/number checks so a value that will be thrown away anyway cannot
|
|
187
|
+
* abort a parse whose only purpose is reading the global output flags.
|
|
188
|
+
*/
|
|
189
|
+
_assign(argv, key, value, spec, opts = {}) {
|
|
190
|
+
const validate = opts.validate !== false;
|
|
161
191
|
const actual = spec?.name || key;
|
|
162
192
|
const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
|
|
163
|
-
if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
193
|
+
if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
164
194
|
this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
|
|
165
195
|
return false;
|
|
166
196
|
}
|
|
167
|
-
if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
|
|
197
|
+
if (validate && spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
|
|
168
198
|
this._fail(`Invalid number for --${actual}: ${value}`);
|
|
169
199
|
return false;
|
|
170
200
|
}
|
|
@@ -176,7 +206,7 @@ export class LocalCLI {
|
|
|
176
206
|
argv[camel(actual)] = argv[actual];
|
|
177
207
|
for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
|
|
178
208
|
}
|
|
179
|
-
_parseOptions(args, path) {
|
|
209
|
+
_parseOptions(args, path, opts = {}) {
|
|
180
210
|
const options = this._optionConfig(path);
|
|
181
211
|
const defs = {};
|
|
182
212
|
for (const [key, spec] of options) defs[key] = {
|
|
@@ -199,11 +229,40 @@ export class LocalCLI {
|
|
|
199
229
|
const argv = { _: path.map((n) => n.name) };
|
|
200
230
|
for (const [key, value] of Object.entries(parsed.values)) {
|
|
201
231
|
const spec = this._specFor(options, key, value);
|
|
202
|
-
if (this._assign(argv, key, value, spec) === false) return null;
|
|
232
|
+
if (this._assign(argv, key, value, spec, opts) === false) return null;
|
|
203
233
|
}
|
|
204
|
-
for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
|
|
234
|
+
for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec, opts);
|
|
205
235
|
return { values: argv, positionals: parsed.positionals };
|
|
206
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the global options from raw tokens WITHOUT dispatching a command and
|
|
240
|
+
* without failing.
|
|
241
|
+
*
|
|
242
|
+
* The format middleware never runs when parsing fails, so the parse-time
|
|
243
|
+
* failure renderer has to know the format the caller asked for. Rather than
|
|
244
|
+
* re-scanning argv with a second, approximate tokenizer (any `--json`
|
|
245
|
+
* anywhere, first `--format` wins, no option termination, no value
|
|
246
|
+
* consumption, no `--no-` negation), it asks the SAME parser the successful
|
|
247
|
+
* path uses. Tolerant mode keeps a value error (`--format xml`, the very
|
|
248
|
+
* failure being rendered) from aborting the resolution.
|
|
249
|
+
*
|
|
250
|
+
* Returns null when the tokens cannot be parsed at all — the caller then
|
|
251
|
+
* falls back to the pipe/TTY default, exactly as it did before.
|
|
252
|
+
*/
|
|
253
|
+
parseGlobalOptions(args) {
|
|
254
|
+
const saved = this.failHandler;
|
|
255
|
+
this.failHandler = null; // errors here are not the failure being reported
|
|
256
|
+
try {
|
|
257
|
+
const { path, commandArgs } = this._findPath(args);
|
|
258
|
+
const parsed = this._parseOptions(commandArgs, path, { validate: false });
|
|
259
|
+
return parsed ? parsed.values : null;
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
} finally {
|
|
263
|
+
this.failHandler = saved;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
207
266
|
_fail(message, error) {
|
|
208
267
|
if (this.failHandler) return this.failHandler(message, error);
|
|
209
268
|
throw error || new Error(message);
|
package/src/cli.js
CHANGED
|
@@ -75,43 +75,28 @@ import { diffCmd } from './commands/diff.js';
|
|
|
75
75
|
import { graphqlCmd } from './commands/graphql.js';
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* The output format the caller asked for, resolved from PARSED argv.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return args.some((arg, i) => {
|
|
88
|
-
if (arg === '--json') return true;
|
|
89
|
-
if (arg.startsWith('--json=')) return arg !== '--json=false';
|
|
90
|
-
if (arg === '--format=json') return true;
|
|
91
|
-
return arg === '--format' && args[i + 1] === 'json';
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The output format the caller explicitly asked for, read from the raw argv.
|
|
80
|
+
* One policy, two callers: the format middleware applies it once a command
|
|
81
|
+
* parses, and the parse-time failure handler applies it when parsing fails.
|
|
82
|
+
* They used to disagree — the failure path re-scanned the raw tokens (any
|
|
83
|
+
* `--json` anywhere, first `--format` match, no option termination, no values
|
|
84
|
+
* consumed by string options, no `--no-` negation), so the stream and the shape
|
|
85
|
+
* of a failure depended on which layer rejected the command instead of on the
|
|
86
|
+
* command line the caller typed.
|
|
97
87
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* by the pipe/TTY default instead of what was asked for.
|
|
88
|
+
* Precedence is the middleware's, unchanged: the shortcut flags beat `--format`,
|
|
89
|
+
* and among the shortcuts the one applied last wins
|
|
90
|
+
* (csv > markdown > styled > quiet > json).
|
|
102
91
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (arg.startsWith('--format=')) return arg.slice('--format='.length);
|
|
112
|
-
if (arg === '--format' && args[i + 1]) return args[i + 1];
|
|
113
|
-
}
|
|
114
|
-
return '';
|
|
92
|
+
export function resolveRequestedFormat(argv) {
|
|
93
|
+
if (!argv) return '';
|
|
94
|
+
if (argv.csv) return 'csv';
|
|
95
|
+
if (argv.markdown) return 'markdown';
|
|
96
|
+
if (argv.styled) return 'styled';
|
|
97
|
+
if (argv.quiet) return 'quiet';
|
|
98
|
+
if (argv.json) return 'json';
|
|
99
|
+
return typeof argv.format === 'string' ? argv.format : '';
|
|
115
100
|
}
|
|
116
101
|
|
|
117
102
|
/**
|
|
@@ -199,15 +184,14 @@ export function buildCLI() {
|
|
|
199
184
|
},
|
|
200
185
|
})
|
|
201
186
|
.middleware([
|
|
202
|
-
// Apply format option overrides
|
|
187
|
+
// Apply format option overrides. The resolution lives in
|
|
188
|
+
// resolveRequestedFormat so the parse-time failure path applies the
|
|
189
|
+
// identical policy instead of approximating it from raw tokens.
|
|
203
190
|
(argv) => {
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (argv.csv) argv.format = 'csv';
|
|
209
|
-
if (argv.format) {
|
|
210
|
-
app.output.setFormat(argv.format);
|
|
191
|
+
const requested = resolveRequestedFormat(argv);
|
|
192
|
+
if (requested) {
|
|
193
|
+
argv.format = requested;
|
|
194
|
+
app.output.setFormat(requested);
|
|
211
195
|
}
|
|
212
196
|
if (argv.get) {
|
|
213
197
|
app.output.setJqFilter(argv.get);
|
|
@@ -485,22 +469,30 @@ export function buildCLI() {
|
|
|
485
469
|
})
|
|
486
470
|
.onParentDiscovery((argv, { path, subcommands }) => {
|
|
487
471
|
// Reached only for a parent command that declares no default handler of
|
|
488
|
-
// its own (
|
|
489
|
-
// fall through and exit 0 having printed nothing.
|
|
490
|
-
// registry, description included, so this list
|
|
491
|
-
// registrations the way a hand-written one did.
|
|
472
|
+
// its own (records attachments, docs community, and any future parent) —
|
|
473
|
+
// the adapter used to fall through and exit 0 having printed nothing.
|
|
474
|
+
// Children come from the registry, description included, so this list
|
|
475
|
+
// cannot drift from the registrations the way a hand-written one did.
|
|
492
476
|
if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
|
|
493
477
|
const canonical = path.join(' ');
|
|
494
478
|
const summary = `jsn ${canonical} — available subcommands.`;
|
|
495
|
-
(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
479
|
+
// Same error boundary as every registered handler (AGENTS.md: handlers go
|
|
480
|
+
// through wrap()). Discovery runs outside any command handler, and the
|
|
481
|
+
// shared extractor throws on a `--get` path that does not resolve — an
|
|
482
|
+
// unwrapped callback rejected parse() into an entry point with no
|
|
483
|
+
// rejection handler, so a missing path printed a Node stack instead of the
|
|
484
|
+
// extraction_not_found envelope.
|
|
485
|
+
return wrap(async () => {
|
|
486
|
+
(argv.app || app).ok({
|
|
487
|
+
command: canonical,
|
|
488
|
+
subcommands,
|
|
489
|
+
_formatted: formatCommandList({
|
|
490
|
+
title: `jsn ${canonical}`,
|
|
491
|
+
commands: subcommands,
|
|
492
|
+
footer: `Run "jsn ${canonical} <command> --help" for details.`,
|
|
493
|
+
}),
|
|
494
|
+
}, { summary });
|
|
495
|
+
})(argv);
|
|
504
496
|
})
|
|
505
497
|
.help('help', 'Show help')
|
|
506
498
|
.version(false)
|
|
@@ -509,6 +501,13 @@ export function buildCLI() {
|
|
|
509
501
|
.fail((msg, err) => {
|
|
510
502
|
if (err) throw err;
|
|
511
503
|
const raw = process.argv.slice(2);
|
|
504
|
+
// A parse-time failure never reaches the format middleware, so resolve the
|
|
505
|
+
// requested format with the adapter's OWN tokenizer (same option specs,
|
|
506
|
+
// option termination, consumed values, repeated options, `--no-`
|
|
507
|
+
// negation) instead of re-scanning raw argv — one policy, one answer for
|
|
508
|
+
// this command line no matter which layer rejects it.
|
|
509
|
+
const formatArgv = cliInstance.parseGlobalOptions(raw);
|
|
510
|
+
const requestedFormat = resolveRequestedFormat(formatArgv);
|
|
512
511
|
if (msg === 'You must specify a command') {
|
|
513
512
|
const hasLoneProfile = raw.some((a, i, arr) => {
|
|
514
513
|
if (a !== '--profile' && a !== '-p') return false;
|
|
@@ -529,12 +528,23 @@ export function buildCLI() {
|
|
|
529
528
|
// stays exactly as it was (exit 0 + grouped help), and an explicit
|
|
530
529
|
// --json gets the same surface as machine-readable data instead of
|
|
531
530
|
// prose that a parser cannot read.
|
|
532
|
-
if (
|
|
531
|
+
if (requestedFormat === 'json') {
|
|
533
532
|
app.output.setFormat('json');
|
|
533
|
+
// `--get` is jsn's jq substitute for every JSON shape (AGENTS.md), so
|
|
534
|
+
// the root discovery envelope honours it too: extract when the path
|
|
535
|
+
// resolves, and fail with the structured envelope when it does not,
|
|
536
|
+
// instead of always dumping the whole envelope.
|
|
537
|
+
if (typeof formatArgv?.get === 'string' && formatArgv.get) {
|
|
538
|
+
app.output.setJqFilter(formatArgv.get);
|
|
539
|
+
}
|
|
534
540
|
const { commands } = cliInstance.commandRegistry();
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
541
|
+
try {
|
|
542
|
+
app.ok({ commands }, {
|
|
543
|
+
summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
|
|
544
|
+
});
|
|
545
|
+
} catch (discoveryError) {
|
|
546
|
+
exitWithError(discoveryError, { app });
|
|
547
|
+
}
|
|
538
548
|
process.exit(0);
|
|
539
549
|
}
|
|
540
550
|
process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
|
|
@@ -546,11 +556,7 @@ export function buildCLI() {
|
|
|
546
556
|
// error envelope on stdout when the caller is in JSON mode. Previously
|
|
547
557
|
// this wrote raw prose to stderr and exited 1, so the exit code and the
|
|
548
558
|
// output shape depended on which layer rejected the command.
|
|
549
|
-
if (
|
|
550
|
-
else {
|
|
551
|
-
const requestedFormat = explicitFormat(raw);
|
|
552
|
-
if (requestedFormat) app.output.setFormat(requestedFormat);
|
|
553
|
-
}
|
|
559
|
+
if (requestedFormat) app.output.setFormat(requestedFormat);
|
|
554
560
|
let hint;
|
|
555
561
|
try {
|
|
556
562
|
const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
|