@jacebenson/jsn 4.3.13 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/package.json +4 -3
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- package/src/commands/rest.js +15 -2
- package/src/commands/scan.js +234 -0
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +12 -2
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- 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
|
|
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
|
|
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
|
|
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',
|
package/src/commands/records.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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'
|
|
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('
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
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
|
|
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',
|
|
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
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
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(
|
|
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
|
|
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',
|
|
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
|
|
265
|
-
const
|
|
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',
|
|
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
|
-
|
|
284
|
-
await app
|
|
285
|
-
|
|
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' })
|
package/src/commands/requests.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
29
|
-
const
|
|
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: [
|
package/src/commands/rest.js
CHANGED
|
@@ -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'];
|