@jacebenson/jsn 4.3.13 → 4.4.0-dev.114.1

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.
Files changed (80) hide show
  1. package/README.md +137 -0
  2. package/package.json +4 -3
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +12 -0
  9. package/src/cli.js +45 -9
  10. package/src/command-audience.js +38 -0
  11. package/src/commands/_generic.js +45 -28
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +24 -20
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/catalog-order.js +93 -0
  18. package/src/commands/catalog.js +8 -10
  19. package/src/commands/changes.js +2 -4
  20. package/src/commands/cmdb-search.js +38 -0
  21. package/src/commands/cmdb.js +2 -0
  22. package/src/commands/columns.js +2 -1
  23. package/src/commands/customizations.js +13 -7
  24. package/src/commands/discover.js +54 -0
  25. package/src/commands/docs/db.js +9 -1
  26. package/src/commands/docs/docs.js +25 -2
  27. package/src/commands/docs/ingest.js +20 -3
  28. package/src/commands/docs/install.js +86 -0
  29. package/src/commands/docs/locks.js +34 -0
  30. package/src/commands/docs/refresh.js +22 -2
  31. package/src/commands/docs/replace.js +30 -0
  32. package/src/commands/docs/snapshot-check.js +11 -0
  33. package/src/commands/docs/snapshot.js +130 -0
  34. package/src/commands/docs/sync.js +16 -6
  35. package/src/commands/forms.js +22 -97
  36. package/src/commands/history.js +57 -0
  37. package/src/commands/incidents.js +2 -4
  38. package/src/commands/knowledge.js +75 -0
  39. package/src/commands/midservers.js +117 -10
  40. package/src/commands/records.js +57 -104
  41. package/src/commands/requests.js +9 -15
  42. package/src/commands/rest.js +15 -2
  43. package/src/commands/scan.js +234 -0
  44. package/src/commands/scopes.js +1 -1
  45. package/src/commands/setup.js +46 -9
  46. package/src/commands/skill.js +73 -76
  47. package/src/commands/tasks.js +2 -4
  48. package/src/commands/tickets.js +20 -19
  49. package/src/commands/uipolicies.js +2 -27
  50. package/src/commands/updatesets.js +66 -13
  51. package/src/commands/upgrade.js +69 -0
  52. package/src/commands/version.js +14 -8
  53. package/src/constrained-query.js +20 -0
  54. package/src/context.js +6 -6
  55. package/src/customization-guidance.js +31 -0
  56. package/src/customizations-report.js +6 -0
  57. package/src/customizations.js +28 -13
  58. package/src/errors.js +23 -1
  59. package/src/essential-records.js +40 -0
  60. package/src/field-behavior.js +15 -0
  61. package/src/help.js +12 -2
  62. package/src/metadata/records.js +249 -0
  63. package/src/midserver-linux-remote.js +210 -0
  64. package/src/midserver-remote-recipe.js +49 -0
  65. package/src/midserver-remote.js +221 -0
  66. package/src/midserver-windows-remote.js +318 -0
  67. package/src/midserver-zip.js +2 -2
  68. package/src/mutation-context.js +74 -0
  69. package/src/mutation-history.js +75 -0
  70. package/src/mutations.js +3 -2
  71. package/src/output.js +34 -2
  72. package/src/record-command-profiles.js +70 -0
  73. package/src/record-profiles.js +93 -0
  74. package/src/records-engine.js +57 -0
  75. package/src/related-lists.js +153 -0
  76. package/src/resolve-record.js +50 -28
  77. package/src/scan.js +249 -0
  78. package/src/sdk-batch.js +130 -0
  79. package/src/sdk.js +157 -18
  80. package/src/update-set-xml.js +89 -0
@@ -1,8 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
- import { input, password as passwordPrompt, select } from '../prompts.js';
4
+ import { input, password as passwordPrompt, select, confirm } from '../prompts.js';
5
5
  import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
6
+ import { AppError, CodeConfirmationRequired } from '../errors.js';
7
+ import { prepareRemoteMidServer, installRemoteMidServer, startRemoteMidServer, stopRemoteMidServer, logsRemoteMidServer, followRemoteMidServer, removeRemoteMidServer } from '../midserver-remote.js';
8
+ import { prepareWindowsRemoteMidServer, installWindowsRemoteMidServer, startWindowsRemoteMidServer, stopWindowsRemoteMidServer, logsWindowsRemoteMidServer, removeWindowsRemoteMidServer } from '../midserver-windows-remote.js';
9
+ import { prepareLinuxRemoteMidServer, installLinuxRemoteMidServer, startLinuxRemoteMidServer, stopLinuxRemoteMidServer, logsLinuxRemoteMidServer, removeLinuxRemoteMidServer } from '../midserver-linux-remote.js';
10
+ import { downloadRemoteMidRecipe } from '../midserver-remote-recipe.js';
6
11
  import { FormatAuto } from '../output.js';
7
12
  import { declareCapabilities } from '../capabilities.js';
8
13
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
@@ -15,7 +20,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
15
20
  const DEFAULT_TIMEOUT = 120000;
16
21
  const DEFAULT_INTERVAL = 2000;
17
22
 
18
- declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
23
+ declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop', 'validate'] });
19
24
 
20
25
  function agentName(record) {
21
26
  const name = getStringField(record, 'name');
@@ -274,6 +279,13 @@ export function assertMidServerReady(record) {
274
279
  }
275
280
 
276
281
  async function promptMidCredentials(app, argv) {
282
+ if (argv.passwordStdin) {
283
+ if (!argv.username) throw new Error('--password-stdin requires --username');
284
+ const password = fs.readFileSync(0, 'utf8').replace(/\r?\n$/, '');
285
+ if (!password || Buffer.byteLength(password) > 65536 || password.includes('\0')) throw new Error('Invalid password input');
286
+ const validation = await validateMidCredentials(app.getEffectiveInstance(), argv.username, password);
287
+ return { username: argv.username, password, releaseName: validation.releaseName };
288
+ }
277
289
  if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
278
290
  let username = argv.username || '';
279
291
  while (true) {
@@ -344,7 +356,31 @@ function removeLocalMidServer(instanceURL, name, directory) {
344
356
  return true;
345
357
  }
346
358
 
359
+ function remoteDirectory(app, argv, name) {
360
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
361
+ const file = ['.jsn-mid-windows-remote.json', '.jsn-mid-linux-remote.json', '.jsn-mid-remote.json'].find(name => fs.existsSync(path.join(directory, name)));
362
+ if (!file) throw new Error('No owned remote MID metadata found');
363
+ const metadata = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
364
+ if (metadata.target !== argv.ssh || metadata.instanceURL !== app.getEffectiveInstance().replace(/\/$/, '') || metadata.name !== name) {
365
+ throw new Error('Remote MID target, instance or name does not match owned local metadata');
366
+ }
367
+ return directory;
368
+ }
369
+
370
+ function nativeRemote(directory) {
371
+ if (fs.existsSync(path.join(directory, '.jsn-mid-windows-remote.json'))) return { start: startWindowsRemoteMidServer, stop: stopWindowsRemoteMidServer, logs: logsWindowsRemoteMidServer, remove: removeWindowsRemoteMidServer };
372
+ if (fs.existsSync(path.join(directory, '.jsn-mid-linux-remote.json'))) return { start: startLinuxRemoteMidServer, stop: stopLinuxRemoteMidServer, logs: logsLinuxRemoteMidServer, remove: removeLinuxRemoteMidServer };
373
+ return null;
374
+ }
375
+
347
376
  async function showMidServerLogs(app, identifier, argv) {
377
+ if (argv.ssh) {
378
+ const directory = remoteDirectory(app, argv, identifier);
379
+ if (argv.follow && nativeRemote(directory)) throw new Error('Remote native logs currently support bounded --tail only');
380
+ if (argv.follow) return followRemoteMidServer({ directory, tail: argv.tail });
381
+ const text = (nativeRemote(directory)?.logs || logsRemoteMidServer)({ directory, tail: argv.tail });
382
+ return app.ok({ target: argv.ssh, text, _formatted: text }, { summary: 'Remote MID logs' });
383
+ }
348
384
  const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
349
385
  const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
350
386
  if (direct) {
@@ -420,7 +456,8 @@ export function midserversCmd(wrap) {
420
456
  builder: (b) => b
421
457
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
422
458
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
423
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
459
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
460
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
424
461
  handler: wrap(async (argv, app) => {
425
462
  await showMidServerLogs(app, argv.identifier, argv);
426
463
  }),
@@ -431,16 +468,19 @@ export function midserversCmd(wrap) {
431
468
  builder: (b) => b
432
469
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
433
470
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
434
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
471
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
472
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
435
473
  handler: wrap(async (argv, app) => {
436
474
  await showMidServerLogs(app, argv.identifier, argv);
437
475
  }),
438
476
  })
439
477
  .command({
440
478
  command: 'start <identifier>',
441
- describe: 'Start an owned direct MID (Windows: foreground console, no service)',
442
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
479
+ describe: 'Start an owned MID locally or over SSH (remote Windows uses a service)',
480
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
481
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
443
482
  handler: wrap(async (argv, app) => {
483
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.start || startRemoteMidServer)({ directory }), { summary: 'Remote MID start command completed' }); }
444
484
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
445
485
  if (!metadata) throw new Error('No owned direct MID installation found');
446
486
  if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
@@ -450,9 +490,11 @@ export function midserversCmd(wrap) {
450
490
  })
451
491
  .command({
452
492
  command: 'stop <identifier>',
453
- describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
454
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
493
+ describe: 'Stop an owned MID locally or over SSH (local Windows console uses Ctrl+C)',
494
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
495
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
455
496
  handler: wrap(async (argv, app) => {
497
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.stop || stopRemoteMidServer)({ directory }), { summary: 'Remote MID stop command completed' }); }
456
498
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
457
499
  if (!metadata) throw new Error('No owned direct MID installation found');
458
500
  app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
@@ -463,13 +505,45 @@ export function midserversCmd(wrap) {
463
505
  describe: 'Install and verify a MID Server',
464
506
  builder: (b) => b
465
507
  .option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
466
- .option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
508
+ .option('username', { type: 'string', describe: 'MID Server username' })
509
+ .option('password-stdin', { type: 'boolean', describe: 'Read MID password from stdin instead of a terminal prompt' })
510
+ .option('ssh', { type: 'string', describe: 'Install MID on a configured SSH target (Docker, native Windows service or Linux background process)' })
511
+ .option('native-platform', { type: 'string', choices: ['windows', 'linux'], default: 'windows', describe: 'Remote direct ZIP platform (Linux uses a user background process and needs Python 3)' })
467
512
  .option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
468
513
  .option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
469
514
  .option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
470
515
  .option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
471
516
  handler: wrap(async (argv, app) => {
472
517
  app.requireInstance();
518
+ if (argv.ssh) {
519
+ if (!argv.name) throw new Error('Remote installation requires --name');
520
+ const name = normalizeMidName(argv.name);
521
+ const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${name}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }));
522
+ if (existing.length) throw new Error('MID name already exists; refusing to adopt an existing MID');
523
+ const credentials = await promptMidCredentials(app, argv);
524
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
525
+ fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 });
526
+ const native = (argv.installMethod || argv['install-method']) === 'direct';
527
+ let installation;
528
+ if (native) {
529
+ const linux = argv.nativePlatform === 'linux';
530
+ const prepare = linux ? prepareLinuxRemoteMidServer : prepareWindowsRemoteMidServer;
531
+ const install = linux ? installLinuxRemoteMidServer : installWindowsRemoteMidServer;
532
+ const start = linux ? startLinuxRemoteMidServer : startWindowsRemoteMidServer;
533
+ prepare({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
534
+ install({ directory, releaseName: credentials.releaseName, username: credentials.username, password: credentials.password });
535
+ installation = start({ directory });
536
+ } else {
537
+ prepareRemoteMidServer({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
538
+ const context = await downloadRemoteMidRecipe(credentials.releaseName);
539
+ installation = installRemoteMidServer({ directory, context, username: credentials.username, password: credentials.password });
540
+ }
541
+ const kind = native ? (argv.nativePlatform === 'linux' ? 'remote-linux-background' : 'remote-windows-service') : 'remote-docker';
542
+ let record;
543
+ try { record = await waitForMidServerUp(app, name, Math.max(1, argv.waitTimeout), Math.max(500, argv.waitInterval)); }
544
+ catch { return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'pending' }, { summary: 'Remote MID started; registration is pending. Runtime and ownership metadata retained for logs or explicit teardown.' }); }
545
+ return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'up', validated: queueValue(record.validated) === 'true', record, runtime_state: installation.state }, { summary: 'Remote MID is Up; validation is a separate explicit operation' });
546
+ }
473
547
  const credentials = await promptMidCredentials(app, argv);
474
548
  const name = argv.name || (await input({
475
549
  message: 'MID Server name:',
@@ -580,8 +654,19 @@ export function midserversCmd(wrap) {
580
654
  aliases: ['delete'],
581
655
  describe: 'Remove a MID Server record (interactive when no identifier is given)',
582
656
  builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
583
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
657
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
658
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
584
659
  handler: wrap(async (argv, app) => {
660
+ if (argv.ssh) {
661
+ if (!argv.identifier) throw new Error('Remote removal requires the owned MID name');
662
+ const directory = remoteDirectory(app, argv, argv.identifier);
663
+ await confirmDelete(app, argv, `Remove owned MID '${argv.identifier}' from SSH target '${argv.ssh}' and delete its MID record`);
664
+ const removed = (nativeRemote(directory)?.remove || removeRemoteMidServer)({ directory });
665
+ const records = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${argv.identifier}`, sysparm_limit: '2', sysparm_fields: 'sys_id,name' }));
666
+ if (records.length > 1) throw new Error('Remote runtime removed; multiple MID records match. Inspect records before deleting any.');
667
+ if (records.length) await app.sdk.delete(AGENT_TABLE, unwrapSysId(records[0]));
668
+ return app.ok({ name: argv.identifier, target: argv.ssh, remote_runtime_removed: removed.state === 'removed', record_deleted: records.length === 1, metadata_directory: directory }, { summary: 'Owned remote MID runtime removed; nonsecret ownership receipt retained' });
669
+ }
585
670
  let record;
586
671
  if (argv.identifier) {
587
672
  try {
@@ -621,6 +706,28 @@ export function midserversCmd(wrap) {
621
706
  app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
622
707
  }),
623
708
  })
709
+ .command({
710
+ command: 'validate <identifier>',
711
+ describe: 'Explicitly trust a MID through the platform Validate action; never writes the validated flag directly',
712
+ builder: b => b.option('timeout', { type: 'number', default: 300000, describe: 'Validation verification timeout in milliseconds' }),
713
+ handler: wrap(async (argv, app) => {
714
+ const record = await resolveMidServer(app.sdk, argv.identifier);
715
+ assertMidServerUp(record);
716
+ if (!argv.force) {
717
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) throw new AppError(CodeConfirmationRequired, 'MID trust validation requires approval.', 'After approval, rerun with --force.');
718
+ if (!await confirm({ message: `Trust MID '${getStringField(record, 'name')}' using the platform Validate action?`, default: false })) return;
719
+ }
720
+ const cookies = await app.sdk._warmSession(), csrf = await app.sdk._getScriptsPageCSRF(cookies);
721
+ await app.sdk.rawRequest(`${app.sdk.baseURL}/xmlhttp.do`, { method: 'POST', headers: { Cookie: cookies, 'X-UserToken': csrf }, body: new URLSearchParams({ sysparm_processor: 'MIDServerAjax', sysparm_name: 'validateMID', sysparm_agent: unwrapSysId(record), sysparm_ck: csrf }).toString() });
722
+ const started = Date.now();
723
+ while (Date.now() - started < argv.timeout) {
724
+ const current = await resolveMidServer(app.sdk, argv.identifier);
725
+ if (queueValue(current.validated) === 'true') return app.ok({ name: getStringField(current, 'name'), validated: true }, { summary: 'MID trust validation verified' });
726
+ await new Promise(resolve => setTimeout(resolve, 2000));
727
+ }
728
+ throw new Error('MID validation was requested but not verified; inspect MID issues before retrying');
729
+ }),
730
+ })
624
731
  .command({
625
732
  command: 'execute [identifier]',
626
733
  describe: 'Execute a shell command through a MID Server',
@@ -1,10 +1,11 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
1
+ import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, verifyWriteBack } from '../helpers.js';
2
+ import { listRecords, recordListParams, getRecord, createRecord, updateRecord, deleteRecord } from '../records-engine.js';
3
+ import { unwrapSysId } from '../resolve-record.js';
4
+ import { errUsage } from '../errors.js';
4
5
  import { declareCapabilities } from '../capabilities.js';
5
6
 
6
7
  declareCapabilities('records', {
7
- mutationSubcommands: ['create', 'update', 'delete', 'bulk', 'attachments add'],
8
+ mutationSubcommands: ['create', 'update', 'delete', 'bulk'],
8
9
  });
9
10
 
10
11
  const tableDefaultColumns = {
@@ -67,6 +68,15 @@ function getDefaultColumns(table) {
67
68
  return tableDefaultColumns[table] || ['sys_id'];
68
69
  }
69
70
 
71
+ function recordLookup(argv) {
72
+ const sysId = argv['sys-id'];
73
+ if ((sysId === undefined) === (argv.identifier === undefined)) throw errUsage('Supply exactly one of --sys-id or --identifier.');
74
+ if (sysId !== undefined && argv.identifierField && argv.identifierField !== 'sys_id') throw errUsage('--sys-id cannot be combined with a different --identifier-field.');
75
+ return { table: argv.table, identifier: sysId ?? argv.identifier,
76
+ identifierField: sysId !== undefined ? 'sys_id' : (argv.identifierField || 'sys_id'),
77
+ displayValues: argv.displayValues, resource: 'Record' };
78
+ }
79
+
70
80
  export function recordsCmd(wrap) {
71
81
  return {
72
82
  command: 'records [subcommand]',
@@ -83,7 +93,19 @@ export function recordsCmd(wrap) {
83
93
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
84
94
  .option('limit', { type: 'number', default: 20, describe: 'Max records' })
85
95
  .option('offset', { type: 'number', default: 0, describe: 'Offset' })
86
- .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' }),
96
+ .option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
97
+ .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' })
98
+ .epilogue([
99
+ 'In an interactive terminal with automatic output, browse a picker (10 visible items).',
100
+ 'Use arrow keys to browse, type to search sys_id on the server, scroll to load more,',
101
+ 'and press Enter to select or Esc to cancel. --sys-id skips the picker.',
102
+ 'Use --json for noninteractive structured output; pipes and explicit formats also skip the picker.',
103
+ 'Example: jsn records list --table incident --query "active=true" --limit 5 --json',
104
+ 'JSON shape (abbreviated): {"ok":true,"data":{"records":[{"sys_id":"...","number":"INC0010001"}]},...}',
105
+ 'Records are at data.records; fields are directly on each record (e.g. record.number).',
106
+ 'Array only: jsn records list --table incident --get data.records',
107
+ 'Discover fields with: jsn columns --table incident',
108
+ ].join('\n')),
87
109
  handler: wrap(async (argv, app) => {
88
110
  const table = argv.table;
89
111
  const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
@@ -122,15 +144,10 @@ export function recordsCmd(wrap) {
122
144
  }
123
145
 
124
146
  // Text/table fallback (or sys-id direct lookup)
125
- const params = new URLSearchParams();
126
- params.set('sysparm_limit', argv['sys-id'] ? '1' : String(argv.limit));
127
- params.set('sysparm_offset', String(argv.offset));
128
- params.set('sysparm_display_value', 'all');
129
147
  const fields = resolveFieldsParam(columns);
130
- if (fields) params.set('sysparm_fields', fields);
131
- if (query) params.set('sysparm_query', query);
132
- const records = await app.sdk.list(table, params);
133
- const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
148
+ const params = recordListParams({ limit: argv['sys-id'] ? 1 : argv.limit, offset: argv.offset, fields, query });
149
+ const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues });
150
+ const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
134
151
 
135
152
  // include_counts: default opt-in on every list. Opt out per-profile
136
153
  // (config include_counts:false) or per-invocation (--no-count).
@@ -182,27 +199,23 @@ export function recordsCmd(wrap) {
182
199
  })
183
200
  .command({
184
201
  command: 'get',
185
- describe: 'Get a single record by sys_id',
202
+ describe: 'Get one record by sys_id or an explicit identifier field',
186
203
  builder: (y) => y
187
204
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
188
- .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
205
+ .option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
206
+ .option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
207
+ .option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
208
+ .option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
189
209
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
190
- .option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' }),
210
+ .option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
211
+ .epilogue('Exact lookup precedes sys_id prefix, then suffix. Ambiguous matches fail.\nExample: jsn records get --table incident --sys-id <sys_id> --json\nExample: jsn records get --table incident --identifier INC0010001 --identifier-field number --json'),
191
212
  handler: wrap(async (argv, app) => {
192
- assertSafeExactMatch(argv['sys-id']);
193
- const params = new URLSearchParams();
194
- params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
195
- params.set('sysparm_limit', '1');
196
- params.set('sysparm_display_value', 'true');
197
- if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', `sys_id,${argv.columns}`);
198
- const records = await app.sdk.list(argv.table, params);
199
- if (records.length === 0) {
200
- throw new Error(`Record not found: ${argv['sys-id']}`);
201
- }
202
- const record = records[0];
213
+ const lookup = recordLookup(argv);
214
+ const fields = argv.columns && argv.columns !== '*' ? argv.columns.split(',') : undefined;
215
+ const record = await getRecord(app.sdk, { ...lookup, fields, displayValue: 'true' });
203
216
  if (argv.attachments) {
204
217
  try {
205
- const atts = await app.sdk.listAttachments(argv['sys-id']);
218
+ const atts = await app.sdk.listAttachments(unwrapSysId(record));
206
219
  record._attachments = atts;
207
220
  } catch {
208
221
  record._attachments = [];
@@ -232,7 +245,7 @@ export function recordsCmd(wrap) {
232
245
  for (const w of warnings) {
233
246
  process.stderr.write(`⚠ ${w.hint}\n`);
234
247
  }
235
- const record = await app.sdk.create(argv.table, recordData);
248
+ const record = await createRecord(app.sdk, { table: argv.table, data: recordData });
236
249
  const sysID = getStringField(record, 'sys_id');
237
250
  const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
238
251
  for (const m of mismatches) {
@@ -249,7 +262,9 @@ export function recordsCmd(wrap) {
249
262
  describe: 'Update an existing record',
250
263
  builder: (y) => y
251
264
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
252
- .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
265
+ .option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
266
+ .option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
267
+ .option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
253
268
  .option('data', { type: 'string', describe: 'JSON fields' })
254
269
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
255
270
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
@@ -261,8 +276,10 @@ export function recordsCmd(wrap) {
261
276
  for (const w of warnings) {
262
277
  process.stderr.write(`⚠ ${w.hint}\n`);
263
278
  }
264
- const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
265
- const mismatches = await verifyWriteBack(app, argv.table, argv['sys-id'], recordData);
279
+ const lookup = recordLookup(argv);
280
+ const resolved = await getRecord(app.sdk, lookup);
281
+ const record = await updateRecord(app.sdk, { table: argv.table, record: resolved, data: recordData });
282
+ const mismatches = await verifyWriteBack(app, argv.table, unwrapSysId(resolved), recordData);
266
283
  for (const m of mismatches) {
267
284
  process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
268
285
  }
@@ -277,82 +294,17 @@ export function recordsCmd(wrap) {
277
294
  describe: 'Delete a record',
278
295
  builder: (y) => y
279
296
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
280
- .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
297
+ .option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
298
+ .option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
299
+ .option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
281
300
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
282
301
  handler: wrap(async (argv, app) => {
283
- await confirmDelete(app, argv, `Delete record from ${argv.table} (${argv['sys-id']})`);
284
- await app.sdk.delete(argv.table, argv['sys-id']);
285
- app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
302
+ const lookup = recordLookup(argv);
303
+ await confirmDelete(app, argv, `Delete record from ${argv.table} (${lookup.identifier})`);
304
+ const sysID = await deleteRecord(app.sdk, lookup);
305
+ app.ok({ message: 'Record deleted', table: argv.table, sys_id: sysID }, { summary: `Deleted record from ${argv.table}` });
286
306
  }),
287
307
  })
288
- .command({
289
- command: 'attachments',
290
- describe: 'Work with attachments on a record',
291
- builder: (y) => y
292
- .command({
293
- command: 'list',
294
- describe: 'List attachments on a record',
295
- builder: (yy) => yy
296
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
297
- .option('table', { type: 'string', describe: 'Parent table name (context only)' }),
298
- handler: wrap(async (argv, app) => {
299
- app.requireInstance();
300
- const attachments = await app.sdk.listAttachments(argv['sys-id']);
301
- app.ok({
302
- sys_id: argv['sys-id'],
303
- table: argv.table || '',
304
- count: attachments.length,
305
- attachments,
306
- context: { instance_url: app.getEffectiveInstance() },
307
- }, { summary: `${attachments.length} attachment(s) on record ${argv['sys-id']}` });
308
- }),
309
- })
310
- .command({
311
- command: 'get <attachment-id>',
312
- describe: 'Download an attachment to a local file',
313
- builder: (yy) => yy
314
- .positional('attachment-id', { describe: 'Attachment sys_id', type: 'string' })
315
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id (used to resolve the file name)' })
316
- .option('out', { alias: 'o', type: 'string', describe: 'Output file path (default: attachment file name in cwd)' }),
317
- handler: wrap(async (argv, app) => {
318
- app.requireInstance();
319
- const buf = await app.sdk.getAttachment(argv['attachment-id']);
320
- let name = argv.out;
321
- if (!name) {
322
- // Best-effort: derive the file name from the attachment row.
323
- let fileName = `${argv['attachment-id']}.bin`;
324
- try {
325
- const rows = await app.sdk.listAttachments(argv['sys-id']);
326
- const hit = (rows || []).find((a) => getStringField(a, 'sys_id') === argv['attachment-id']);
327
- const fn = getStringField(hit, 'file_name');
328
- if (fn) fileName = fn;
329
- } catch { /* keep default */ }
330
- name = fileName;
331
- }
332
- await fs.promises.writeFile(name, buf);
333
- app.ok({ sys_id: argv['attachment-id'], out: name, bytes: buf.length }, { summary: `Wrote ${buf.length} bytes to ${name}` });
334
- }),
335
- })
336
- .command({
337
- command: 'add <file>',
338
- describe: 'Upload a file as a new attachment on a record (mutation)',
339
- builder: (yy) => yy
340
- .positional('file', { describe: 'Local file path to upload', type: 'string' })
341
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
342
- .option('table', { type: 'string', demandOption: true, describe: 'Parent table for the record (e.g. incident)' })
343
- .option('name', { type: 'string', describe: 'Display file name (default: basename of --file)' })
344
- .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
345
- handler: wrap(async (argv, app) => {
346
- app.requireInstance();
347
- await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${argv['sys-id']}?`);
348
- const content = await fs.promises.readFile(argv.file);
349
- const fileName = argv.name || path.basename(argv.file);
350
- const created = await app.sdk.addAttachment(argv.table, argv['sys-id'], content, fileName);
351
- app.ok({ sys_id: created?.sys_id, file_name: fileName, table: argv.table, recordsys_id: argv['sys-id'] }, { summary: `Attached ${fileName}` });
352
- }),
353
- })
354
- .demandCommand(1, 'Specify an attachment action: list, get, or add'),
355
- })
356
308
  .command({
357
309
  command: 'aggregate',
358
310
  describe: 'Run ServiceNow Stats API aggregation with optional grouping',
@@ -360,6 +312,7 @@ export function recordsCmd(wrap) {
360
312
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
361
313
  .option('query', { type: 'string', describe: 'Encoded query to constrain the aggregation' })
362
314
  .option('group-by', { type: 'string', describe: 'Comma-separated fields to group by' })
315
+ .option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
363
316
  .option('count', { type: 'boolean', default: true, describe: 'Include record counts' })
364
317
  .option('avg-fields', { type: 'string', describe: 'Comma-separated fields to average' })
365
318
  .option('sum-fields', { type: 'string', describe: 'Comma-separated fields to sum' })
@@ -1,11 +1,12 @@
1
- import { buildTicketCommands } from './_ticket.js';
2
- import { getStringField, assertSafeExactMatch } from '../helpers.js';
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
2
+ import { getRecord, recordOptions } from '../records-engine.js';
3
+ import { unwrapSysId } from '../resolve-record.js';
4
+ import { buildProfileCommand } from '../record-profiles.js';
3
5
 
4
- const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
5
6
 
6
7
  export function requestsCmd(wrap) {
7
8
  // Get the base command definition from the shared ticket builder
8
- const baseCmd = buildTicketCommands('sc_req_item', 'requests', 'req', requestDefaultColumns, {}, null, wrap);
9
+ const baseCmd = buildProfileCommand('requests', wrap);
9
10
 
10
11
  // Override the builder to inject our enriched show handler.
11
12
  // yargs allows re-registration — the last .command() call wins the slot.
@@ -16,6 +17,7 @@ export function requestsCmd(wrap) {
16
17
  command: 'show <number>',
17
18
  aliases: ['get'],
18
19
  describe: 'Show a specific request with attachments and catalog variables',
20
+ builder: y => relatedListOptions(recordOptions(y)),
19
21
  handler: wrap(enrichedShowHandler),
20
22
  });
21
23
  };
@@ -25,17 +27,8 @@ export function requestsCmd(wrap) {
25
27
 
26
28
  async function enrichedShowHandler(argv, app) {
27
29
  const number = argv.number;
28
- assertSafeExactMatch(number);
29
- const params = new URLSearchParams();
30
- params.set('sysparm_query', `number=${number}`);
31
- params.set('sysparm_display_value', 'all');
32
- params.set('sysparm_limit', '1');
33
- const records = await app.sdk.list('sc_req_item', params);
34
- if (records.length === 0) {
35
- throw new Error(`Request not found: ${number}`);
36
- }
37
- const record = records[0];
38
- const sysID = getStringField(record, 'sys_id');
30
+ const record = await getRecord(app.sdk, { table: 'sc_req_item', identifier: number, matchField: 'number', identifierField: argv.identifierField, displayValues: argv.displayValues, resource: 'Request' });
31
+ const sysID = unwrapSysId(record);
39
32
 
40
33
  // Fetch attachments and catalog variables in parallel
41
34
  const [attachments, variables] = await Promise.all([
@@ -50,6 +43,7 @@ async function enrichedShowHandler(argv, app) {
50
43
  table: 'sc_req_item',
51
44
  };
52
45
 
46
+ await enrichRelatedLists(record, { table: 'sc_req_item', argv, app });
53
47
  app.ok(record, {
54
48
  summary: `Request ${number}`,
55
49
  breadcrumbs: [
@@ -2,7 +2,7 @@ import { declareCapabilities } from '../capabilities.js';
2
2
 
3
3
  // Raw REST passthrough — can hit any endpoint with any method, so the whole
4
4
  // command is a mutation surface on read-only profiles.
5
- declareCapabilities('rest', { mutationSubcommands: [''] });
5
+ declareCapabilities('rest', { mutationSubcommands: [''], mutationContextReadMethods: ['GET', 'HEAD', 'OPTIONS'] });
6
6
 
7
7
  export function restCmd(wrap) {
8
8
  return {
@@ -10,6 +10,10 @@ export function restCmd(wrap) {
10
10
  describe: 'Make raw REST API calls',
11
11
  builder: (yargs) => {
12
12
  return yargs
13
+ .positional('endpoint', {
14
+ type: 'string',
15
+ describe: 'API path, optionally including query parameters; with --table, a record sys_id',
16
+ })
13
17
  .option('method', {
14
18
  alias: 'X',
15
19
  type: 'string',
@@ -34,7 +38,16 @@ export function restCmd(wrap) {
34
38
  .option('query', {
35
39
  type: 'string',
36
40
  describe: 'Query parameters string',
37
- });
41
+ })
42
+ .epilogue([
43
+ 'Prefer a specific jsn command or records for normal table operations.',
44
+ 'Raw REST is blocked on read-only profiles for every method, including GET.',
45
+ 'Examples:',
46
+ ' jsn rest "/api/now/table/incident?sysparm_limit=3" --json',
47
+ ' jsn rest --table incident --query "sysparm_limit=3" --json',
48
+ ' jsn rest /stats.do --raw',
49
+ 'Use either query parameters in the endpoint or --query, rather than both.',
50
+ ].join('\n'));
38
51
  },
39
52
  handler: wrap(async (argv, app) => {
40
53
  const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];