@jacebenson/jsn 4.3.7-dev.39 → 4.3.7-dev.41

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.39",
3
+ "version": "4.3.7-dev.41",
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": {
@@ -24,11 +24,38 @@ class CommandNode {
24
24
  if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
25
25
  }
26
26
 
27
+ /**
28
+ * Register a child, replacing any existing registration that claims the same
29
+ * canonical name (or that the new child claims as an alias).
30
+ *
31
+ * yargs semantics are last-registration-wins: a command module re-registers a
32
+ * subcommand to layer a specialized handler over the base one (see
33
+ * `requests show`). Appending instead of replacing left the base registration
34
+ * first in the list and reachable by dispatch/help while the duplicate was
35
+ * invisible to the registry (child counts, inventory, completion).
36
+ *
37
+ * Registration order is preserved (the slot is replaced in place) and the
38
+ * replaced node's aliases are carried over so layered commands keep resolving
39
+ * through the alias that the base registration published.
40
+ */
41
+ register(child) {
42
+ const index = this.children.findIndex((existing) => existing.name === child.name
43
+ || existing.aliases.includes(child.name)
44
+ || child.aliases.includes(existing.name));
45
+ if (index === -1) {
46
+ this.children.push(child);
47
+ return child;
48
+ }
49
+ child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])];
50
+ this.children[index] = child;
51
+ return child;
52
+ }
53
+
27
54
  api() {
28
55
  const node = this;
29
56
  return {
30
57
  command(definition, builder, handler) {
31
- node.children.push(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
58
+ node.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
32
59
  return this;
33
60
  },
34
61
  option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
@@ -63,10 +90,16 @@ export class LocalCLI {
63
90
  option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
64
91
  options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
65
92
  command(definition, builder, handler) {
66
- const node = new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition);
67
- this.root.children.push(node);
93
+ this.root.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
68
94
  return this;
69
95
  }
96
+ /**
97
+ * Register the fallback used when a parent command (a node with children) is
98
+ * invoked with no subcommand and declares no handler of its own. Without it
99
+ * those parents exited 0 having printed nothing, which reads as success to a
100
+ * script and as a hang to a human.
101
+ */
102
+ onParentDiscovery(fn) { this.parentDiscovery = fn; return this; }
70
103
  demandCommand(count, message) { this.root.demand = { count, message }; return this; }
71
104
  completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
72
105
  help() { this.helpEnabled = true; return this; }
@@ -175,6 +208,54 @@ export class LocalCLI {
175
208
  if (this.failHandler) return this.failHandler(message, error);
176
209
  throw error || new Error(message);
177
210
  }
211
+ /**
212
+ * Describe the registered command surface: root commands (canonical name,
213
+ * aliases, describe, child names) and the global option declarations.
214
+ *
215
+ * This is the single source both the grouped root help and the machine
216
+ * discovery output are generated from, so neither can drift away from the
217
+ * commands that actually exist. Builders run here (idempotently) because a
218
+ * parent's children are only registered when its builder is invoked.
219
+ */
220
+ commandRegistry() {
221
+ const visible = (node) => node.name !== this.completionCommand;
222
+ const serialize = (node) => {
223
+ node.build();
224
+ return {
225
+ name: node.name,
226
+ command: node.definition.command || node.name,
227
+ aliases: [...node.aliases],
228
+ description: node.definition.describe || '',
229
+ subcommands: node.children.filter(visible).map((child) => child.name),
230
+ };
231
+ };
232
+ return {
233
+ commands: this.root.children.filter(visible).map(serialize),
234
+ options: [...this.root.options.values()].map((spec) => ({
235
+ name: spec.name,
236
+ alias: names(spec.alias),
237
+ description: spec.description || spec.describe || '',
238
+ choices: spec.choices || [],
239
+ type: spec.type || 'boolean',
240
+ })),
241
+ };
242
+ }
243
+
244
+ /** Structured child signatures for a parent node, built on demand. */
245
+ subcommandsOf(node) {
246
+ return node.children
247
+ .filter((child) => child.name !== this.completionCommand)
248
+ .map((child) => {
249
+ child.build();
250
+ return {
251
+ name: child.name,
252
+ command: child.definition.command || child.name,
253
+ aliases: [...child.aliases],
254
+ description: child.definition.describe || '',
255
+ };
256
+ });
257
+ }
258
+
178
259
  _help(node, path = []) {
179
260
  const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
180
261
  if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
@@ -232,6 +313,11 @@ export class LocalCLI {
232
313
  const parsed = this._parseOptions(commandArgs, path);
233
314
  if (!parsed) return;
234
315
  const argv = parsed.values;
316
+ // A parent command's own children are registry-derived, so a default
317
+ // handler can list exactly what is registered instead of repeating a
318
+ // hand-written list that drifts (see the records parent, which advertised
319
+ // an `inspect` description the registration disagreed with).
320
+ if (node.children.length) argv.subcommands = this.subcommandsOf(node);
235
321
  const specs = [...this._optionConfig(path).values()];
236
322
  const positions = positionSpec(node.definition.command || '').map((position) => ({
237
323
  ...position, ...node.positionals.get(position.name), name: position.name,
@@ -242,7 +328,13 @@ export class LocalCLI {
242
328
  for (const middleware of this.middlewares) await middleware(argv);
243
329
  for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
244
330
  if (node.definition.handler) await node.definition.handler(argv);
245
- else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
331
+ // A parent with no handler of its own used to fall through and exit 0
332
+ // with no output — indistinguishable from a command that ran and found
333
+ // nothing. The registered discovery hook (cli.js) prints the child
334
+ // surface instead: a JSON envelope in machine mode, a list for humans.
335
+ else if (path.length && argv.subcommands?.length && this.parentDiscovery) {
336
+ await this.parentDiscovery(argv, { path: path.map((n) => n.name), subcommands: argv.subcommands });
337
+ } else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
246
338
  }
247
339
  }
248
340
 
package/src/cli.js CHANGED
@@ -10,7 +10,8 @@ import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
12
  import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
13
- import { guardExit, exitWithError } from './errors.js';
13
+ import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
14
+ import { formatCommandList } from './helpers.js';
14
15
 
15
16
  // Command modules
16
17
  import { setupCmd } from './commands/setup.js';
@@ -73,6 +74,53 @@ import { midserversCmd } from './commands/midservers.js';
73
74
  import { diffCmd } from './commands/diff.js';
74
75
  import { graphqlCmd } from './commands/graphql.js';
75
76
 
77
+ /**
78
+ * Did the caller explicitly ask for machine output?
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.
97
+ *
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.
102
+ */
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 '';
115
+ }
116
+
117
+ /**
118
+ * Parent commands owned by another change. The adapter's discovery fallback
119
+ * (which gives a handler-less parent its child list instead of a silent exit 0)
120
+ * is skipped for these families so their own fix owns the behavior.
121
+ */
122
+ const DISCOVERY_OWNED_ELSEWHERE = new Set(['auth', 'perf', 'skill', 'groupmembers', 'grouproles']);
123
+
76
124
  function wrap(handler) {
77
125
  return async (argv) => {
78
126
  try {
@@ -435,14 +483,33 @@ export function buildCLI() {
435
483
  cliInstance.showCompletionScript();
436
484
  },
437
485
  })
486
+ .onParentDiscovery((argv, { path, subcommands }) => {
487
+ // 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.
492
+ if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
493
+ const canonical = path.join(' ');
494
+ 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 });
504
+ })
438
505
  .help('help', 'Show help')
439
506
  .version(false)
440
507
  .strictCommands()
441
508
  .strictOptions(false)
442
509
  .fail((msg, err) => {
443
510
  if (err) throw err;
511
+ const raw = process.argv.slice(2);
444
512
  if (msg === 'You must specify a command') {
445
- const raw = process.argv.slice(2);
446
513
  const hasLoneProfile = raw.some((a, i, arr) => {
447
514
  if (a !== '--profile' && a !== '-p') return false;
448
515
  const next = arr[i + 1];
@@ -458,11 +525,40 @@ export function buildCLI() {
458
525
  }
459
526
  process.exit(0);
460
527
  }
461
- process.stdout.write(renderHelp(loadConfig()) + '\n');
528
+ // No command is a discovery request, not a failure: the human view
529
+ // stays exactly as it was (exit 0 + grouped help), and an explicit
530
+ // --json gets the same surface as machine-readable data instead of
531
+ // prose that a parser cannot read.
532
+ if (explicitJsonRequest(raw)) {
533
+ app.output.setFormat('json');
534
+ const { commands } = cliInstance.commandRegistry();
535
+ app.ok({ commands }, {
536
+ summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
537
+ });
538
+ process.exit(0);
539
+ }
540
+ process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
462
541
  process.exit(0);
463
542
  }
464
- process.stderr.write(msg + '\n');
465
- process.exit(1);
543
+ // Every other failure here (unknown command, missing required argument,
544
+ // invalid choice/number) is a usage failure. Route it through the same
545
+ // renderer as handler and guard failures: exit 2, and the documented
546
+ // error envelope on stdout when the caller is in JSON mode. Previously
547
+ // this wrote raw prose to stderr and exited 1, so the exit code and the
548
+ // 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
+ }
554
+ let hint;
555
+ try {
556
+ const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
557
+ hint = `Run "jsn ${path ? `${path} ` : ''}--help" for usage.`;
558
+ } catch {
559
+ hint = 'Run "jsn --help" for the command list.';
560
+ }
561
+ exitWithError(new AppError(CodeUsage, msg, hint), { app });
466
562
  });
467
563
 
468
564
  // Snapshot the root command names (plus aliases) for the completion
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  assertSafeExactMatch,
10
10
  buildQuerySuffix,
11
+ formatCommandList,
11
12
  formatRecordForDisplay,
12
13
  getStringField,
13
14
  interactiveList,
@@ -351,6 +352,23 @@ export function cmdbCmd(wrap) {
351
352
  return {
352
353
  command: 'cmdb [subcommand]',
353
354
  describe: 'Query the Configuration Management Database (CMDB) — CIs and relationship traversal (read-only)',
355
+ // Parent discovery. Without a handler `jsn cmdb` exited 0 having printed
356
+ // nothing: a script reads that as success and a human as a hang. The child
357
+ // list comes from the registry (argv.subcommands), so it cannot drift from
358
+ // what is registered; the styled view is a side channel that JSON ignores.
359
+ handler: wrap(async (argv, app) => {
360
+ const subcommands = argv.subcommands || [];
361
+ const summary = 'Query the Configuration Management Database (CMDB): CI lookup and relationship traversal. Read-only.';
362
+ app.ok({
363
+ command: 'cmdb',
364
+ subcommands,
365
+ _formatted: formatCommandList({
366
+ title: summary,
367
+ commands: subcommands,
368
+ footer: 'Run "jsn cmdb <command> --help" for details.',
369
+ }),
370
+ }, { summary });
371
+ }),
354
372
  builder: (yargs) => {
355
373
  return yargs
356
374
  .command({
@@ -15,6 +15,7 @@ import { searchDocs, getDocByIdOrPath } from './search.js';
15
15
  import { serveDocs } from './serve.js';
16
16
  import { syncCommunity, listCommunityDocs } from './community.js';
17
17
  import { declareCapabilities } from '../../capabilities.js';
18
+ import { formatCommandList } from '../../helpers.js';
18
19
 
19
20
  declareCapabilities('docs', { noInstance: true, skipDailyChecks: true });
20
21
 
@@ -256,21 +257,22 @@ export function docsCmd(wrap) {
256
257
  .demandCommand(1, 'Specify an action: sync or list'),
257
258
  });
258
259
  },
259
- handler: () => {
260
- process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
261
- process.stdout.write('Available subcommands:\n');
262
- process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
263
- process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
264
- process.stdout.write(' search Full-text + semantic search across the local docs index\n');
265
- process.stdout.write(' show Display a full documentation page by id or path\n');
266
- process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
267
- process.stdout.write(' community Sync ServiceNow Community articles (hubs + linked posts) into the index\n');
268
- process.stdout.write('\n');
269
- process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
270
- process.stdout.write('\n');
271
- const dbPath = getDocsDbPath();
272
- process.stdout.write(`Docs DB: ${dbPath} (run "jsn docs status" for details, or query directly with sqlite3)\n`);
273
- process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
274
- },
260
+ handler: wrap(async (argv, app) => {
261
+ const subcommands = argv.subcommands || [];
262
+ const summary = 'jsn docs — ServiceNow documentation search (sync, status, search, show, serve).';
263
+ app.ok({
264
+ command: 'docs',
265
+ subcommands,
266
+ _formatted: formatCommandList({
267
+ title: summary,
268
+ commands: subcommands,
269
+ footer: [
270
+ 'Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.',
271
+ `Docs DB: ${getDocsDbPath()} (run "jsn docs status" for details, or query directly with sqlite3)`,
272
+ 'Run "jsn docs <command> --help" for details.',
273
+ ].join('\n'),
274
+ }),
275
+ }, { summary });
276
+ }),
275
277
  };
276
278
  }
@@ -6,6 +6,7 @@ import { inspectFlow } from '../flow-inspection.js';
6
6
  import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
7
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
8
  import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
+ import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES } from '../flow-triggers.js';
9
10
  import { testFlow } from '../flow-test.js';
10
11
  import { errUsage } from '../errors.js';
11
12
  import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
@@ -32,6 +33,18 @@ function flowInspectionAdapter(app) {
32
33
  };
33
34
  }
34
35
 
36
+ // The picker is driven by the instance's own trigger definitions, but this CLI
37
+ // can only create the families it has verified. State both plainly so a created
38
+ // flow never surprises the operator with a trigger that was not really set.
39
+ function triggerGuidance(trigger) {
40
+ return {
41
+ supported_families: [...SUPPORTED_TRIGGER_TYPES].sort(),
42
+ excluded_families: [...EXCLUDED_TRIGGER_TYPES.entries()].map(([type, reason]) => ({ type, reason })).sort((a, b) => a.type.localeCompare(b.type)),
43
+ created_trigger: trigger ? { id: trigger.id, type: trigger.type, name: trigger.name, triggerDefinitionId: trigger.triggerDefinitionId } : null,
44
+ note: 'The trigger picker lists only families this CLI can create and verify. Families the picker omits are reported in excluded_families.',
45
+ };
46
+ }
47
+
35
48
  declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'publish', 'test'] });
36
49
 
37
50
  function renderChecks(checks) {
@@ -281,10 +294,11 @@ export function flowsCmd(wrap) {
281
294
  })
282
295
  .command({
283
296
  command: 'create',
284
- describe: 'Guide draft flow creation in a terminal, or use a schema-driven JSON manifest',
297
+ describe: 'Create a draft flow: pick a trigger category then trigger in a terminal, or use a JSON manifest in scripts',
285
298
  builder: (y) => y
286
299
  .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
287
- .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' }),
300
+ .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
301
+ .option('scope', { type: 'string', describe: 'Transaction scope sys_id or global for the guided picker' }),
288
302
  handler: wrap(async (argv, app) => {
289
303
  app.requireInstance();
290
304
  const dataFile = argv.dataFile ?? argv['data-file'];
@@ -299,8 +313,8 @@ export function flowsCmd(wrap) {
299
313
  const result = await createFlowFromManifest(app.sdk, app.getEffectiveInstance(), manifest, {
300
314
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
301
315
  });
302
- app.ok(result, {
303
- summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
316
+ app.ok({ ...result, trigger_guidance: triggerGuidance(result.trigger) }, {
317
+ summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) with trigger ${result.trigger?.type ?? 'unknown'} (${result.trigger?.id ?? 'no trigger id'}) without activation`,
304
318
  breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the draft flow' }],
305
319
  });
306
320
  }),
@@ -1,4 +1,5 @@
1
1
  import { declareCapabilities } from '../capabilities.js';
2
+ import { formatCommandList } from '../helpers.js';
2
3
 
3
4
  function numberOrString(value) {
4
5
  const n = Number(value);
@@ -270,6 +271,22 @@ export function platformCmd(wrap) {
270
271
  return {
271
272
  command: 'platform',
272
273
  describe: 'Inspect read-only platform health and node statistics',
274
+ // Parent discovery. This parent had no handler, so `jsn platform` and
275
+ // `jsn platform --json` exited 0 with no output at all — silent success.
276
+ // Children come from the registry, not a second hand-written list.
277
+ handler: wrap(async (argv, app) => {
278
+ const subcommands = argv.subcommands || [];
279
+ const summary = 'Inspect read-only platform health and node statistics.';
280
+ app.ok({
281
+ command: 'platform',
282
+ subcommands,
283
+ _formatted: formatCommandList({
284
+ title: summary,
285
+ commands: subcommands,
286
+ footer: 'Run "jsn platform <command> --help" for details.',
287
+ }),
288
+ }, { summary });
289
+ }),
273
290
  builder: (y) => y.command({
274
291
  command: 'health',
275
292
  describe: 'Show cluster, queue, semaphore, and node health',
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
3
+ import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
 
6
6
  declareCapabilities('records', {
@@ -514,20 +514,24 @@ export function recordsCmd(wrap) {
514
514
  })
515
515
 
516
516
  },
517
- handler: (argv) => {
517
+ // Parent discovery through the output controller: the subcommand list is
518
+ // registry-derived (argv.subcommands), so `jsn records --json` returns a
519
+ // structured envelope instead of prose, and the descriptions can no longer
520
+ // describe a different surface than the registrations (the previous
521
+ // hand-written list advertised `inspect` as table schema/statistics).
522
+ handler: wrap(async (argv, app) => {
518
523
  if (argv._[1]) return; // a subcommand ran — its own handler handled it
519
- console.log('Query and manage records in any ServiceNow table.');
520
- console.log('');
521
- console.log('Available subcommands:');
522
- console.log(' list List records from a table (includes total by default)');
523
- console.log(' get Get a single record by sys_id (--attachments to include files)');
524
- console.log(' create Create a record');
525
- console.log(' update Update a record');
526
- console.log(' delete Delete a record');
527
- console.log(' aggregate Run Stats API aggregation with optional grouping');
528
- console.log(' inspect Inspect table schema and statistics');
529
- console.log('');
530
- console.log('Run "jsn records <command> --help" for details.');
531
- },
524
+ const subcommands = argv.subcommands || [];
525
+ const summary = 'Query and manage records in any ServiceNow table.';
526
+ app.ok({
527
+ command: 'records',
528
+ subcommands,
529
+ _formatted: formatCommandList({
530
+ title: summary,
531
+ commands: subcommands,
532
+ footer: 'Run "jsn records <command> --help" for details.',
533
+ }),
534
+ }, { summary });
535
+ }),
532
536
  };
533
537
  }
package/src/errors.js CHANGED
@@ -10,6 +10,7 @@ export const CodeAPI = 'api_error';
10
10
  export const CodeAmbiguous = 'ambiguous';
11
11
  export const CodeEmptyResult = 'empty_result';
12
12
  export const CodeConfirmationRequired = 'confirmation_required';
13
+ export const CodeExtractionNotFound = 'extraction_not_found';
13
14
 
14
15
  export class AppError extends Error {
15
16
  constructor(code, message, hint = '', status = 0, cause = null) {
@@ -118,6 +119,24 @@ export function errAmbiguous(resource, matches) {
118
119
  );
119
120
  }
120
121
 
122
+ /**
123
+ * Structured error for `--get <path>` resolving to nothing.
124
+ *
125
+ * An unresolvable path (typo, wrong envelope shape, out-of-range index) is a
126
+ * failure, not a value: printing `undefined` and exiting 0 made a misspelled
127
+ * extract look like a successful empty result until the next tool in the pipe
128
+ * tried to parse it. A path that resolves to a present null, false, 0, or ""
129
+ * is a real value and is returned unchanged — only an unresolved path errors.
130
+ */
131
+ export function errExtractionNotFound(path) {
132
+ return new AppError(
133
+ CodeExtractionNotFound,
134
+ `No value at --get path "${path}".`,
135
+ 'Check the path against the envelope (run the same command without --get, or with --json, to see its shape). '
136
+ + 'Present null, false, 0, and "" values are returned as-is; only an unresolvable path fails.'
137
+ );
138
+ }
139
+
121
140
  /**
122
141
  * Structured error for a destructive action that requires confirmation
123
142
  * but no human is available to answer (non-TTY, pipes, or AI agents).
@@ -152,12 +171,20 @@ export function isErrorCode(err, code) {
152
171
  *
153
172
  * Exit-code contract (observable behavior, pinned by tests):
154
173
  * not_found → 1 stderr, "Error (not_found): …" + identifier hint
155
- * usage / usage_error → 2 stderr, "Error (usage): …"
174
+ * extraction_not_found → 1 JSON envelope on stdout in JSON mode, else stderr
175
+ * usage / usage_error → 2 JSON envelope on stdout in JSON mode
176
+ * (machine consumers), else stderr "Error (usage): …"
156
177
  * system_error → 3 stderr, "Error (system): …"
157
178
  * confirmation_required → 1 JSON envelope on stdout in JSON mode
158
179
  * (machine consumers), else stderr + hint
159
180
  * anything else → 1 stderr, "Error: …"
160
181
  *
182
+ * The JSON envelope is the SAME shape for every failure that a machine
183
+ * consumer can hit — guardError(), the mutation middleware, parse-time
184
+ * failures routed by cli.js, and handler-thrown AppErrors all end up here, so
185
+ * `--json` never yields human prose on stdout and never yields a
186
+ * success-shaped payload for a failed command.
187
+ *
161
188
  * jsonMode resolution: prefer app.output.effectiveFormat() (respects
162
189
  * --json/--quiet/etc. shortcuts AND piped-stdout auto-detection); fall
163
190
  * back to argv.format for callers without an App (unit tests).
@@ -201,15 +228,35 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
201
228
  return { stream: 'stderr', text, exitCode: 1 };
202
229
  }
203
230
 
231
+ if (code === CodeExtractionNotFound) {
232
+ // Same contract as every other machine-consumable failure: a parseable
233
+ // envelope on stdout when the caller is in JSON mode, human text on
234
+ // stderr otherwise. Exit 1 — the command ran, the value was absent.
235
+ if (resolveJsonMode(app, argv) && app) {
236
+ return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
237
+ }
238
+ let text = `Error (${code}): ${err.message}\n`;
239
+ if (err.hint) text += `\nHint: ${err.hint}\n`;
240
+ return { stream: 'stderr', text, exitCode: 1 };
241
+ }
242
+
204
243
  // 'usage' is the legacy literal; CodeUsage ('usage_error') is the
205
244
  // exported constant. Both render as the usage class.
206
245
  if (code === CodeUsage || code === 'usage') {
207
- // Interactive command families can opt into machine-readable fail-fast errors
208
- // without changing the legacy stderr contract for other usage errors.
209
- if (err.jsonEnvelope === true && resolveJsonMode(app, argv) && app) {
246
+ // Usage failures were the last split-brain case: a parse-time failure
247
+ // exited 1 with raw prose, a middleware guard exited 2 with a JSON
248
+ // envelope, and a handler-thrown usage error exited 2 with prose — all
249
+ // for the same class of mistake. One renderer, one exit code (2), and a
250
+ // structured envelope whenever the caller is in JSON mode.
251
+ if (resolveJsonMode(app, argv) && app) {
210
252
  return { stream: 'stdout', text: null, exitCode: 2, appErr: true };
211
253
  }
212
- return { stream: 'stderr', text: `Error (usage): ${err.message}\n`, exitCode: 2 };
254
+ // Keep the actionable hint (the failing command's --help) in the human view
255
+ // too — it used to be dropped on this path while every other error class
256
+ // printed one.
257
+ let text = `Error (usage): ${err.message}\n`;
258
+ if (err.hint) text += `\nHint: ${err.hint}\n`;
259
+ return { stream: 'stderr', text, exitCode: 2 };
213
260
  }
214
261
 
215
262
  if (code === 'system_error') {