@jacebenson/jsn 4.3.8 → 4.3.9
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 +122 -0
- package/package.json +4 -2
- package/skills/servicenow/SKILL.md +54 -0
- package/src/cli.js +4 -0
- package/src/commands/actions.js +89 -2
- package/src/commands/catalog.js +2 -2
- package/src/commands/customizations.js +82 -0
- package/src/commands/flows.js +22 -3
- package/src/commands/skipped-updates.js +102 -0
- package/src/commands/updatesets.js +91 -31
- package/src/context.js +16 -25
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +517 -0
- package/src/flow-publish.js +15 -3
- package/src/impact-analysis.js +67 -0
- package/src/sdk.js +33 -2
- package/src/skipped-update-provider.js +45 -0
- package/src/skipped-updates.js +177 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { checkbox, select, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { canUsePicker, interactiveList, getStringField } from '../helpers.js';
|
|
3
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
4
|
+
import { errUsage } from '../errors.js';
|
|
5
|
+
import { discoverSkippedUpdates, reviewSkippedUpdate, prepareSkippedBatch, processSkippedBatch, raw, SKIP_TABLE } from '../skipped-updates.js';
|
|
6
|
+
|
|
7
|
+
const label = row => `${raw(row.sys_id)} ${getStringField(row, 'target_name') || getStringField(row, 'file_name')}`;
|
|
8
|
+
const selectionOptions = y => y
|
|
9
|
+
.option('ids', { type: 'string', describe: 'Comma-separated literal finding sys_ids, maximum 100' })
|
|
10
|
+
.option('action', { type: 'string', choices: ['retain', 'revert'], describe: 'Explicit selected action, never inferred from recommendations' });
|
|
11
|
+
|
|
12
|
+
export function renderSkippedPreview(preview) {
|
|
13
|
+
const lines = [`Instance: ${preview.instance}`, `Exact selected batch: ${preview.count} finding(s)`];
|
|
14
|
+
for (const item of preview.selected) {
|
|
15
|
+
lines.push(`\n${item.sys_id}: ${item.artifact.update_name}`, `Action: ${item.action}`, item.operation,
|
|
16
|
+
`Recommendation only: ${item.impact.recommendation.action}. ${item.impact.recommendation.basis}`,
|
|
17
|
+
`State: disposition=${item.finding.disposition}, resolution=${item.finding.resolution_status}`,
|
|
18
|
+
`Evidence: base version ${item.impact.evidence.baseline_version || 'unknown'}, source ${item.impact.evidence.baseline_source_table || 'unknown'}/${item.impact.evidence.baseline_source || 'unknown'}`,
|
|
19
|
+
`Finding: ${preview.instance}/${SKIP_TABLE}.do?sys_id=${item.sys_id}`,
|
|
20
|
+
item.impact.direct_behavior.summary);
|
|
21
|
+
for (const change of item.impact.changes) lines.push(` ${change.field}: ${JSON.stringify(change.current)} -> ${JSON.stringify(change.base)}`, ` ${change.effect}`);
|
|
22
|
+
for (const dep of item.impact.known_dependencies) lines.push(` Known dependency: ${dep.table}.${dep.field} / ${dep.sys_id}`);
|
|
23
|
+
for (const gap of item.impact.unknown) lines.push(` Unknown: ${gap}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push(`\nConfirmation token: ${preview.confirmation_token}`, 'No writes have been performed.');
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function skippedUpdatesCmd(wrap, ui = {}) {
|
|
30
|
+
declareCapabilities('skipped-updates', { mutationSubcommands: ['process'] });
|
|
31
|
+
const prompts = { checkbox, select, confirm, canUsePicker, interactiveList, ...ui };
|
|
32
|
+
async function showReview(app, id) {
|
|
33
|
+
const review = await reviewSkippedUpdate(app.sdk, id);
|
|
34
|
+
const display = { instance: app.getEffectiveInstance(), selected: [{ ...review, action: 'none', operation: 'Read-only review.' }], count: 1, confirmation_token: 'not applicable' };
|
|
35
|
+
app.ok({ ...review, _formatted: renderSkippedPreview(display) }, { summary: 'Skipped update review; deeper effects remain unknown' });
|
|
36
|
+
}
|
|
37
|
+
async function selection(argv, app, guided) {
|
|
38
|
+
let ids = typeof argv.ids === 'string' ? argv.ids.split(',') : undefined;
|
|
39
|
+
let action = argv.action;
|
|
40
|
+
if (!ids && guided) {
|
|
41
|
+
const page = await discoverSkippedUpdates(app.sdk, { limit: 100, offset: 0 });
|
|
42
|
+
if (!page.count) throw errUsage('No unresolved skipped findings were returned.');
|
|
43
|
+
ids = await prompts.checkbox({ message: `Select findings from this page${page.has_more ? ' (more exist; use list --offset for others)' : ''}`,
|
|
44
|
+
choices: page.findings.map(row => ({ name: label(row), value: raw(row.sys_id) })), required: true });
|
|
45
|
+
}
|
|
46
|
+
if (!ids?.length) throw errUsage('Provide --ids with the exact selected finding sys_ids.');
|
|
47
|
+
if (!action && guided) action = await prompts.select({ message: 'Choose the action for these findings',
|
|
48
|
+
choices: [{ name: 'Retain customization, mark reviewed and retained', value: 'retain' },
|
|
49
|
+
{ name: 'Return to base system, discard customization', value: 'revert' }] });
|
|
50
|
+
return { ids, action, instance: app.getEffectiveInstance() };
|
|
51
|
+
}
|
|
52
|
+
return { command: 'skipped-updates', describe: 'Review and process existing unresolved skipped updates across all upgrade history',
|
|
53
|
+
builder: y => y
|
|
54
|
+
.command({ command: 'list', describe: 'Discover unresolved skips instance-wide, independent of new upgrades',
|
|
55
|
+
builder: y => y.option('limit', { type: 'number', default: 100, describe: 'Page size, maximum 500' })
|
|
56
|
+
.option('offset', { type: 'number', default: 0, describe: 'Page offset' }),
|
|
57
|
+
handler: wrap(async (argv, app) => {
|
|
58
|
+
app.requireInstance();
|
|
59
|
+
if (prompts.canUsePicker(app, argv)) {
|
|
60
|
+
const selected = await prompts.interactiveList({ app, table: SKIP_TABLE, singular: 'skipped update',
|
|
61
|
+
columns: ['target_name', 'file_name', 'disposition', 'resolution_status'], labelField: 'target_name',
|
|
62
|
+
query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed', formatLabel: label });
|
|
63
|
+
if (selected === undefined) return;
|
|
64
|
+
if (selected) { await showReview(app, raw(selected.sys_id)); return; }
|
|
65
|
+
}
|
|
66
|
+
const page = await discoverSkippedUpdates(app.sdk, argv);
|
|
67
|
+
app.ok(page, { summary: `${page.count} unresolved finding(s) fetched${page.has_more ? '; more available' : ''}. Instance-wide total is not measured.` });
|
|
68
|
+
}) })
|
|
69
|
+
.command({ command: 'review <sys-id>', describe: 'Review artifact evidence, field changes and bounded known dependencies',
|
|
70
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
71
|
+
await showReview(app, argv['sys-id']);
|
|
72
|
+
}) })
|
|
73
|
+
.command({ command: 'preview', describe: 'Read-only exact batch preview and confirmation token', builder: selectionOptions,
|
|
74
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
75
|
+
const options = await selection(argv, app, prompts.canUsePicker(app, argv));
|
|
76
|
+
const preview = await prepareSkippedBatch(app.sdk, options);
|
|
77
|
+
app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Read-only batch preview; review before confirming' });
|
|
78
|
+
}) })
|
|
79
|
+
.command({ command: 'process', describe: 'Apply selected action after ONE explicit confirmation of the exact preview',
|
|
80
|
+
builder: y => selectionOptions(y).option('confirm', { type: 'string', describe: 'Confirmation token from a previously reviewed exact preview; required in automation' }),
|
|
81
|
+
handler: wrap(async (argv, app) => { app.requireInstance();
|
|
82
|
+
try {
|
|
83
|
+
const guided = prompts.canUsePicker(app, argv);
|
|
84
|
+
const options = await selection(argv, app, guided);
|
|
85
|
+
const result = await processSkippedBatch({ sdk: app.sdk, ...options,
|
|
86
|
+
readOnly: app.session?.readOnly === true, confirmationToken: argv.confirm,
|
|
87
|
+
onPreview: guided ? async preview => app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Review exact batch before confirmation' }) : undefined,
|
|
88
|
+
confirm: guided && !argv.confirm ? async () => prompts.confirm({ message: 'Apply exactly this selected batch?', default: false }) : undefined });
|
|
89
|
+
app.ok({ ...result, _formatted: result.cancelled ? 'Cancelled. No writes performed.'
|
|
90
|
+
: result.results.map(row => `${row.sys_id} ${row.action}: ${row.success ? 'verified success' : `FAILED (${row.effect})`}`).join('\n') },
|
|
91
|
+
{ summary: `${result.success_count} succeeded; ${result.failure_count} failed${result.partial ? '; partial batch' : ''}` });
|
|
92
|
+
if (result.failure_count) process.exitCode = 1;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.name === 'ExitPromptError' || err.name === 'AbortPromptError') {
|
|
95
|
+
app.ok({ cancelled: true, writes_performed: false }, { summary: 'Cancelled. No writes performed.' }); return;
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}) })
|
|
100
|
+
.demandCommand(1, 'Choose list, review, preview or process'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { editor, confirm } from '@inquirer/prompts';
|
|
3
|
+
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
|
|
4
|
+
import { getActiveProfile } from '../config.js';
|
|
5
|
+
import { FormatAuto } from '../output.js';
|
|
3
6
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
4
|
-
import { errAPI, errUsage } from '../errors.js';
|
|
7
|
+
import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
5
8
|
import { declareCapabilities } from '../capabilities.js';
|
|
6
9
|
|
|
7
10
|
declareCapabilities('updatesets', {
|
|
@@ -133,6 +136,28 @@ async function fetchUpdateSetById(app, sysID) {
|
|
|
133
136
|
return Array.isArray(records) && records.length > 0 ? records[0] : null;
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
/** Completion refuses duplicate names rather than guessing a mutation target. */
|
|
140
|
+
async function resolveCompletionTarget(app, name) {
|
|
141
|
+
if (name !== undefined) {
|
|
142
|
+
if (!String(name).trim()) throw errUsage('Update set name must not be empty');
|
|
143
|
+
assertSafeExactMatch(name);
|
|
144
|
+
const params = new URLSearchParams({
|
|
145
|
+
sysparm_query: `name=${name}`, sysparm_limit: '2',
|
|
146
|
+
sysparm_display_value: 'all', sysparm_fields: 'sys_id,name',
|
|
147
|
+
});
|
|
148
|
+
const records = await app.sdk.list('sys_update_set', params);
|
|
149
|
+
if (!records.length) throw errUsage(`Update set not found: ${name}`);
|
|
150
|
+
if (records.length !== 1) {
|
|
151
|
+
throw errUsage(`Multiple update sets named "${name}". Select the intended set with "jsn updatesets set", then run "jsn updatesets complete" without a name.`);
|
|
152
|
+
}
|
|
153
|
+
return { sys_id: rawReferenceId(records[0].sys_id), name: getStringField(records[0], 'name') };
|
|
154
|
+
}
|
|
155
|
+
const userSysID = await requireCurrentUserSysId(app.sdk);
|
|
156
|
+
const current = await getCurrentUpdateSet(app.sdk, userSysID);
|
|
157
|
+
if (!current?.sys_id) throw errUsage('No current update set. Set one first:\n jsn updatesets set');
|
|
158
|
+
return current;
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
/** Build a safe, flat preorder hierarchy with ancestors before the selected set. */
|
|
137
162
|
export async function buildUpdateSetHierarchy(app, selectedRecord) {
|
|
138
163
|
const selectedID = getStringField(selectedRecord, 'sys_id');
|
|
@@ -535,25 +560,16 @@ export function updateSetsCmd(wrap) {
|
|
|
535
560
|
throw errUsage(`Cannot set "${name}" as current — update set is ${stateRaw === 'ignore' ? 'ignored' : 'complete'}.\nView it instead: jsn updatesets show "${name}"`);
|
|
536
561
|
}
|
|
537
562
|
const userSysID = await requireCurrentUserSysId(app.sdk);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
// switches the application scope into the set's scope. The
|
|
542
|
-
// application field is a sys_scope reference, so its raw value
|
|
543
|
-
// IS the scope sys_id the apps.current_app preference stores.
|
|
563
|
+
// Scope selection can reset the session's capture set. Select the
|
|
564
|
+
// scope first, then the requested set, and verify both picker writes.
|
|
565
|
+
const scopeSysId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
|
|
544
566
|
let scopeNote = '';
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const scopeName = updateSetApp?.display_value || updateSetApp;
|
|
550
|
-
scopeNote = ` — scope switched to ${scopeName}`;
|
|
551
|
-
}
|
|
552
|
-
} catch {
|
|
553
|
-
// scope switch failed — fall back to a warning
|
|
554
|
-
const currentApp = await getCurrentApplication(app.sdk, userSysID).catch(() => null);
|
|
555
|
-
scopeNote = scopeMismatchWarning(updateSetApp, currentApp);
|
|
567
|
+
if (scopeSysId) {
|
|
568
|
+
await setCurrentApplication(app.sdk, userSysID, scopeSysId);
|
|
569
|
+
const scopeName = updateSetApp?.display_value || updateSetApp;
|
|
570
|
+
scopeNote = ` — scope switched to ${scopeName}`;
|
|
556
571
|
}
|
|
572
|
+
await setCurrentUpdateSet(app.sdk, userSysID, sysID);
|
|
557
573
|
|
|
558
574
|
app.ok({ update_set: name, sys_id: sysID }, { summary: `Current update set: ${name}${scopeNote}` });
|
|
559
575
|
}),
|
|
@@ -606,15 +622,18 @@ export function updateSetsCmd(wrap) {
|
|
|
606
622
|
|
|
607
623
|
// Auto-set as current update set
|
|
608
624
|
const sysID = record?.sys_id?.value || record?.sys_id;
|
|
625
|
+
let selectionNote = '';
|
|
609
626
|
try {
|
|
610
627
|
const userSysID = await requireCurrentUserSysId(app.sdk);
|
|
611
628
|
await setCurrentUpdateSet(app.sdk, userSysID, sysID);
|
|
612
|
-
} catch {
|
|
613
|
-
//
|
|
629
|
+
} catch (error) {
|
|
630
|
+
// Creation succeeded. Report partial success without hiding a
|
|
631
|
+
// failed selection or inviting a duplicate create on retry.
|
|
632
|
+
selectionNote = `; not selected as current: ${error.message}. Run jsn updatesets set "${argv.name}"`;
|
|
614
633
|
}
|
|
615
634
|
|
|
616
635
|
app.ok(record, {
|
|
617
|
-
summary: `Created update set: ${argv.name}`,
|
|
636
|
+
summary: `Created update set: ${argv.name}${selectionNote}`,
|
|
618
637
|
breadcrumbs: [
|
|
619
638
|
{
|
|
620
639
|
action: 'set',
|
|
@@ -631,16 +650,57 @@ export function updateSetsCmd(wrap) {
|
|
|
631
650
|
}),
|
|
632
651
|
})
|
|
633
652
|
.command({
|
|
634
|
-
command: 'complete',
|
|
635
|
-
describe: '
|
|
653
|
+
command: 'complete [name]',
|
|
654
|
+
describe: 'Complete a named or current update set with completion notes',
|
|
655
|
+
builder: (y) => y
|
|
656
|
+
.positional('name', { type: 'string', describe: 'Unique update set name, defaults to the current set' })
|
|
657
|
+
.option('notes', { type: 'string', describe: 'Completion notes, one bullet per line; opens an editor when omitted interactively' }),
|
|
636
658
|
handler: wrap(async (argv, app) => {
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
if (!current
|
|
640
|
-
|
|
659
|
+
const current = await resolveCompletionTarget(app, argv.name);
|
|
660
|
+
const record = await fetchUpdateSetById(app, current.sys_id);
|
|
661
|
+
if (!record) throw errUsage(`Update set not found or unreadable: ${current.sys_id}`);
|
|
662
|
+
if (!Object.hasOwn(record, 'description')
|
|
663
|
+
|| (record.description && typeof record.description === 'object' && !Object.hasOwn(record.description, 'value'))) {
|
|
664
|
+
throw errUsage(`Description is unreadable for update set "${current.name}" (${current.sys_id}); refusing to replace unknown text.`);
|
|
665
|
+
}
|
|
666
|
+
const existing = String(record.description && typeof record.description === 'object'
|
|
667
|
+
? record.description.value ?? '' : record.description ?? '');
|
|
668
|
+
const interactive = !argv.json && !argv.get && (!argv.format || argv.format === FormatAuto)
|
|
669
|
+
&& app.output?.getFormat() === FormatAuto && canPrompt();
|
|
670
|
+
let notes = argv.notes;
|
|
671
|
+
let description;
|
|
672
|
+
try {
|
|
673
|
+
if (notes === undefined) {
|
|
674
|
+
if (!interactive) throw errUsage('Provide nonempty completion notes with --notes in noninteractive mode');
|
|
675
|
+
notes = await editor({ message: `Completion notes for "${current.name}". Write concise bullets, one per line` });
|
|
676
|
+
}
|
|
677
|
+
const bullets = String(notes).split(/\r?\n/)
|
|
678
|
+
.map(line => line.trim().replace(/^[-*•]\s*/, '').trim()).filter(Boolean)
|
|
679
|
+
.map(line => `- ${line}`).join('\n');
|
|
680
|
+
if (!bullets) throw errUsage('Provide nonempty completion notes with --notes');
|
|
681
|
+
description = existing ? `${existing}\n\n${bullets}` : bullets;
|
|
682
|
+
if (existing && !argv.force && getActiveProfile(app.config)?.skip_confirmations !== true) {
|
|
683
|
+
const question = `Append notes and complete "${current.name}"?\nExisting Description:\n${existing}\n\nNew notes:\n${bullets}`;
|
|
684
|
+
if (!interactive) throw errConfirmationRequired(question, buildRerunCommand(argv, {
|
|
685
|
+
rerun: ['updatesets', 'complete', ...(argv.name !== undefined ? [argv.name] : []), '--notes', String(notes)],
|
|
686
|
+
}));
|
|
687
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
688
|
+
}
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (['ExitPromptError', 'AbortPromptError'].includes(error.name)) return;
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
const data = { state: 'complete', description };
|
|
694
|
+
try {
|
|
695
|
+
await app.sdk.update('sys_update_set', current.sys_id, data);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
throw errAPI(error.status || 0, `Could not complete update set "${current.name}" (${current.sys_id}): ${error.message}. Check the record before retrying.`);
|
|
698
|
+
}
|
|
699
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', current.sys_id, data);
|
|
700
|
+
if (mismatches.length) {
|
|
701
|
+
throw errAPI(200, `Could not verify completion of update set "${current.name}" (${current.sys_id}): ${mismatches.map(item => item.field).join(', ')} did not persist or could not be read. The write may have partially succeeded; inspect the set before retrying.`);
|
|
641
702
|
}
|
|
642
|
-
|
|
643
|
-
app.ok({ update_set: current.name, state: 'complete' }, { summary: `Update set marked complete: ${current.name}` });
|
|
703
|
+
app.ok({ update_set: current.name, sys_id: current.sys_id, state: 'complete', description }, { summary: `Update set marked complete: ${current.name}` });
|
|
644
704
|
}),
|
|
645
705
|
})
|
|
646
706
|
.command({
|
|
@@ -667,7 +727,7 @@ export function updateSetsCmd(wrap) {
|
|
|
667
727
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
668
728
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
669
729
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
670
|
-
console.log(' complete
|
|
730
|
+
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|
|
671
731
|
console.log(' ignore Ignore the current update set');
|
|
672
732
|
console.log(' parent <name> Set the parent of an update set');
|
|
673
733
|
console.log('\nRun "jsn updatesets <command> --help" for details.');
|
package/src/context.js
CHANGED
|
@@ -63,25 +63,16 @@ export async function getCurrentUpdateSet(sdk, userSysID) {
|
|
|
63
63
|
return { name: val, sys_id: val };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (prefs.length > 0) {
|
|
77
|
-
await sdk.update('sys_user_preference', getStringField(prefs[0], 'sys_id'), { value });
|
|
78
|
-
} else {
|
|
79
|
-
await sdk.create('sys_user_preference', {
|
|
80
|
-
user: userSysID,
|
|
81
|
-
name,
|
|
82
|
-
value,
|
|
83
|
-
type: 'string',
|
|
84
|
-
});
|
|
66
|
+
// Use the same selection contract as the platform picker. Table API writes to
|
|
67
|
+
// sys_user_preference do not update an already-warmed session's capture context.
|
|
68
|
+
async function selectContext(sdk, picker, data, expected) {
|
|
69
|
+
const endpoint = `${sdk.baseURL}/api/now/ui/concoursepicker/${picker}`;
|
|
70
|
+
await sdk.request(endpoint, { method: 'PUT', body: JSON.stringify(data) });
|
|
71
|
+
const response = await sdk.request(endpoint, { method: 'GET' });
|
|
72
|
+
const current = response?.result?.current;
|
|
73
|
+
const actual = picker === 'application' ? current : current?.sysId;
|
|
74
|
+
if (actual !== expected) {
|
|
75
|
+
throw new Error(`${picker === 'application' ? 'Application' : 'Update set'} selection was not applied. Expected ${expected}, received ${typeof actual === 'string' ? actual : 'an unreadable selection'}.`);
|
|
85
76
|
}
|
|
86
77
|
}
|
|
87
78
|
|
|
@@ -98,12 +89,12 @@ export async function requireCurrentUserSysId(sdk) {
|
|
|
98
89
|
return getStringField(user[0], 'sys_id');
|
|
99
90
|
}
|
|
100
91
|
|
|
101
|
-
/**
|
|
102
|
-
export async function setCurrentApplication(sdk,
|
|
103
|
-
await
|
|
92
|
+
/** Select application scope in the authenticated session and verify it. */
|
|
93
|
+
export async function setCurrentApplication(sdk, _userSysID, scopeSysID) {
|
|
94
|
+
await selectContext(sdk, 'application', { app_id: scopeSysID }, scopeSysID);
|
|
104
95
|
}
|
|
105
96
|
|
|
106
|
-
/**
|
|
107
|
-
export async function setCurrentUpdateSet(sdk,
|
|
108
|
-
await
|
|
97
|
+
/** Select the capture update set in the authenticated session and verify it. */
|
|
98
|
+
export async function setCurrentUpdateSet(sdk, _userSysID, updateSetSysID) {
|
|
99
|
+
await selectContext(sdk, 'updateset', { sysId: updateSetSysID }, updateSetSysID);
|
|
109
100
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CLASSIFICATIONS = {
|
|
5
|
+
ootb_modifications: {
|
|
6
|
+
label: 'Historical upgrade skip',
|
|
7
|
+
confidence: 'Historical evidence only',
|
|
8
|
+
summary: 'An upgrade record says the platform preserved this artifact. That does not prove it currently differs from the vendor version.',
|
|
9
|
+
next_steps: [
|
|
10
|
+
'Compare the current artifact with a trusted baseline for this exact release.',
|
|
11
|
+
'Review later update-set history for a merge, reversion, or further edit.',
|
|
12
|
+
'Check callers and add a regression test in non-production before considering a revert.',
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
customer_app_artifacts: {
|
|
16
|
+
label: 'Caller-declared customer scope',
|
|
17
|
+
confidence: 'Declaration, not verified ownership',
|
|
18
|
+
summary: 'The scope record matched a scope declared by the person who captured this report. The instance does not verify customer or publisher ownership.',
|
|
19
|
+
next_steps: [
|
|
20
|
+
'Confirm the application owner and publisher with the responsible team.',
|
|
21
|
+
'Check whether the artifact is active or referenced, then review its tests and update-set history.',
|
|
22
|
+
'Treat it as customer-owned only after an owner confirms the declaration.',
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
unclassified: {
|
|
26
|
+
label: 'Unclassified metadata',
|
|
27
|
+
confidence: 'Insufficient evidence',
|
|
28
|
+
summary: 'Visible metadata was found, but this capture could not establish whether it is customer-owned or differs from an out-of-box artifact.',
|
|
29
|
+
next_steps: [
|
|
30
|
+
'Identify the owner using application records, update-set history, and team knowledge.',
|
|
31
|
+
'Check current references and expected behavior before changing or deleting it.',
|
|
32
|
+
'Compare with a trusted vendor baseline only when evidence shows this is vendor-delivered.',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const humanize = value => String(value || '').replace(/^sys_/, '').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/\bui\b/gi, 'UI').replace(/\b[a-z]/g, letter => letter.toUpperCase());
|
|
38
|
+
const categoryLabel = category => ({ ootb_modifications: 'Historical upgrade skips', customer_app_artifacts: 'Declared customer scopes', unclassified: 'Unclassified metadata' })[category] || humanize(category);
|
|
39
|
+
const itemName = item => {
|
|
40
|
+
const { name = '', update_name: updateName = '', table = '', sys_id: sysId = '' } = item.identity || {};
|
|
41
|
+
if (name && name !== sysId && name !== updateName) return /[_]|[a-z][A-Z]/.test(name) ? humanize(name) : name;
|
|
42
|
+
if (updateName) {
|
|
43
|
+
const prefixes = [table, table.replace(/_[^_]+$/, ''), table.replace(/_[^_]+_[^_]+$/, '')].filter(Boolean);
|
|
44
|
+
const prefix = prefixes.find(candidate => updateName.startsWith(`${candidate}_`));
|
|
45
|
+
return humanize(prefix ? updateName.slice(prefix.length + 1) : updateName) || 'Name unavailable';
|
|
46
|
+
}
|
|
47
|
+
return sysId ? 'Name unavailable' : 'Unnamed artifact';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function classifyCustomization(category) {
|
|
51
|
+
const classification = CLASSIFICATIONS[category] || CLASSIFICATIONS.unclassified;
|
|
52
|
+
return {
|
|
53
|
+
code: CLASSIFICATIONS[category] ? category : 'unclassified',
|
|
54
|
+
label: classification.label,
|
|
55
|
+
confidence: classification.confidence,
|
|
56
|
+
summary: classification.summary,
|
|
57
|
+
next_steps: [...classification.next_steps],
|
|
58
|
+
// There is no verified baseline or dependency graph to support safe automatic remediation.
|
|
59
|
+
automated_remediation: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recordUrl(instance, table, sysId) {
|
|
64
|
+
if (!/^[a-z0-9_]+$/i.test(String(table || '')) || !sysId) return null;
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(`${encodeURIComponent(table)}.do`, `${String(instance).replace(/\/+$/, '')}/`);
|
|
67
|
+
if (!['http:', 'https:'].includes(url.protocol)) return null;
|
|
68
|
+
url.searchParams.set('sys_id', String(sysId));
|
|
69
|
+
return url.href;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildActionableReport(report) {
|
|
76
|
+
const artifacts = Object.entries(report.categories || {}).flatMap(([category, items]) => items.map(item => ({
|
|
77
|
+
category,
|
|
78
|
+
category_label: categoryLabel(category),
|
|
79
|
+
identity: item.identity || {},
|
|
80
|
+
name: itemName(item),
|
|
81
|
+
table_label: humanize(item.identity?.table || 'unknown'),
|
|
82
|
+
scope: item.scope?.scope || item.scope?.sys_id || '(unknown scope)',
|
|
83
|
+
updated_by: item.identity?.updated_by || '(unknown updater)',
|
|
84
|
+
evidence: item.evidence || [],
|
|
85
|
+
risk: item.risk || { rating: 'unknown', rationale: 'Risk has not been established.' },
|
|
86
|
+
triage: classifyCustomization(category),
|
|
87
|
+
record_url: recordUrl(report.instance, item.identity?.table, item.identity?.sys_id),
|
|
88
|
+
})));
|
|
89
|
+
return {
|
|
90
|
+
run_id: report.run_id,
|
|
91
|
+
label: report.label || '',
|
|
92
|
+
instance: report.instance,
|
|
93
|
+
status: report.status,
|
|
94
|
+
start_time: report.start_time,
|
|
95
|
+
artifacts,
|
|
96
|
+
coverage: report.coverage,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const safeJson = value => JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, character => ({ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026', '\u2028': '\\u2028', '\u2029': '\\u2029' })[character]);
|
|
101
|
+
const escapeHtml = value => String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
102
|
+
|
|
103
|
+
export function renderCustomizationHtml(report) {
|
|
104
|
+
const view = buildActionableReport(report);
|
|
105
|
+
const categoryOptions = [...new Set(view.artifacts.map(item => item.category))].map(category => `<option value="${escapeHtml(category)}">${escapeHtml(categoryLabel(category))}</option>`).join('');
|
|
106
|
+
const data = safeJson(view);
|
|
107
|
+
return `<!doctype html>
|
|
108
|
+
<html lang="en">
|
|
109
|
+
<head>
|
|
110
|
+
<meta charset="utf-8">
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
112
|
+
<title>Customization review${view.label ? `: ${escapeHtml(view.label)}` : ''}</title>
|
|
113
|
+
<style>
|
|
114
|
+
:root{color-scheme:light;--ink:#202833;--muted:#526173;--line:#d8dee7;--panel:#fff;--wash:#f3f6fa;--blue:#1358a5;--amber:#825300}*{box-sizing:border-box}body{margin:0;background:var(--wash);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:1180px;margin:auto;padding:32px 20px 60px}h1,h2,h3,p{margin-top:0}h1{font-size:1.8rem;margin-bottom:4px}h2{font-size:1.25rem;margin:0 0 14px}h3{font-size:1.1rem;margin:0}small,.muted{color:var(--muted)}.intro,.toolbar,.coverage,.group,.card{background:var(--panel);border:1px solid var(--line);border-radius:10px}.intro,.toolbar,.coverage{padding:18px 20px;margin-bottom:16px}.intro p:last-child{margin-bottom:0}.stats{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.stat{background:var(--wash);border-radius:8px;padding:10px 14px}.toolbar{display:flex;flex-wrap:wrap;gap:12px;align-items:end;position:sticky;top:0;z-index:2}.control{display:grid;gap:4px}.control.grow{flex:1;min-width:220px}input,select{font:inherit;padding:8px 10px;border:1px solid #aeb9c7;border-radius:6px;background:white;color:var(--ink)}.group{padding:18px;margin:16px 0}.group>h2{border-bottom:1px solid var(--line);padding-bottom:10px}.card{padding:16px;margin:12px 0}.card-head{display:flex;gap:12px;align-items:flex-start;justify-content:space-between}.badge{display:inline-block;border-radius:100px;background:#e9f0fb;color:#183e71;padding:3px 9px;font-size:.83rem;font-weight:650}.confidence{color:var(--amber);font-size:.9rem;font-weight:650}.meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin:10px 0;color:var(--muted);font-size:.92rem}.card h4{margin:12px 0 4px}.card ul{margin:4px 0 12px;padding-left:22px}.card a{color:var(--blue)}.coverage summary{cursor:pointer;font-weight:650}.coverage li{margin:6px 0}.empty{padding:20px;text-align:center;color:var(--muted)}.notice{border-left:4px solid #d39219;padding:8px 12px;background:#fff7e6;margin:12px 0}
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body><main>
|
|
118
|
+
<header class="intro"><h1>Customization review</h1><p>${escapeHtml(view.label || view.run_id)} · ${escapeHtml(view.instance)}</p><p class="muted">Status: ${escapeHtml(view.status)}. This report gives review steps, not automatic fixes. Confirm evidence before changing records.</p><div class="stats" id="stats"></div><p class="notice">A historical upgrade skip does not prove a current difference. A scope or updater name does not prove ownership. The broad metadata source is a bounded sample.</p></header>
|
|
119
|
+
<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
|
+
<section id="results" aria-live="polite"></section>
|
|
121
|
+
<details class="coverage"><summary>Capture coverage and limitations</summary><div id="coverage-content"></div></details>
|
|
122
|
+
<script id="report-data" type="application/json">${data}</script>
|
|
123
|
+
<script>
|
|
124
|
+
'use strict';
|
|
125
|
+
const report=JSON.parse(document.getElementById('report-data').textContent);
|
|
126
|
+
const results=document.getElementById('results');
|
|
127
|
+
const search=document.getElementById('search');
|
|
128
|
+
const category=document.getElementById('category');
|
|
129
|
+
const groupBy=document.getElementById('group-by');
|
|
130
|
+
const el=(tag,text,className='')=>{const node=document.createElement(tag);if(text!==undefined)node.textContent=text;if(className)node.className=className;return node;};
|
|
131
|
+
const groupName=item=>groupBy.value==='scope'?item.scope:groupBy.value==='updated_by'?item.updated_by:item.table_label;
|
|
132
|
+
const addList=(parent,title,items)=>{parent.append(el('h4',title));const ul=el('ul');for(const text of items)ul.append(el('li',text));parent.append(ul);};
|
|
133
|
+
function card(item){
|
|
134
|
+
const article=el('article',undefined,'card');const head=el('div',undefined,'card-head');const title=el('h3',item.name);head.append(title,el('span',item.triage.confidence,'confidence'));article.append(head);
|
|
135
|
+
article.append(el('p',item.triage.summary));const meta=el('div',undefined,'meta');meta.append(el('span',item.table_label+' · '+(item.identity.sys_id||'ID unavailable')),el('span','Scope: '+item.scope),el('span','Updated by: '+item.updated_by));article.append(meta,el('span',item.category_label,'badge'));
|
|
136
|
+
addList(article,'Evidence',item.evidence.length?item.evidence.map(e=>e.source+': '+e.sys_id+'. '+e.basis):['No artifact evidence was captured.']);addList(article,'Next steps',item.triage.next_steps);article.append(el('p','Risk: '+(item.risk.rating||'unknown')+'. '+(item.risk.rationale||'Not established.')));
|
|
137
|
+
if(item.record_url){const link=el('a','Open record in ServiceNow');link.href=item.record_url;link.rel='noopener noreferrer';link.target='_blank';article.append(link);}return article;
|
|
138
|
+
}
|
|
139
|
+
function render(){
|
|
140
|
+
const term=search.value.trim().toLowerCase();const selected=category.value;
|
|
141
|
+
const filtered=report.artifacts.filter(item=>(!selected||item.category===selected)&&(!term||JSON.stringify(item).toLowerCase().includes(term)));
|
|
142
|
+
results.replaceChildren();if(!filtered.length){results.append(el('p','No artifacts match these filters.','empty'));return;}
|
|
143
|
+
const groups=new Map();for(const item of filtered){const key=groupName(item);if(!groups.has(key))groups.set(key,[]);groups.get(key).push(item);}
|
|
144
|
+
for(const [name,items] of [...groups].sort(([a],[b])=>a.localeCompare(b))){const section=el('section',undefined,'group');section.append(el('h2',name+' ('+items.length+')'));for(const item of items)section.append(card(item));results.append(section);}
|
|
145
|
+
}
|
|
146
|
+
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
|
+
search.addEventListener('input',render);category.addEventListener('change',render);groupBy.addEventListener('change',render);render();
|
|
148
|
+
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));
|
|
149
|
+
</script>
|
|
150
|
+
</main></body></html>\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function writeCustomizationHtml(report, { out } = {}) {
|
|
154
|
+
const runId = String(report.run_id || 'capture').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
155
|
+
const target = path.resolve(out || path.join(process.cwd(), `customizations-${runId}.html`));
|
|
156
|
+
fs.writeFileSync(target, renderCustomizationHtml(report), { mode: 0o600, flag: 'wx' });
|
|
157
|
+
return target;
|
|
158
|
+
}
|