@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.43",
3
+ "version": "4.3.7-dev.45",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- this.aliases = names(this.definition.aliases || this.definition.alias);
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
- child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])];
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
- _assign(argv, key, value, spec) {
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
- * Did the caller explicitly ask for machine output?
78
+ * The output format the caller asked for, resolved from PARSED argv.
79
79
  *
80
- * Read from the raw argv because parse-time failures happen BEFORE the format
81
- * middleware can run, so `--json`/`--format=json` had not reached the output
82
- * controller yet — which is exactly why the structured failure envelope was
83
- * unreachable for the mistakes scripts and agents hit most (a missing required
84
- * flag, a bad choice, an unknown command).
85
- */
86
- function explicitJsonRequest(args) {
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
- * Same reason as explicitJsonRequest: the format middleware never runs for a
99
- * parse-time failure, so an explicit `--format=styled` (human) or
100
- * `--format=json` (machine) was silently ignored and the failure was rendered
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 explicitFormat(args) {
104
- for (let i = 0; i < args.length; i += 1) {
105
- const arg = args[i];
106
- if (arg === '--json') return 'json';
107
- if (arg === '--quiet' || arg === '-q') return 'quiet';
108
- if (arg === '--styled') return 'styled';
109
- if (arg === '--markdown') return 'markdown';
110
- if (arg === '--csv') return 'csv';
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
- if (argv.json) argv.format = 'json';
205
- if (argv.quiet) argv.format = 'quiet';
206
- if (argv.styled) argv.format = 'styled';
207
- if (argv.markdown) argv.format = 'markdown';
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 (cmdb, platform, and any future parent) — the adapter used to
489
- // fall through and exit 0 having printed nothing. Children come from the
490
- // registry, description included, so this list cannot drift from the
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
- (argv.app || app).ok({
496
- command: canonical,
497
- subcommands,
498
- _formatted: formatCommandList({
499
- title: `jsn ${canonical}`,
500
- commands: subcommands,
501
- footer: `Run "jsn ${canonical} <command> --help" for details.`,
502
- }),
503
- }, { summary });
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 (explicitJsonRequest(raw)) {
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
- app.ok({ commands }, {
536
- summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
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 (explicitJsonRequest(raw)) app.output.setFormat('json');
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(' ');