@jacebenson/jsn 4.3.12 → 4.3.13-dev.101.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.
- package/README.md +95 -0
- package/package.json +2 -1
- 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 +24 -4
- package/src/cli.js +41 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +58 -37
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +38 -31
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- 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/groups.js +11 -8
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +64 -119
- package/src/commands/requests.js +10 -16
- 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 +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +31 -22
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/users.js +11 -8
- 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 +11 -2
- package/src/helpers.js +44 -0
- 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,16 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inspectUpdateSetXML, updateSetFilename, MAX_UPDATE_SET_BYTES } from '../update-set-xml.js';
|
|
2
4
|
import { editor, confirm } from '../prompts.js';
|
|
3
|
-
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
|
|
5
|
+
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack, quoteShellArg } from '../helpers.js';
|
|
4
6
|
import { getActiveProfile } from '../config.js';
|
|
5
7
|
import { FormatAuto } from '../output.js';
|
|
6
8
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
7
|
-
import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
9
|
+
import { AppError, CodeConfirmationRequired, errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
8
10
|
import { declareCapabilities } from '../capabilities.js';
|
|
9
11
|
import { resolveScope } from './scopes.js';
|
|
10
12
|
import { unwrapSysId } from '../resolve-record.js';
|
|
11
13
|
|
|
12
14
|
declareCapabilities('updatesets', {
|
|
13
|
-
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent'],
|
|
15
|
+
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent', 'import', 'reopen'],
|
|
16
|
+
mutationContextExemptSubcommands: ['create', 'set', 'import', 'reopen'],
|
|
17
|
+
localPreviewSubcommands: ['import'],
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -27,7 +31,7 @@ export function scopeMismatchWarning(updateSetApp, currentApp) {
|
|
|
27
31
|
const updateSetAppId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
|
|
28
32
|
if (!updateSetAppId || !currentApp.appSysId || updateSetAppId === currentApp.appSysId) return '';
|
|
29
33
|
const name = updateSetApp?.display_value || updateSetApp;
|
|
30
|
-
return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes
|
|
34
|
+
return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes may not be captured in this set. Run: jsn scopes set ${quoteShellArg(updateSetAppId)}`;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/** Format a picker label for an update set: "Name [State] (app/scope)". */
|
|
@@ -361,6 +365,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
361
365
|
const stateRaw = String(full.state?.value ?? full.state ?? '').toLowerCase();
|
|
362
366
|
const isClosed = ['complete', 'committed'].includes(stateRaw);
|
|
363
367
|
const hints = [];
|
|
368
|
+
hints.push({ action: 'scan', cmd: `jsn scan run --update-set ${sysID}${app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : ''}`, description: 'Offer an explicit scan of current records before completing or promoting this set' });
|
|
364
369
|
if (isClosed) {
|
|
365
370
|
hints.push({
|
|
366
371
|
action: 'set',
|
|
@@ -381,7 +386,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
381
386
|
});
|
|
382
387
|
hints.push({
|
|
383
388
|
action: 'export',
|
|
384
|
-
cmd: `jsn updatesets export "${name}" --out ${
|
|
389
|
+
cmd: `jsn updatesets export "${name}" --out ${quoteShellArg(updateSetFilename(name))}`,
|
|
385
390
|
description: 'Export this update set to XML',
|
|
386
391
|
});
|
|
387
392
|
if (!isClosed) {
|
|
@@ -424,7 +429,7 @@ export function updateSetsCmd(wrap) {
|
|
|
424
429
|
return {
|
|
425
430
|
command: 'updatesets [subcommand]',
|
|
426
431
|
aliases: ['updateset', 'us'],
|
|
427
|
-
describe: 'Manage update sets',
|
|
432
|
+
describe: 'Manage update sets. Global + Default mutations require approval (--force only after user approval); create/set remain available to select a dedicated capture set. Scope mismatches warn; completed capture sets block writes.',
|
|
428
433
|
builder: (yargs) => {
|
|
429
434
|
return yargs
|
|
430
435
|
.command({
|
|
@@ -477,11 +482,14 @@ export function updateSetsCmd(wrap) {
|
|
|
477
482
|
})
|
|
478
483
|
.command({
|
|
479
484
|
command: 'export <name>',
|
|
480
|
-
describe: 'Export
|
|
485
|
+
describe: 'Export XML without changing state; auto-name a file in a terminal, preserve piped stdout',
|
|
481
486
|
builder: (y) => y
|
|
482
487
|
.positional('name', { describe: 'Update set name', type: 'string' })
|
|
483
|
-
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
488
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
489
|
+
.option('out-dir', { type: 'string', describe: 'Write an automatically named XML file in this directory' })
|
|
490
|
+
.option('stdout', { type: 'boolean', describe: 'Write raw XML to stdout, including in a terminal' }),
|
|
484
491
|
handler: wrap(async (argv, app) => {
|
|
492
|
+
if ([Boolean(argv.out), Boolean(argv.outDir), Boolean(argv.stdout)].filter(Boolean).length > 1) throw errUsage('Choose one of --out, --out-dir, or --stdout');
|
|
485
493
|
app.requireInstance();
|
|
486
494
|
const record = await resolveUpdateSetByName(app, argv.name);
|
|
487
495
|
const sysID = getStringField(record, 'sys_id');
|
|
@@ -503,14 +511,16 @@ export function updateSetsCmd(wrap) {
|
|
|
503
511
|
}
|
|
504
512
|
|
|
505
513
|
const bytes = Buffer.byteLength(xml, 'utf-8');
|
|
506
|
-
|
|
507
|
-
|
|
514
|
+
const out = argv.out || (argv.outDir || (process.stdout.isTTY && !argv.stdout && !argv.json && argv.format !== 'json')
|
|
515
|
+
? path.join(argv.outDir || '.', updateSetFilename(getStringField(record, 'name'))) : null);
|
|
516
|
+
if (out) {
|
|
517
|
+
await fs.promises.writeFile(out, xml, { flag: 'wx' });
|
|
508
518
|
app.ok({
|
|
509
519
|
update_set: getStringField(record, 'name'),
|
|
510
520
|
sys_id: sysID,
|
|
511
|
-
out
|
|
521
|
+
out,
|
|
512
522
|
bytes,
|
|
513
|
-
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${
|
|
523
|
+
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${out}` });
|
|
514
524
|
return;
|
|
515
525
|
}
|
|
516
526
|
|
|
@@ -518,7 +528,7 @@ export function updateSetsCmd(wrap) {
|
|
|
518
528
|
// (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
|
|
519
529
|
// envelope when the user explicitly asks for it — auto format
|
|
520
530
|
// detection must NOT hijack the payload for piped consumers.
|
|
521
|
-
if (argv.json || argv.format === 'json') {
|
|
531
|
+
if (!argv.stdout && (argv.json || argv.format === 'json')) {
|
|
522
532
|
app.ok({
|
|
523
533
|
update_set: getStringField(record, 'name'),
|
|
524
534
|
sys_id: sysID,
|
|
@@ -530,6 +540,47 @@ export function updateSetsCmd(wrap) {
|
|
|
530
540
|
process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
|
|
531
541
|
}),
|
|
532
542
|
})
|
|
543
|
+
.command({
|
|
544
|
+
command: 'import <file>',
|
|
545
|
+
describe: 'Load XML as a retrieved update set; never preview, commit, or select it automatically',
|
|
546
|
+
builder: y => y
|
|
547
|
+
.positional('file', { type: 'string', describe: 'Update set XML export' })
|
|
548
|
+
.option('dry-run', { type: 'boolean', describe: 'Inspect locally without any instance requests' })
|
|
549
|
+
.option('yes', { type: 'boolean', describe: 'Confirm loading the displayed XML into the selected target' }),
|
|
550
|
+
handler: wrap(async (argv, app) => {
|
|
551
|
+
const stat = await fs.promises.stat(argv.file);
|
|
552
|
+
if (!stat.isFile() || stat.size > MAX_UPDATE_SET_BYTES) throw errUsage('Expected an XML file no larger than 50 MiB');
|
|
553
|
+
const xml = await fs.promises.readFile(argv.file, 'utf8');
|
|
554
|
+
const inspection = inspectUpdateSetXML(xml);
|
|
555
|
+
const { members, ...summary } = inspection;
|
|
556
|
+
if (argv.dryRun) return app.ok({ ...summary, members, dry_run: true }, { summary: `XML: ${inspection.name}; ${inspection.count} updates (local inspection only)` });
|
|
557
|
+
app.requireInstance();
|
|
558
|
+
const question = `Load "${inspection.name}" (${inspection.count} updates) into ${app.getEffectiveInstance()} as a retrieved update set? This does not commit its changes.`;
|
|
559
|
+
if (!argv.yes) {
|
|
560
|
+
if (!canPrompt() || argv.json || argv.get || (argv.format && argv.format !== FormatAuto)) {
|
|
561
|
+
throw new AppError(CodeConfirmationRequired, `${question} — confirmation required. Pass --yes after approving this target.`,
|
|
562
|
+
`Rerun: ${buildRerunCommand(argv, { rerun: ['updatesets', 'import', argv.file, '--yes'] })}`);
|
|
563
|
+
}
|
|
564
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
565
|
+
}
|
|
566
|
+
const result = await app.sdk.importUpdateSet(xml, inspection, path.basename(argv.file));
|
|
567
|
+
app.ok({ ...summary, ...result, committed: false }, { summary: `Loaded retrieved update set "${inspection.name}" (${result.count} updates); not committed` });
|
|
568
|
+
}),
|
|
569
|
+
})
|
|
570
|
+
.command({
|
|
571
|
+
command: 'reopen <name>',
|
|
572
|
+
describe: 'Explicitly return a complete or ignored local update set to in progress; does not select it',
|
|
573
|
+
handler: wrap(async (argv, app) => {
|
|
574
|
+
const target = await resolveCompletionTarget(app, argv.name);
|
|
575
|
+
const record = await fetchUpdateSetById(app, target.sys_id);
|
|
576
|
+
const state = String(record?.state?.value ?? record?.state ?? '').toLowerCase();
|
|
577
|
+
if (!['complete', 'ignore'].includes(state)) throw errUsage(`Cannot reopen an update set in state "${state}"; expected complete or ignore`);
|
|
578
|
+
await app.sdk.update('sys_update_set', target.sys_id, { state: 'in progress' });
|
|
579
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', target.sys_id, { state: 'in progress' });
|
|
580
|
+
if (mismatches.length) throw errAPI(200, 'Reopen could not be verified; inspect the update set before retrying');
|
|
581
|
+
app.ok({ update_set: target.name, sys_id: target.sys_id, state: 'in progress' }, { summary: `Reopened update set: ${target.name}` });
|
|
582
|
+
}),
|
|
583
|
+
})
|
|
533
584
|
.command({
|
|
534
585
|
command: 'set [name]',
|
|
535
586
|
describe: 'Set the current update set (interactive picker with scope when run bare)',
|
|
@@ -737,6 +788,8 @@ export function updateSetsCmd(wrap) {
|
|
|
737
788
|
console.log(' list List update sets (shows scope)');
|
|
738
789
|
console.log(' show <name> Show an update set (type counts + risky items)');
|
|
739
790
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
791
|
+
console.log(' import <file> Load retrieved XML (--dry-run inspects locally; --yes confirms)');
|
|
792
|
+
console.log(' reopen <name> Explicitly return a closed set to in progress');
|
|
740
793
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
741
794
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
742
795
|
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|
package/src/commands/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
|
|
2
2
|
import { resolveRecord, resolveSysId } from '../resolve-record.js';
|
|
3
3
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
4
|
|
|
@@ -72,12 +72,12 @@ export function usersCmd(wrap) {
|
|
|
72
72
|
.option('username', { alias: 'u', type: 'string', describe: 'Username (e.g. john.doe)' })
|
|
73
73
|
.option('name', { alias: 'n', type: 'string', describe: 'Full name (e.g. John Doe)' })
|
|
74
74
|
.option('email', { alias: 'e', type: 'string', describe: 'Email address' })
|
|
75
|
-
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
75
|
+
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
76
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
77
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
78
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
76
79
|
handler: wrap(async (argv, app) => {
|
|
77
|
-
const recordData = {};
|
|
78
|
-
if (argv.data) {
|
|
79
|
-
Object.assign(recordData, JSON.parse(argv.data));
|
|
80
|
-
}
|
|
80
|
+
const recordData = buildRecordPayload(argv, { required: false });
|
|
81
81
|
if (argv.username) recordData.user_name = argv.username;
|
|
82
82
|
if (argv.name) recordData.name = argv.name;
|
|
83
83
|
if (argv.email) recordData.email = argv.email;
|
|
@@ -97,10 +97,13 @@ export function usersCmd(wrap) {
|
|
|
97
97
|
command: 'update <identifier>',
|
|
98
98
|
describe: 'Update a user by user_name or sys_id',
|
|
99
99
|
builder: (y) => y
|
|
100
|
-
.option('data', { type: 'string',
|
|
100
|
+
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
101
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
102
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
103
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
101
104
|
handler: wrap(async (argv, app) => {
|
|
102
105
|
const id = argv.identifier;
|
|
103
|
-
const recordData =
|
|
106
|
+
const recordData = buildRecordPayload(argv);
|
|
104
107
|
const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
|
|
105
108
|
const updated = await app.sdk.update('sys_user', sysID, recordData);
|
|
106
109
|
app.ok(updated, {
|
package/src/commands/version.js
CHANGED
|
@@ -6,7 +6,13 @@ import { declareCapabilities } from '../capabilities.js';
|
|
|
6
6
|
declareCapabilities('version', { noInstance: true, skipDailyChecks: true });
|
|
7
7
|
|
|
8
8
|
const NPM_PKG = '@jacebenson/jsn';
|
|
9
|
-
|
|
9
|
+
export function getReleaseChannel(version = getVersion()) {
|
|
10
|
+
return /^\d+\.\d+\.\d+-dev\./.test(version) ? 'dev' : 'latest';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getInstallCommand(version = getVersion()) {
|
|
14
|
+
return `npm install -g ${NPM_PKG}${getReleaseChannel(version) === 'dev' ? '@dev' : ''}`;
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
export function getVersion() {
|
|
12
18
|
try {
|
|
@@ -26,16 +32,16 @@ export function cancelBackgroundChecks() {
|
|
|
26
32
|
backgroundChecks.clear();
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
export async function checkLatest({ timeout = 1500, background = false } = {}) {
|
|
35
|
+
export async function checkLatest({ timeout = 1500, background = false, version = getVersion() } = {}) {
|
|
30
36
|
const controller = new AbortController();
|
|
31
37
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
32
38
|
timer.unref();
|
|
33
39
|
if (background) backgroundChecks.add(controller);
|
|
34
40
|
try {
|
|
35
|
-
const res = await fetch(
|
|
41
|
+
const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${getReleaseChannel(version)}`, { signal: controller.signal });
|
|
36
42
|
if (!res.ok) return null;
|
|
37
43
|
const data = await res.json();
|
|
38
|
-
return data.version;
|
|
44
|
+
return typeof data.version === 'string' ? data.version : null;
|
|
39
45
|
} catch {
|
|
40
46
|
return null;
|
|
41
47
|
} finally {
|
|
@@ -58,11 +64,11 @@ export function versionCmd(wrap) {
|
|
|
58
64
|
const version = getVersion();
|
|
59
65
|
|
|
60
66
|
if (argv.check) {
|
|
61
|
-
const latest = await checkLatest();
|
|
67
|
+
const latest = await checkLatest({ version });
|
|
62
68
|
if (!latest) {
|
|
63
69
|
app.ok({ version }, {
|
|
64
70
|
summary: `jsn ${version} (could not reach npm registry to check for updates)`,
|
|
65
|
-
notice: `Run \`npm view ${NPM_PKG}\` directly to check manually`,
|
|
71
|
+
notice: `Run \`npm view ${NPM_PKG}@${getReleaseChannel(version)} version\` directly to check manually`,
|
|
66
72
|
});
|
|
67
73
|
return;
|
|
68
74
|
}
|
|
@@ -82,11 +88,11 @@ export function versionCmd(wrap) {
|
|
|
82
88
|
version,
|
|
83
89
|
latest,
|
|
84
90
|
upToDate: false,
|
|
85
|
-
installCmd:
|
|
91
|
+
installCmd: getInstallCommand(version),
|
|
86
92
|
}, {
|
|
87
93
|
summary: `jsn ${version} — newer version ${latest} available`,
|
|
88
94
|
breadcrumbs: [
|
|
89
|
-
{ action: 'update', cmd:
|
|
95
|
+
{ action: 'update', cmd: getInstallCommand(version), description: `Update jsn from ${version} to ${latest}` },
|
|
90
96
|
],
|
|
91
97
|
});
|
|
92
98
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { errUsageHint } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** Constrained table views accept literal AND filters. Encoded queries have no
|
|
4
|
+
* portable parenthesization; OR/NQ/dynamic clauses must not escape fixed filters. */
|
|
5
|
+
export function constrainQuery(query = '', constraint = '') {
|
|
6
|
+
if (!constraint) return query;
|
|
7
|
+
const clauses = String(query).split('^').filter((clause, index, values) => clause !== 'EQ' || index !== values.length - 1);
|
|
8
|
+
if (!query) return constraint;
|
|
9
|
+
const literal = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*(?:(?:!=|>=|<=|=|>|<|NOT LIKE|STARTSWITH|ENDSWITH|LIKE|NOT IN|IN)[^^]*|ISEMPTY|ISNOTEMPTY)$/;
|
|
10
|
+
const ordering = /^ORDERBY(?:DESC)?[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*$/;
|
|
11
|
+
const allowed = clause => ordering.test(clause) || (literal.test(clause)
|
|
12
|
+
&& (!/^(?:NQ|OR)/i.test(clause) || /^order(?:!=|>=|<=|=|>|<|NOT LIKE|STARTSWITH|ENDSWITH|LIKE|NOT IN|IN|ISEMPTY|ISNOTEMPTY)/.test(clause)));
|
|
13
|
+
if (/javascript\s*:/i.test(query) || clauses.some(clause => !allowed(clause))) {
|
|
14
|
+
throw errUsageHint('This constrained command accepts literal AND filters and ORDERBY only; OR, NQ and dynamic query clauses are unsupported.',
|
|
15
|
+
`The fixed filter is ${constraint}. Use literal AND clauses, or explicitly use records list for advanced queries and retain the intended constraints in every branch.`);
|
|
16
|
+
}
|
|
17
|
+
return clauses.length ? `${constraint}^${clauses.join('^')}` : constraint;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const constrainedQueryHelp = constraint => `Always includes: ${constraint}. Additional --query filters use literal AND clauses (plus ORDERBY). OR, NQ and dynamic query clauses are rejected before requests so they cannot bypass this view's fixed filter.`;
|
package/src/context.js
CHANGED
|
@@ -18,26 +18,26 @@ export async function getCurrentUser(sdk) {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function getCurrentApplication(sdk, userSysID) {
|
|
21
|
+
export async function getCurrentApplication(sdk, userSysID, { unknownWhenMissing = false } = {}) {
|
|
22
22
|
const params = new URLSearchParams();
|
|
23
23
|
params.set('sysparm_query', `user=${userSysID}^name=apps.current_app`);
|
|
24
24
|
params.set('sysparm_limit', '1');
|
|
25
25
|
params.set('sysparm_fields', 'value');
|
|
26
26
|
const records = await sdk.list('sys_user_preference', params);
|
|
27
|
-
if (records.length === 0) return { scope: 'global', appSysId: '' };
|
|
27
|
+
if (records.length === 0) return { scope: unknownWhenMissing ? '' : 'global', appSysId: '' };
|
|
28
28
|
const val = records[0].value?.value || records[0].value;
|
|
29
|
-
if (!val) return { scope: 'global', appSysId: '' };
|
|
29
|
+
if (!val) return { scope: unknownWhenMissing ? '' : 'global', appSysId: '' };
|
|
30
30
|
|
|
31
31
|
// Try to resolve sys_id to scope name
|
|
32
32
|
try {
|
|
33
33
|
const app = await sdk.get('sys_scope', val);
|
|
34
|
-
if (app) return { scope: app
|
|
34
|
+
if (app) return { scope: getStringField(app, 'scope') || (unknownWhenMissing ? '' : 'global'), appSysId: val };
|
|
35
35
|
} catch {
|
|
36
36
|
// fallback
|
|
37
37
|
}
|
|
38
38
|
try {
|
|
39
39
|
const app2 = await sdk.get('sys_app', val);
|
|
40
|
-
if (app2) return { scope: app2
|
|
40
|
+
if (app2) return { scope: getStringField(app2, 'scope') || (unknownWhenMissing ? '' : 'global'), appSysId: val };
|
|
41
41
|
} catch {
|
|
42
42
|
// fallback
|
|
43
43
|
}
|
|
@@ -56,7 +56,7 @@ export async function getCurrentUpdateSet(sdk, userSysID) {
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
const us = await sdk.get('sys_update_set', val);
|
|
59
|
-
if (us) return { name: us
|
|
59
|
+
if (us) return { name: getStringField(us, 'name') || val, sys_id: val, application: us.application, state: getStringField(us, 'state') };
|
|
60
60
|
} catch {
|
|
61
61
|
// fallback
|
|
62
62
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared interpretation for CLI help, saved-report views and comparisons.
|
|
2
|
+
// Keep this independent of capture/storage so older captures remain readable.
|
|
3
|
+
export function customizationGuidance() {
|
|
4
|
+
return {
|
|
5
|
+
capture: 'Capture uses bounded GET-only table reads and saves reports locally. Authentication may refresh OAuth credentials locally or bootstrap a session/GCK with authentication POSTs. Saved list/show/compare/html operations are offline.',
|
|
6
|
+
evidence: {
|
|
7
|
+
upgrade_skips: 'Raw upgrade dispositions 4 and 104 are historical skipped-customization evidence; reviewed_reverted rows are excluded. Display labels and other skip reasons are not equivalent. A skip does not prove a current OOTB baseline deviation.',
|
|
8
|
+
ownership: 'Customer scopes must be declared explicitly. Matching a scope verifies its identity, not customer or publisher ownership. Non-global scope and updater names do not establish ownership.',
|
|
9
|
+
metadata: 'The broad sys_metadata read is an arbitrary sys_id-ordered bounded sample, not an inventory. Declared-customer and unclassified categories are not complete inventories; they can include sampled artifacts and artifacts found by skipped-history lookups.',
|
|
10
|
+
resolution: 'Every observed skipped file_name is resolved by chunked sys_metadata.sys_update_name lookups. Unresolved names and failed lookup chunks remain coverage gaps. A resolved artifact may lack a scope name when its scope was outside the bounded sys_scope read.',
|
|
11
|
+
},
|
|
12
|
+
limitations: [
|
|
13
|
+
'All captures are incomplete. No findings is not proof of no customizations; ACLs, pagination limits, unmatched evidence and concurrent changes can hide artifacts.',
|
|
14
|
+
'Current baseline, full source payloads, runtime reversion impact and dependencies are not established. An empty dependency list does not prove none exist.',
|
|
15
|
+
],
|
|
16
|
+
comparison: 'Comparisons cover captured metadata and evidence only. Added/removed means newly observed/no longer observed, not proven creation/deletion. Changed declarations or coverage can change categories without an artifact change.',
|
|
17
|
+
next_steps: [
|
|
18
|
+
'Review coverage, evidence and per-artifact confidence before drawing conclusions.',
|
|
19
|
+
'Verify ownership and compare with a trusted baseline for the exact release; investigate dependencies and test in non-production before changing records. Never recommend automatic reversion.',
|
|
20
|
+
],
|
|
21
|
+
automated_remediation: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatCustomizationGuidance() {
|
|
26
|
+
const guidance = customizationGuidance();
|
|
27
|
+
return [
|
|
28
|
+
...Object.values(guidance.evidence), ...guidance.limitations,
|
|
29
|
+
guidance.comparison, ...guidance.next_steps.map(step => `Next: ${step}`),
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { customizationGuidance, formatCustomizationGuidance } from './customization-guidance.js';
|
|
3
4
|
|
|
4
5
|
const CLASSIFICATIONS = {
|
|
5
6
|
ootb_modifications: {
|
|
@@ -94,6 +95,7 @@ export function buildActionableReport(report) {
|
|
|
94
95
|
start_time: report.start_time,
|
|
95
96
|
artifacts,
|
|
96
97
|
coverage: report.coverage,
|
|
98
|
+
interpretation: customizationGuidance(),
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -119,6 +121,7 @@ export function renderCustomizationHtml(report) {
|
|
|
119
121
|
<section class="toolbar" aria-label="Report filters"><label class="control grow">Search artifacts<input id="search" type="search" placeholder="Name, table, scope, updater, evidence"></label><label class="control">Classification<select id="category"><option value="">All classifications</option>${categoryOptions}</select></label><label class="control">Group by<select id="group-by"><option value="type">File type</option><option value="scope">Scope</option><option value="updated_by">Updated by</option></select></label></section>
|
|
120
122
|
<section id="results" aria-live="polite"></section>
|
|
121
123
|
<details class="coverage"><summary>Capture coverage and limitations</summary><div id="coverage-content"></div></details>
|
|
124
|
+
<details class="coverage"><summary>How to interpret this evidence</summary>${formatCustomizationGuidance().split('\n').map(line => `<p>${escapeHtml(line)}</p>`).join('')}</details>
|
|
122
125
|
<script id="report-data" type="application/json">${data}</script>
|
|
123
126
|
<script>
|
|
124
127
|
'use strict';
|
|
@@ -146,6 +149,9 @@ function render(){
|
|
|
146
149
|
for(const [categoryName,items] of Object.entries(report.artifacts.reduce((groups,item)=>{groups[item.category]=(groups[item.category]||0)+1;return groups;},{})))document.getElementById('stats').append(el('span',categoryName.replaceAll('_',' ')+': '+items,'stat'));
|
|
147
150
|
search.addEventListener('input',render);category.addEventListener('change',render);groupBy.addEventListener('change',render);render();
|
|
148
151
|
const coverage=document.getElementById('coverage-content');const ul=el('ul');for(const [source,details] of Object.entries(report.coverage.sources||{})){ul.append(el('li',source+': '+details.status+', '+details.fetched+'/'+(details.available??'unknown')+' rows. '+details.reason));}coverage.append(ul);for(const note of report.coverage.limitations||[])coverage.append(el('p',note));
|
|
152
|
+
coverage.append(el('p','Unmatched upgrade history records: '+(report.coverage.unmatched_upgrade_records??'unknown')));
|
|
153
|
+
if(report.coverage.unmatched_customer_scopes?.length)coverage.append(el('p','Unmatched customer declarations: '+report.coverage.unmatched_customer_scopes.join(', ')));
|
|
154
|
+
const upgrade=report.coverage.upgrade_evidence;if(upgrade){coverage.append(el('p','Observed skipped rows: '+upgrade.skipped_rows_observed+'; update names: '+upgrade.update_names_observed+'; resolved by lookup: '+upgrade.resolved_by_lookup+'; failed lookup chunks: '+upgrade.lookup_failures));if(upgrade.unresolved_update_names?.length)coverage.append(el('p','Unresolved skipped update names: '+upgrade.unresolved_update_names.join(', ')));}
|
|
149
155
|
</script>
|
|
150
156
|
</main></body></html>\n`;
|
|
151
157
|
}
|
package/src/customizations.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { getVersion } from './commands/version.js';
|
|
8
8
|
import { classifyCustomization } from './customizations-report.js';
|
|
9
|
+
import { customizationGuidance, formatCustomizationGuidance } from './customization-guidance.js';
|
|
9
10
|
|
|
10
11
|
// Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
|
|
11
12
|
// dispositions do not establish a preserved customization, so the upgrade
|
|
@@ -290,22 +291,28 @@ export function compareReports(baseline, newer) {
|
|
|
290
291
|
const unknowns = ['Additions/removals mean newly observed/no longer observed, not proven creation/deletion. ACLs, limits and concurrent changes can affect visibility.', 'Only captured metadata and evidence are compared, not full artifact content or OOTB baselines.', 'Ownership declarations, scope visibility and evidence coverage can cause category changes without an artifact change.'];
|
|
291
292
|
if (unreliable) unknowns.push(`Unavailable or empty sources make observed additions/removals unreliable: baseline [${impaired.baseline.join(', ') || 'none'}], new [${impaired.new.join(', ') || 'none'}]. They may reflect missing coverage rather than a real change and must not be read as mass creation or deletion.`);
|
|
292
293
|
return { baseline_run_id: baseline.run_id, new_run_id: newer.run_id, instance: baseline.instance, status: unreliable ? 'unreliable' : 'incomplete', additions, removals, changes,
|
|
293
|
-
impaired_sources: impaired,
|
|
294
|
+
impaired_sources: impaired, interpretation: customizationGuidance(),
|
|
294
295
|
coverage: { baseline: baseline.coverage, new: newer.coverage },
|
|
295
296
|
unknowns };
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
function
|
|
299
|
-
const lines = [
|
|
300
|
-
for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}${coverage.kind ? ` [${coverage.kind}]` : ''}: ${coverage.status}, ${coverage.fetched} fetched of ${coverage.available === null || coverage.available === undefined ? 'unknown' : coverage.available} available. ${coverage.reason}`);
|
|
301
|
-
for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
|
|
302
|
-
if (report.coverage.unmatched_customer_scopes
|
|
299
|
+
function coverageLines(report) {
|
|
300
|
+
const lines = [];
|
|
301
|
+
for (const [source, coverage] of Object.entries(report.coverage.sources || {})) lines.push(`${source}${coverage.kind ? ` [${coverage.kind}]` : ''}: ${coverage.status}, ${coverage.fetched} fetched of ${coverage.available === null || coverage.available === undefined ? 'unknown' : coverage.available} available. ${coverage.reason}`);
|
|
302
|
+
for (const limitation of report.coverage.limitations || []) lines.push(`Unknown: ${limitation}`);
|
|
303
|
+
if (report.coverage.unmatched_customer_scopes?.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
|
|
303
304
|
const upgrade = report.coverage.upgrade_evidence;
|
|
304
305
|
if (upgrade) {
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
const unresolved = upgrade.unresolved_update_names;
|
|
307
|
+
lines.push(`Observed skipped upgrade rows: ${upgrade.skipped_rows_observed}; distinct update names: ${upgrade.update_names_observed}; resolved by direct name lookup: ${upgrade.resolved_by_lookup}; unresolved: ${unresolved?.length ?? 'unknown'}${upgrade.lookup_failures ? `; failed lookup chunks: ${upgrade.lookup_failures}` : ''}`);
|
|
308
|
+
if (unresolved?.length) lines.push(`Unresolved skipped update names (not visible in sys_metadata): ${unresolved.slice(0, 20).join(', ')}${unresolved.length > 20 ? `, ... and ${unresolved.length - 20} more` : ''}`);
|
|
307
309
|
}
|
|
308
|
-
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records}`);
|
|
310
|
+
lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records ?? 'unknown'}`);
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function formatReportDetailed(report) {
|
|
315
|
+
const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE', ...coverageLines(report), '', 'INTERPRETATION', formatCustomizationGuidance()];
|
|
309
316
|
for (const [category, items] of Object.entries(report.categories)) {
|
|
310
317
|
lines.push('', `${category.toUpperCase()} (${items.length})`);
|
|
311
318
|
for (const item of items) {
|
|
@@ -357,8 +364,13 @@ export function formatReport(report, { groupBy = 'type', filter = '' } = {}) {
|
|
|
357
364
|
}
|
|
358
365
|
lines.push('', 'Expand a group with --filter <group-name>. File-type filters accept the friendly label or table name. Regroup with --group-by scope or --group-by updated-by.', 'Examples:', ` jsn customizations show ${report.run_id} --filter "Script Include"`, ` jsn customizations show ${report.run_id} --group-by scope`, ` jsn customizations show ${report.run_id} --group-by updated-by`);
|
|
359
366
|
lines.push('', 'Coverage and limitations are shared across these artifacts.');
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
lines.push(...coverageLines(report), '', 'INTERPRETATION', formatCustomizationGuidance());
|
|
368
|
+
for (const [category, records] of Object.entries(report.categories)) {
|
|
369
|
+
if (!records.length) continue;
|
|
370
|
+
const triage = classifyCustomization(category);
|
|
371
|
+
lines.push(`${triage.label}: ${triage.confidence}. ${triage.summary}`);
|
|
372
|
+
for (const step of triage.next_steps) lines.push(`Next: ${step}`);
|
|
373
|
+
}
|
|
362
374
|
return lines.join('\n') + '\n';
|
|
363
375
|
}
|
|
364
376
|
|
|
@@ -395,18 +407,21 @@ function fieldChanges(before, after) {
|
|
|
395
407
|
function describeComparedItem(item) {
|
|
396
408
|
const identity = item?.identity || {};
|
|
397
409
|
const scope = item?.scope || {};
|
|
410
|
+
const triage = classifyCustomization(item?.category);
|
|
398
411
|
return [
|
|
399
412
|
`Artifact: ${identity.table || 'unknown'}:${identity.sys_id || 'unknown'} "${identity.name || identity.update_name || 'unnamed'}"`,
|
|
400
413
|
`Category: ${item?.category || 'unknown'}`,
|
|
401
414
|
`Scope: ${scope.scope || scope.sys_id || 'unknown'}${scope.name ? ` (${scope.name})` : ''}`,
|
|
402
415
|
`Evidence: ${(item?.evidence || []).map(evidence => `${evidence.source}:${evidence.sys_id}`).join(', ') || 'none captured'}`,
|
|
403
416
|
`Risk: ${item?.risk?.rating || 'unknown'}`,
|
|
417
|
+
`Triage: ${triage.label} (${triage.confidence}). ${triage.summary}`,
|
|
418
|
+
...triage.next_steps.map(step => `Next: ${step}`),
|
|
404
419
|
`Recommendation: ${item?.recommendation || 'not recorded'}`,
|
|
405
420
|
];
|
|
406
421
|
}
|
|
407
422
|
|
|
408
423
|
export function formatReportComparison(result) {
|
|
409
|
-
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns];
|
|
424
|
+
const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns, '', 'INTERPRETATION', formatCustomizationGuidance()];
|
|
410
425
|
const impaired = result.impaired_sources || { baseline: [], new: [] };
|
|
411
426
|
if (impaired.baseline.length) lines.push(`Unreliable baseline sources: ${impaired.baseline.join(', ')}`);
|
|
412
427
|
if (impaired.new.length) lines.push(`Unreliable new sources: ${impaired.new.join(', ')}`);
|
|
@@ -511,7 +526,7 @@ export async function captureCustomizations({ sdk, customerScopes = [], pageSize
|
|
|
511
526
|
},
|
|
512
527
|
unmatched_upgrade_records: data.sys_upgrade_history_log.filter(row => !visibleNames.has(text(row.file_name))).length,
|
|
513
528
|
unmatched_customer_scopes: customerScopes.filter(id => !data.sys_scope.some(row => text(row.sys_id) === id || text(row.scope) === id)),
|
|
514
|
-
limitations: ['Inventory covers visible sys_metadata and upgrade history only, not every customization mechanism.', `Upgrade history is read server-side for raw dispositions ${SKIP_DISPOSITIONS.join(' and ')} only; unmatched upgrade counts describe that filtered population, not all upgrade history.`, 'Coverage compares rows fetched with the Stats API count for the same query; a short page never implies exhaustion and an unavailable count leaves completeness unknown.', `The broad sys_metadata source is a bounded sample of an arbitrary sys_id-ordered slice, not an inventory, so unclassified and declared-customer categories
|
|
529
|
+
limitations: ['Inventory covers visible sys_metadata and upgrade history only, not every customization mechanism.', `Upgrade history is read server-side for raw dispositions ${SKIP_DISPOSITIONS.join(' and ')} only; unmatched upgrade counts describe that filtered population, not all upgrade history.`, 'Coverage compares rows fetched with the Stats API count for the same query; a short page never implies exhaustion and an unavailable count leaves completeness unknown.', `The broad sys_metadata source is a bounded sample of an arbitrary sys_id-ordered slice, not an inventory, so unclassified and declared-customer categories are not complete inventories; they can include sampled artifacts and artifacts resolved by skipped-history lookups. Skipped-upgrade artifacts are separate: they are resolved by the update names the filtered history read observed (${RESOLUTION_SOURCE}).`, 'Name resolution only looks up artifacts with skipped-upgrade evidence, so customizations with no upgrade-skip history are not discovered by it.', 'A resolved artifact can show a scope sys_id without a scope name when that scope record was outside the bounded sys_scope read.', 'Row/field ACL filtering and concurrent changes cannot be ruled out.', 'No current baseline, source payloads or dependency graph captured. Comparisons detect captured metadata/evidence changes only.', 'OOTB category means historical skipped-upgrade evidence, not a verified current deviation. Customer scopes are caller declarations.'],
|
|
515
530
|
},
|
|
516
531
|
};
|
|
517
532
|
}
|
package/src/errors.js
CHANGED
|
@@ -77,6 +77,16 @@ export function errNetwork(cause) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export function errAPI(status, msg) {
|
|
80
|
+
// Authentication/authorization bodies can echo credentials. Never render them.
|
|
81
|
+
if (status === 401) {
|
|
82
|
+
const error = new AppError(CodeAuth, 'Authentication rejected for the active profile (HTTP 401).', 'Run: jsn auth login', 401);
|
|
83
|
+
error.recovery = 'jsn auth login';
|
|
84
|
+
return error;
|
|
85
|
+
}
|
|
86
|
+
if (status === 403) {
|
|
87
|
+
return new AppError(CodeForbidden, 'Access forbidden (HTTP 403).',
|
|
88
|
+
'This is an authorization/ACL/role problem, not a login problem. Check access to this endpoint.', 403);
|
|
89
|
+
}
|
|
80
90
|
let hint = 'Check the API documentation for this endpoint.';
|
|
81
91
|
if (status >= 500) {
|
|
82
92
|
hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
|
|
@@ -203,7 +213,7 @@ export function isErrorCode(err, code) {
|
|
|
203
213
|
* back to argv.format for callers without an App (unit tests).
|
|
204
214
|
*/
|
|
205
215
|
function resolveJsonMode(app, argv) {
|
|
206
|
-
if (app?.output) return app.output.effectiveFormat()
|
|
216
|
+
if (app?.output) return ['json', 'quiet'].includes(app.output.effectiveFormat());
|
|
207
217
|
return argv?.format === 'json';
|
|
208
218
|
}
|
|
209
219
|
|
|
@@ -229,6 +239,11 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
|
|
|
229
239
|
return { stream: 'stderr', text, exitCode: 1 };
|
|
230
240
|
}
|
|
231
241
|
|
|
242
|
+
if (code === CodeForbidden) {
|
|
243
|
+
if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
|
|
244
|
+
return { stream: 'stderr', text: `Error (forbidden): ${err.message}\n\nHint: ${err.hint}\n`, exitCode: 1 };
|
|
245
|
+
}
|
|
246
|
+
|
|
232
247
|
if (code === CodeConfirmationRequired) {
|
|
233
248
|
// Structured error for AI agents / piped consumers: the JSON envelope
|
|
234
249
|
// goes to stdout (where tooling reads) so the agent can surface the
|
|
@@ -241,6 +256,13 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
|
|
|
241
256
|
return { stream: 'stderr', text, exitCode: 1 };
|
|
242
257
|
}
|
|
243
258
|
|
|
259
|
+
if (err.resolution) {
|
|
260
|
+
if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
|
|
261
|
+
const lookup = err.resolution;
|
|
262
|
+
const text = `Error (${code}): ${err.message}\nLookup: ${lookup.mode} ${lookup.table}.${lookup.field}; ${lookup.at_least ? 'at least ' : ''}${lookup.matches} matches.\n${err.hint ? `\nHint: ${err.hint}\n` : ''}`;
|
|
263
|
+
return { stream: 'stderr', text, exitCode: 1 };
|
|
264
|
+
}
|
|
265
|
+
|
|
244
266
|
if (code === CodeNotFound) {
|
|
245
267
|
let text = `Error (${code}): ${err.message}\n`;
|
|
246
268
|
if (identifier) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Essential relationships are a declarative opt-in of specialized profiles.
|
|
2
|
+
// Supplementary metadata/counts live separately; this never fetches them.
|
|
3
|
+
import { assertSafeExactMatch, quoteShellArg } from './helpers.js';
|
|
4
|
+
import { listRecords, displayValue, recordListParams } from './records-engine.js';
|
|
5
|
+
|
|
6
|
+
export async function enrichEssentialRecords(record, { sdk, relationships, displayValues = 'both', profileName, instance }) {
|
|
7
|
+
if (!relationships?.length) return record;
|
|
8
|
+
const id = typeof record.sys_id === 'object' ? record.sys_id?.value : record.sys_id;
|
|
9
|
+
const entries = await Promise.all(relationships.map(async relationship => {
|
|
10
|
+
const limit = relationship.limit ?? 100;
|
|
11
|
+
const query = `${relationship.foreignKey}=${id}`;
|
|
12
|
+
const result = { table: relationship.table, query, limit, records: [], loaded: 0,
|
|
13
|
+
coverage: 'visible_rows_only', visibility: 'Subject to field/row ACLs; empty results do not prove no related records.' };
|
|
14
|
+
try {
|
|
15
|
+
if (!id) throw new Error('No readable raw parent sys_id');
|
|
16
|
+
assertSafeExactMatch(id);
|
|
17
|
+
const ordered = query + (relationship.orderBy ? `^ORDERBY${relationship.orderBy}` : '');
|
|
18
|
+
const params = recordListParams({ limit: limit + 1, query: ordered,
|
|
19
|
+
fields: relationship.columns ? ['sys_id', ...relationship.columns] : undefined,
|
|
20
|
+
displayValue: displayValue(displayValues),
|
|
21
|
+
});
|
|
22
|
+
const rows = await listRecords(sdk, { table: relationship.table, params });
|
|
23
|
+
if (!Array.isArray(rows)) throw new Error('Invalid related record response');
|
|
24
|
+
result.records = rows.slice(0, limit);
|
|
25
|
+
result.loaded = result.records.length;
|
|
26
|
+
result.has_more = rows.length > limit;
|
|
27
|
+
result.status = 'loaded';
|
|
28
|
+
const selector = profileName ? ` --profile ${quoteShellArg(profileName)}` : instance ? ` --instance ${quoteShellArg(instance)}` : '';
|
|
29
|
+
result.command = `jsn${selector} records list --table ${quoteShellArg(relationship.table)} --query ${quoteShellArg(ordered)} --limit ${limit} --json`;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Provider bodies can contain private data. Preserve uncertainty, not raw errors.
|
|
32
|
+
result.status = 'unavailable';
|
|
33
|
+
result.error = { code: error.code === 'forbidden' || error.status === 403 ? 'forbidden' : 'unavailable',
|
|
34
|
+
message: 'Essential related records could not be read. Check parent identity, table/field permissions and connection.' };
|
|
35
|
+
}
|
|
36
|
+
return [relationship.name, result];
|
|
37
|
+
}));
|
|
38
|
+
record._essential = Object.fromEntries(entries);
|
|
39
|
+
return record;
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Schema-backed investigation paths; no universal priority order is assumed.
|
|
2
|
+
export const fieldBehaviorHelp = [
|
|
3
|
+
'Investigate the observed symptom; there is no fixed priority order for every field problem.',
|
|
4
|
+
'Query filtering: jsn b4rules list --query "collection=incident^active=true" --json',
|
|
5
|
+
'Before-query rules use sys_script.when=before and the Boolean action_query=true.',
|
|
6
|
+
'Dictionary entry: jsn records list --table sys_dictionary --query "name=incident^element=caller_id" --json',
|
|
7
|
+
'Inherited dictionary overrides: jsn records list --table sys_dictionary_override --query "name=incident^element=caller_id" --json',
|
|
8
|
+
'Inspect base_table and inherited tables as needed; the dictionary entry is separate from its overrides.',
|
|
9
|
+
'Access controls: jsn acls list --query "nameINincident,incident.*,incident.caller_id,*.caller_id,*,*.*" --json',
|
|
10
|
+
'Also inspect inherited-table and wildcard ACLs. Exact name=incident alone omits field/wildcard rules.',
|
|
11
|
+
'ACL rows are evidence, not proof of effective access: operation, roles, conditions and scripts matter.',
|
|
12
|
+
'Form behavior: jsn clientscripts list --query "table=incident^active=true" --json',
|
|
13
|
+
'Form policies: jsn uipolicies list --query "table=incident^active=true" --json',
|
|
14
|
+
'Use the command help and show output to inspect the relevant records. Never bypass a business rule, ACL or other protection to make a symptom disappear.',
|
|
15
|
+
].join('\n');
|