@jacebenson/jsn 4.3.13-dev.72.1 → 4.3.13-dev.74.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.72.1",
3
+ "version": "4.3.13-dev.74.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ compatibility: |
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
  metadata:
16
16
  author: jacebenson
17
- version: "4.3.6"
17
+ version: "4.3.10"
18
18
  repository: https://github.com/jacebenson/jsn
19
19
  ---
20
20
 
@@ -43,10 +43,9 @@ All `list` subcommands use a paginated interactive picker in TTY mode:
43
43
  1. **Output modes** — `--json` when parsing data; text when presenting to humans
44
44
  2. **Use sys_id for updates** — All update/delete operations require sys_id
45
45
  3. **Check auth first** — Run `jsn auth status` before operations
46
- 4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
47
- 5. **Use `--profile <name>`** to target a specific instance, or `jsn auth switch <name>` to change the default
48
- 6. **Before using `eval` or `rest`** — Ask: *"Is there a more specific jsn command?"* Prefer specific over generic over escape hatch over eval.
49
- 7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
46
+ 4. **Verify the intended instance** — Use `--profile <name>` for explicit targeting; never assume the default is right. `jsn auth switch <name>` changes the default.
47
+ 5. **Eval safety** — Prefer purpose-built or `records` commands. Never use `eval` to bypass a blocked business rule, ACL, or other ServiceNow protection. If eval is genuinely needed, explain why and obtain explicit user approval.
48
+ 6. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
50
49
 
51
50
  ## Command Hierarchy
52
51
 
@@ -75,44 +74,26 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
75
74
 
76
75
  ### Customization evidence
77
76
 
77
+ Use `jsn customizations --help` to discover bounded read-only capture and offline
78
+ review/comparison. For example:
79
+
78
80
  ```sh
79
81
  jsn customizations capture --label review --customer-scope x_acme_app --json
80
- jsn customizations list --json
81
- jsn customizations show <run_id> --get data.categories
82
- jsn customizations show <run_id> --get data.triage
83
- jsn customizations html <run_id> [--out review.html]
84
- jsn customizations compare <baseline_id> <new_id> --json
82
+ jsn customizations show <run_id> --json
85
83
  ```
86
84
 
87
- Capture performs bounded GET-only table reads and saves local reports. It never
88
- evals or creates, updates or deletes instance records. Standard authentication
89
- may refresh OAuth tokens, store refreshed credentials locally and bootstrap a
90
- session/GCK using authentication POSTs. List/show/compare are offline.
91
- Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
92
- never infer customer ownership from a non-global scope. The declaration is
93
- caller evidence, not independent publisher verification. OOTB findings reflect
94
- historical upgrade skips, not a current verified baseline deviation. The upgrade
95
- history is read server-side for the skip dispositions and every observed skipped
96
- `file_name` is resolved against `sys_metadata` by chunked
97
- `sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
98
- skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
99
- slice and is reported as a bounded sample, not an inventory, describing only the
100
- unclassified and declared-customer categories. Unresolved skipped names and
101
- failed lookup chunks stay explicit rather than becoming absent artifacts, and a
102
- resolved artifact can lack a scope name when its scope record was outside the
103
- bounded `sys_scope` read. Missing
104
- ACL access, pagination limits and unmatched evidence remain explicit. All
105
- reports are incomplete; no findings is not proof of no customizations.
106
- Comparisons cover captured metadata/evidence, not source payloads. Removals mean
107
- no longer observed, not proven deletion. Reversion impact and dependencies
108
- remain unknown until separately investigated. Never recommend automatic reversion.
109
-
110
- Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
111
- `4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
112
- excludes that history row. Use raw values, not display labels or other skip reasons.
85
+ Read the CLI's interpretation, coverage and per-artifact triage before drawing
86
+ conclusions. Verify ownership and current behavior before proposing changes;
87
+ never recommend automatic reversion.
113
88
 
114
89
  ### Complete an update set
115
90
 
91
+ Before development changes, identify the intended application scope and update
92
+ set. Keep related changes together, use the appropriate update-set or application
93
+ deployment mechanism, and report where changes were captured. Not all changes
94
+ are update-set tracked. Only complete a set when the human explicitly asks,
95
+ including sets you created; completing the current set returns selection to Default.
96
+
116
97
  ```bash
117
98
  jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
118
99
  ```
@@ -127,18 +108,10 @@ child sets when the parent completes; their descriptions are not replaced.
127
108
  Both selected-set Description and state are read back before success. On
128
109
  verification failure, inspect the set before retrying.
129
110
 
130
- ### Table and raw REST examples
131
-
132
- ```bash
133
- # Query a table with the normal records command
134
- jsn records list --table incident --query "active=true" --limit 5 --json
135
-
136
- # Get a single record
137
- jsn records get --table incident --sys-id abc123... --json
111
+ ### Table and raw REST discovery
138
112
 
139
- # Raw endpoint access when no specific command fits
140
- jsn rest "/api/now/table/incident?sysparm_limit=3" --json
141
- ```
113
+ Use `jsn records list --help`, `jsn records get --help`, and `jsn rest --help`
114
+ for syntax and examples. REST is blocked on read-only connections, including GET.
142
115
 
143
116
  ### `jsn docs` — offline documentation search
144
117
 
package/src/auth.js CHANGED
@@ -612,7 +612,9 @@ export class AuthManager {
612
612
  if (!instance) {
613
613
  throw errAuth('No instance specified');
614
614
  }
615
+ const existed = !!this.credentialStore.load(instance, username);
615
616
  this.credentialStore.delete(instance, username);
617
+ return existed && !this.credentialStore.load(instance, username);
616
618
  }
617
619
 
618
620
  /**
@@ -1,5 +1,5 @@
1
1
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
- import { errUsage, errUsageHint } from '../errors.js';
2
+ import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
3
3
  import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
  import process from 'node:process';
@@ -808,10 +808,33 @@ Examples:
808
808
  }
809
809
  const target = resolveTargetProfile(argv, app.config, instanceURL);
810
810
  const username = target.profile?.username ?? null;
811
- if (username || canLogoutBareProfile(app, instanceURL, target.profile, !!argv.profile)) {
812
- app.auth.logout(instanceURL, username);
811
+ if (!username && !canLogoutBareProfile(app, instanceURL, target.profile, !!target.name)) {
812
+ throw errUsageHint('Cannot identify which stored credentials to clear.', 'Choose an unambiguous identity with --profile <name>.');
813
813
  }
814
- app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
814
+ const selector = target.name ? `--profile ${quoteShellArg(target.name)}` : quoteShellArg(instanceURL);
815
+ const rerun = `jsn auth logout ${selector}`;
816
+ const warning = `Clear locally stored credentials for ${target.name ? `profile "${target.name}" at ` : ''}${instanceURL}${username ? ` (user ${username})` : ''}? You will need to authenticate again. The profile is retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`;
817
+ if (!argv.force) {
818
+ if (!canUsePicker(app, argv)) throw errConfirmationRequired(warning, rerun);
819
+ const { confirm } = await import('../prompts.js');
820
+ if (!await confirm({ message: warning, default: false })) {
821
+ app.ok({ logged_out: false, credentials_cleared: false, profile: target.name, instance: instanceURL },
822
+ { summary: 'Logout cancelled; stored credentials were retained.' });
823
+ return;
824
+ }
825
+ }
826
+ const cleared = app.auth.logout(instanceURL, username) === true;
827
+ const nextActions = cleared ? [
828
+ { action: 'Authenticate again', cmd: `jsn auth login ${selector}`, description: 'Restore locally stored authentication' },
829
+ ...(target.name ? [{ action: 'Remove retained profile', cmd: `jsn auth remove ${quoteShellArg(target.name)}`, description: 'Remove this connection if no longer needed' }] : []),
830
+ ] : [];
831
+ app.ok({ logged_out: cleared, credentials_cleared: cleared, profile: target.name, instance: instanceURL,
832
+ profile_retained: !!target.name, environment_credentials_affected: false, remote_session_terminated: false,
833
+ next_actions: nextActions }, {
834
+ summary: cleared ? `✓ Cleared local credentials for ${target.name || instanceURL}. Profile retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`
835
+ : `No stored credentials were cleared for ${target.name || instanceURL}. Environment credentials and ServiceNow/Chrome sessions are unaffected.`,
836
+ breadcrumbs: nextActions,
837
+ });
815
838
  }
816
839
 
817
840
  export async function showAuthStatus(argv, app) {
@@ -1099,7 +1122,8 @@ export function authCmd(wrap) {
1099
1122
  })
1100
1123
  .command({
1101
1124
  command: 'logout [instance]',
1102
- describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
1125
+ describe: 'Clear local credentials after approval; retains the profile. Unnecessary after ordinary operations; does not sign out of ServiceNow/Chrome or clear environment credentials.',
1126
+ builder: sub => sub.option('force', { type: 'boolean', describe: 'Clear credentials only after explicit approval for this exact logout' }),
1103
1127
  handler: wrap(logoutProfile),
1104
1128
  })
1105
1129
  .command({
@@ -4,6 +4,7 @@ import { canUsePicker } from '../helpers.js';
4
4
  import { paginatedSearch } from '../paginated-search.js';
5
5
  import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
6
6
  import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
7
+ import { customizationGuidance, formatCustomizationGuidance } from '../customization-guidance.js';
7
8
 
8
9
  // Only capture needs an instance. Saved reports never need network access.
9
10
  declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
@@ -25,7 +26,7 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
25
26
  function show(app, report, options = {}) {
26
27
  const actionable = buildActionableReport(report);
27
28
  const triage = actionable.artifacts.map(({ category, category_label, identity, triage: classification }) => ({ category, category_label, identity, ...classification }));
28
- app.ok({ ...report, triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
29
+ app.ok({ ...report, interpretation: customizationGuidance(), triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
29
30
  }
30
31
  export function customizationsCmd(wrap) {
31
32
  return {
@@ -36,7 +37,8 @@ export function customizationsCmd(wrap) {
36
37
  builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
37
38
  .option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
38
39
  .option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
39
- .option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' }),
40
+ .option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
41
+ .epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nExample: jsn customizations capture --label review --customer-scope x_acme_app --json`),
40
42
  handler: wrap(async (argv, app) => {
41
43
  app.requireInstance();
42
44
  if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
@@ -55,18 +57,21 @@ export function customizationsCmd(wrap) {
55
57
  })
56
58
  .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
57
59
  .option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
58
- .option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' }), handler: wrap(async (argv, app) => {
60
+ .option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
61
+ .epilogue(`Offline review: inspect coverage, interpretation and triage in --json output.\n${formatCustomizationGuidance()}\nExample: jsn customizations show <run_id> --get data.triage`), handler: wrap(async (argv, app) => {
59
62
  const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
60
63
  if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
61
64
  }) })
62
65
  .command({ command: 'html [run_id]', describe: 'Write a searchable, self-contained HTML review of a saved capture', builder: b => b
63
- .option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' }), handler: wrap(async (argv, app) => {
66
+ .option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
67
+ .epilogue('Offline review with the same interpretation and coverage as show; writes a local file without changing instance records.\nExample: jsn customizations html <run_id> --out review.html'), handler: wrap(async (argv, app) => {
64
68
  const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
65
69
  if (!report) return;
66
70
  const file = writeCustomizationHtml(report, { out: argv.out });
67
- app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0) }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
71
+ app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0), interpretation: customizationGuidance() }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
68
72
  }) })
69
- .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
73
+ .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
74
+ builder: b => b.epilogue(`Offline metadata/evidence comparison; inspect both captures' coverage before interpreting changes.\n${formatCustomizationGuidance()}\nExample: jsn customizations compare <baseline_id> <new_id> --json`), handler: wrap(async (argv, app) => {
70
75
  if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
71
76
  if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
72
77
  const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
@@ -76,7 +81,8 @@ export function customizationsCmd(wrap) {
76
81
  if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
77
82
  const result = compareReports(baseline, newer);
78
83
  app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
79
- }) }),
84
+ }) })
85
+ .epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nStart: jsn customizations capture --help\nReview: jsn customizations show --help\nCompare: jsn customizations compare --help`),
80
86
  handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
81
87
  };
82
88
  }
@@ -83,7 +83,15 @@ export function recordsCmd(wrap) {
83
83
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
84
84
  .option('limit', { type: 'number', default: 20, describe: 'Max records' })
85
85
  .option('offset', { type: 'number', default: 0, describe: 'Offset' })
86
- .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' }),
86
+ .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' })
87
+ .epilogue([
88
+ 'In an interactive terminal with automatic output, browse a picker (10 visible items).',
89
+ 'Use arrow keys to browse, type to search sys_id on the server, scroll to load more,',
90
+ 'and press Enter to select or Esc to cancel. --sys-id skips the picker.',
91
+ 'Use --json for noninteractive structured output; pipes and explicit formats also skip the picker.',
92
+ 'Example: jsn records list --table incident --query "active=true" --limit 5 --json',
93
+ 'Discover fields with: jsn columns --table incident',
94
+ ].join('\n')),
87
95
  handler: wrap(async (argv, app) => {
88
96
  const table = argv.table;
89
97
  const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
@@ -187,7 +195,8 @@ export function recordsCmd(wrap) {
187
195
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
188
196
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
189
197
  .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' }),
198
+ .option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
199
+ .epilogue('Example: jsn records get --table incident --sys-id <sys_id> --json'),
191
200
  handler: wrap(async (argv, app) => {
192
201
  assertSafeExactMatch(argv['sys-id']);
193
202
  const params = new URLSearchParams();
@@ -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'];
@@ -119,9 +119,8 @@ function saveSkillConfig(updates) {
119
119
  fs.writeFileSync(globalConfigPath(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
120
120
  }
121
121
 
122
- function recordSkillInstall(targetPath) {
123
- const bundled = readBundledSkill();
124
- const version = extractVersion(bundled) || 'unknown';
122
+ function recordSkillInstall(targetPath, content) {
123
+ const version = extractVersion(content) || 'unknown';
125
124
  saveSkillConfig({
126
125
  skillLocation: targetPath,
127
126
  skillVersion: version,
@@ -260,7 +259,7 @@ export function skillCmd(wrap) {
260
259
  })
261
260
  .command({
262
261
  command: 'install [dir]',
263
- describe: 'Download and save the latest skill file',
262
+ describe: 'Install the bundled skill matched to this CLI version',
264
263
  builder: (y) => y
265
264
  .positional('dir', {
266
265
  type: 'string',
@@ -271,9 +270,10 @@ export function skillCmd(wrap) {
271
270
  describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit for interactive picker.',
272
271
  }),
273
272
  handler: wrap(async (argv, app) => {
274
- const res = await fetch(SKILL_RAW_URL);
275
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
276
- const content = await res.text();
273
+ const content = readBundledSkill();
274
+ if (!content) {
275
+ throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md). Reinstall jsn to restore it.');
276
+ }
277
277
 
278
278
  // Resolve target directories
279
279
  let targets = [];
@@ -389,7 +389,7 @@ export function skillCmd(wrap) {
389
389
  }
390
390
 
391
391
  // Record install location so checkSkill knows where to look
392
- recordSkillInstall(targets[0].path);
392
+ recordSkillInstall(targets[0].path, content);
393
393
 
394
394
  const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
395
395
  const summary = targets.length === 1
@@ -399,7 +399,7 @@ export function skillCmd(wrap) {
399
399
  const okOpts = {
400
400
  summary,
401
401
  breadcrumbs: [
402
- { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall to default target' },
402
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Reinstall the bundled skill to default target' },
403
403
  ],
404
404
  };
405
405
 
@@ -407,7 +407,7 @@ export function skillCmd(wrap) {
407
407
  okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
408
408
  }
409
409
 
410
- app.ok({ installed, from: SKILL_RAW_URL }, okOpts);
410
+ app.ok({ installed, from: SKILL_REPO_PATH }, okOpts);
411
411
  }),
412
412
  });
413
413
  },
@@ -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
  }
@@ -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 formatReportDetailed(report) {
299
- const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
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.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
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
- 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: ${upgrade.unresolved_update_names.length}${upgrade.lookup_failures ? `; failed lookup chunks: ${upgrade.lookup_failures}` : ''}`);
306
- if (upgrade.unresolved_update_names.length) lines.push(`Unresolved skipped update names (not visible in sys_metadata): ${upgrade.unresolved_update_names.slice(0, 20).join(', ')}${upgrade.unresolved_update_names.length > 20 ? `, ... and ${upgrade.unresolved_update_names.length - 20} more` : ''}`);
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
- for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}: ${coverage.status}, ${coverage.fetched}/${coverage.available ?? 'unknown'} rows${coverage.kind ? ` (${coverage.kind})` : ''}`);
361
- lines.push('Limits: metadata is a bounded sample; skipped-upgrade evidence is historical, not proof of a current OOTB difference. Scope and updater are clues, not proof of ownership.');
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 only describe sampled artifacts. 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.'],
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
  }