@jacebenson/jsn 4.3.13-dev.88.1 → 4.3.13-dev.90.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.88.1",
3
+ "version": "4.3.13-dev.90.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": {
@@ -32,6 +32,7 @@
32
32
  "@inquirer/prompts": "^7.5.1",
33
33
  "better-sqlite3": "^13.0.3",
34
34
  "js-yaml": "^5.5.0",
35
+ "saxes": "^6.0.0",
35
36
  "turndown": "^7.2.4"
36
37
  },
37
38
  "devDependencies": {
@@ -1,154 +1,33 @@
1
1
  ---
2
2
  name: servicenow
3
- description: |
4
- Interact with ServiceNow instances via the jsn CLI. Use when working with ServiceNow
5
- development, administration, or data exploration. Handles tables, records, business rules,
6
- flows, script includes, ACLs, update sets, and more. Triggered by ServiceNow URLs
7
- (service-now.com, servicenow.com) or when the user mentions ServiceNow, jsn, servicenow,
8
- or related terms like tables, records, business rules, flows, script includes, ACLs,
9
- update sets, or encoded queries.
3
+ description: Use the jsn CLI for ServiceNow development, administration, and data exploration.
10
4
  license: MIT
11
- compatibility: |
12
- Requires jsn CLI (github.com/jacebenson/jsn). Install via:
13
- npm install -g @jacebenson/jsn
14
- Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
5
  metadata:
16
6
  author: jacebenson
17
- version: "4.3.12"
7
+ version: "4.3.13"
18
8
  repository: https://github.com/jacebenson/jsn
19
9
  ---
20
10
 
21
- # Jace's ServiceNow CLI (jsn)
11
+ # ServiceNow with JSN
22
12
 
23
- Explore and manage ServiceNow instances. Interactive paginated pickers with server-side search on all list commands.
13
+ Discover commands with `jsn --help`, `jsn discover "search words"`, and
14
+ `jsn <command> --help`.
24
15
 
25
- ## Discovery
16
+ - Verify the intended instance and select it explicitly with `--profile <name>`.
17
+ - Prefer purpose-built or records commands over `eval`. Explain the need and get
18
+ approval before using eval; avoid bypassing ServiceNow protections.
19
+ - Honor delete confirmations. Get approval for destructive work and Global +
20
+ Default mutations; use `--force` only for the approved operation.
26
21
 
27
- ```bash
28
- jsn --help # All commands grouped by domain
29
- jsn <command> --help # Flags, subcommands, and examples
30
- ```
22
+ For development:
31
23
 
32
- ## Interactive Picker
24
+ 1. Choose or create the application scope and update set.
25
+ 2. Do the work, keeping related changes together.
26
+ 3. Verify the results on the instance and report where changes were captured.
27
+ 4. Offer to create ATFs and instance scans to verify the outcomes.
28
+ 5. Ask whether anything else belongs in the update set. If the user confirms
29
+ it is finished, complete it with notes.
33
30
 
34
- All `list` subcommands use a paginated interactive picker in TTY mode:
35
- - Arrow through items (10 per page)
36
- - Type to search (server-side LIKE query)
37
- - Scroll past last loaded item to load next page
38
- - Enter to select, Esc to cancel
39
- - Falls back to table output with `--json`
40
-
41
- ## Agent Rules
42
-
43
- 1. **Output modes** — `--json` when parsing data; text when presenting to humans
44
- 2. **Use sys_id for updates** — All update/delete operations require sys_id
45
- 3. **Verify the intended instance** — Use `--profile <name>` for explicit targeting; never assume the default is right. `jsn auth switch <name>` changes the default.
46
- 4. **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.
47
- 5. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
48
-
49
- ## Command Hierarchy
50
-
51
- Pick the most specific tool for the job. **Never default to eval** — it's the last resort:
52
-
53
- 1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn catalogitems`, etc.
54
- 2. **`jsn records --table <name>`** — generic CRUD on any table
55
- 3. **`jsn docs search <query>`** — offline ServiceNow documentation search (no instance needed)
56
- 4. **`jsn rest`** — raw Table API escape hatch
57
- 5. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
58
- 6. **Ask the human** — if none of the above work
59
-
60
- ## Key Commands
61
-
62
- | Domain | Commands |
63
- |--------|---------|
64
- | **Core** | `incidents`, `changes`, `requests`, `tasks` |
65
- | **Catalog** | `catalogitems list/show/create` — items with variables and flow info |
66
- | **Automation** | `flows`, `actions`, `rules`, `triggers`, `scheduledjobs` |
67
- | **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
68
- | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
69
- | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
70
- | **Config** | `setup`, `auth`, `updatesets`, `scopes` |
71
- | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
72
- | **Developer** | `eval`, `rest`, `skill`, `version` |
73
-
74
- ### Customization evidence
75
-
76
- Use `jsn customizations --help` to discover bounded read-only capture and offline
77
- review/comparison. For example:
78
-
79
- ```sh
80
- jsn customizations capture --label review --customer-scope x_acme_app --json
81
- jsn customizations show <run_id> --json
82
- ```
83
-
84
- Read the CLI's interpretation, coverage and per-artifact triage before drawing
85
- conclusions. Verify ownership and current behavior before proposing changes;
86
- never recommend automatic reversion.
87
-
88
- ### Complete an update set
89
-
90
- Before development changes, identify the intended application scope and update
91
- set. Keep related changes together, use the appropriate update-set or application
92
- deployment mechanism, and report where changes were captured. Not all changes
93
- are update-set tracked. Only complete a set when the human explicitly asks,
94
- including sets you created; completing the current set returns selection to Default.
95
-
96
- ```bash
97
- jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
98
- ```
99
-
100
- `complete [name]` defaults to the current set when the name is omitted. Explicit
101
- names must be unique. Supply nonempty `--notes` in automation; real newlines
102
- separate bullets. A human terminal opens a notes editor when `--notes` is absent.
103
- Existing Description text is preserved and appended after confirmation. Use
104
- `--force` only after approval, or the profile's `skip_confirmations` policy.
105
- Notes apply only to the selected set. ServiceNow's normal batch rules can complete
106
- child sets when the parent completes; their descriptions are not replaced.
107
- Both selected-set Description and state are read back before success. On
108
- verification failure, inspect the set before retrying.
109
-
110
- ### Table and raw REST discovery
111
-
112
- Use `jsn records list --help`, `jsn records get --help`, and `jsn rest --help`
113
- for syntax and examples. REST is blocked on read-only connections, including GET.
114
-
115
- ### `jsn docs` — offline documentation search
116
-
117
- No ServiceNow instance required. Searches the full ServiceNow docs locally with FTS5 + semantic hybrid search.
118
-
119
- ```bash
120
- # First run: download docs and build the index (~45k files, ~3-5 min)
121
- jsn docs sync
122
-
123
- # Check state
124
- jsn docs status
125
-
126
- # Search (keyword + semantic hybrid)
127
- jsn docs search "GlideRecord query" --limit 5 --json
128
-
129
- # Filter by bundle
130
- jsn docs search "REST API" --bundle it-service-management --json
131
-
132
- # Start the web UI
133
- jsn docs serve
134
-
135
- ```
136
-
137
- **Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
138
-
139
- ## Troubleshooting Field Behavior
140
-
141
- Check order (priority):
142
-
143
- 1. **Before-query business rules** — `sys_script` with `when=before`, `action=query`
144
- ```bash
145
- jsn b4rules list --query "collection=sys_user"
146
- ```
147
- 2. **ACLs** — `jsn acls list --query "name=incident"`
148
- 3. **Dictionary overrides** — `jsn records list --table sys_dictionary --query "name=incident^element=caller_id"`
149
- 4. **Client scripts** / **UI policies** — form-level field behavior
150
-
151
- ## Configuration
152
-
153
- Use `jsn setup` for interactive configuration. `jsn setup --help` lists the
154
- scriptable `jsn auth` equivalents and secure authentication input options.
31
+ For incoming update sets: import the XML, preview the set, and resolve preview
32
+ errors with the human where needed. Once the preview is clear, commit the set
33
+ and verify the results.
@@ -134,3 +134,8 @@ export function mutationPaths(caps = collectCapabilities()) {
134
134
  export function _resetCapabilities() {
135
135
  REGISTRY.clear();
136
136
  }
137
+
138
+ /** Declared local-only previews bypass instance access, including context headers. */
139
+ export function isLocalPreview(argv, caps = collectCapabilities()) {
140
+ return argv.dryRun === true && caps.get(argv._?.[0])?.localPreviewSubcommands?.includes(argv._?.[1]) === true;
141
+ }
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp, renderTips } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
12
  import { skippedUpdatesCmd } from './commands/skipped-updates.js';
13
- import { noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
13
+ import { isLocalPreview, noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
14
14
  import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
15
15
  import { formatCommandList } from './helpers.js';
16
16
 
@@ -258,6 +258,7 @@ export function buildCLI() {
258
258
  .middleware(async (argv) => {
259
259
  // Session (incl. --instance/--profile overrides) was already applied
260
260
  // by the middleware above — nothing to re-apply here.
261
+ if (isLocalPreview(argv)) return;
261
262
  const cmd = (argv._[0] || '').toString();
262
263
  // Daily-check + context-header skip-list is derived from command
263
264
  // capability declarations (skipDailyChecks: true).
@@ -1,17 +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
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'],
14
- mutationContextExemptSubcommands: ['create', 'set'],
15
+ mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent', 'import', 'reopen'],
16
+ mutationContextExemptSubcommands: ['create', 'set', 'import', 'reopen'],
17
+ localPreviewSubcommands: ['import'],
15
18
  });
16
19
 
17
20
  /**
@@ -382,7 +385,7 @@ export async function formatUpdateSetDetail(app, record) {
382
385
  });
383
386
  hints.push({
384
387
  action: 'export',
385
- cmd: `jsn updatesets export "${name}" --out ${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.xml`,
388
+ cmd: `jsn updatesets export "${name}" --out ${quoteShellArg(updateSetFilename(name))}`,
386
389
  description: 'Export this update set to XML',
387
390
  });
388
391
  if (!isClosed) {
@@ -478,11 +481,14 @@ export function updateSetsCmd(wrap) {
478
481
  })
479
482
  .command({
480
483
  command: 'export <name>',
481
- describe: 'Export an update set to XML (raw XML to stdout, or --out file)',
484
+ describe: 'Export XML without changing state; auto-name a file in a terminal, preserve piped stdout',
482
485
  builder: (y) => y
483
486
  .positional('name', { describe: 'Update set name', type: 'string' })
484
- .option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' }),
487
+ .option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
488
+ .option('out-dir', { type: 'string', describe: 'Write an automatically named XML file in this directory' })
489
+ .option('stdout', { type: 'boolean', describe: 'Write raw XML to stdout, including in a terminal' }),
485
490
  handler: wrap(async (argv, app) => {
491
+ if ([Boolean(argv.out), Boolean(argv.outDir), Boolean(argv.stdout)].filter(Boolean).length > 1) throw errUsage('Choose one of --out, --out-dir, or --stdout');
486
492
  app.requireInstance();
487
493
  const record = await resolveUpdateSetByName(app, argv.name);
488
494
  const sysID = getStringField(record, 'sys_id');
@@ -504,14 +510,16 @@ export function updateSetsCmd(wrap) {
504
510
  }
505
511
 
506
512
  const bytes = Buffer.byteLength(xml, 'utf-8');
507
- if (argv.out) {
508
- await fs.promises.writeFile(argv.out, xml);
513
+ const out = argv.out || (argv.outDir || (process.stdout.isTTY && !argv.stdout && !argv.json && argv.format !== 'json')
514
+ ? path.join(argv.outDir || '.', updateSetFilename(getStringField(record, 'name'))) : null);
515
+ if (out) {
516
+ await fs.promises.writeFile(out, xml, { flag: 'wx' });
509
517
  app.ok({
510
518
  update_set: getStringField(record, 'name'),
511
519
  sys_id: sysID,
512
- out: argv.out,
520
+ out,
513
521
  bytes,
514
- }, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${argv.out}` });
522
+ }, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${out}` });
515
523
  return;
516
524
  }
517
525
 
@@ -519,7 +527,7 @@ export function updateSetsCmd(wrap) {
519
527
  // (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
520
528
  // envelope when the user explicitly asks for it — auto format
521
529
  // detection must NOT hijack the payload for piped consumers.
522
- if (argv.json || argv.format === 'json') {
530
+ if (!argv.stdout && (argv.json || argv.format === 'json')) {
523
531
  app.ok({
524
532
  update_set: getStringField(record, 'name'),
525
533
  sys_id: sysID,
@@ -531,6 +539,47 @@ export function updateSetsCmd(wrap) {
531
539
  process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
532
540
  }),
533
541
  })
542
+ .command({
543
+ command: 'import <file>',
544
+ describe: 'Load XML as a retrieved update set; never preview, commit, or select it automatically',
545
+ builder: y => y
546
+ .positional('file', { type: 'string', describe: 'Update set XML export' })
547
+ .option('dry-run', { type: 'boolean', describe: 'Inspect locally without any instance requests' })
548
+ .option('yes', { type: 'boolean', describe: 'Confirm loading the displayed XML into the selected target' }),
549
+ handler: wrap(async (argv, app) => {
550
+ const stat = await fs.promises.stat(argv.file);
551
+ if (!stat.isFile() || stat.size > MAX_UPDATE_SET_BYTES) throw errUsage('Expected an XML file no larger than 50 MiB');
552
+ const xml = await fs.promises.readFile(argv.file, 'utf8');
553
+ const inspection = inspectUpdateSetXML(xml);
554
+ const { members, ...summary } = inspection;
555
+ if (argv.dryRun) return app.ok({ ...summary, members, dry_run: true }, { summary: `XML: ${inspection.name}; ${inspection.count} updates (local inspection only)` });
556
+ app.requireInstance();
557
+ const question = `Load "${inspection.name}" (${inspection.count} updates) into ${app.getEffectiveInstance()} as a retrieved update set? This does not commit its changes.`;
558
+ if (!argv.yes) {
559
+ if (!canPrompt() || argv.json || argv.get || (argv.format && argv.format !== FormatAuto)) {
560
+ throw new AppError(CodeConfirmationRequired, `${question} — confirmation required. Pass --yes after approving this target.`,
561
+ `Rerun: ${buildRerunCommand(argv, { rerun: ['updatesets', 'import', argv.file, '--yes'] })}`);
562
+ }
563
+ if (!await confirm({ message: question, default: false })) return;
564
+ }
565
+ const result = await app.sdk.importUpdateSet(xml, inspection, path.basename(argv.file));
566
+ app.ok({ ...summary, ...result, committed: false }, { summary: `Loaded retrieved update set "${inspection.name}" (${result.count} updates); not committed` });
567
+ }),
568
+ })
569
+ .command({
570
+ command: 'reopen <name>',
571
+ describe: 'Explicitly return a complete or ignored local update set to in progress; does not select it',
572
+ handler: wrap(async (argv, app) => {
573
+ const target = await resolveCompletionTarget(app, argv.name);
574
+ const record = await fetchUpdateSetById(app, target.sys_id);
575
+ const state = String(record?.state?.value ?? record?.state ?? '').toLowerCase();
576
+ if (!['complete', 'ignore'].includes(state)) throw errUsage(`Cannot reopen an update set in state "${state}"; expected complete or ignore`);
577
+ await app.sdk.update('sys_update_set', target.sys_id, { state: 'in progress' });
578
+ const mismatches = await verifyWriteBack(app, 'sys_update_set', target.sys_id, { state: 'in progress' });
579
+ if (mismatches.length) throw errAPI(200, 'Reopen could not be verified; inspect the update set before retrying');
580
+ app.ok({ update_set: target.name, sys_id: target.sys_id, state: 'in progress' }, { summary: `Reopened update set: ${target.name}` });
581
+ }),
582
+ })
534
583
  .command({
535
584
  command: 'set [name]',
536
585
  describe: 'Set the current update set (interactive picker with scope when run bare)',
@@ -738,6 +787,8 @@ export function updateSetsCmd(wrap) {
738
787
  console.log(' list List update sets (shows scope)');
739
788
  console.log(' show <name> Show an update set (type counts + risky items)');
740
789
  console.log(' export <name> Export an update set to XML (stdout or --out)');
790
+ console.log(' import <file> Load retrieved XML (--dry-run inspects locally; --yes confirms)');
791
+ console.log(' reopen <name> Explicitly return a closed set to in progress');
741
792
  console.log(' set [name] Set the current update set (picker when run bare)');
742
793
  console.log(' create Create a new update set (auto-sets as current)');
743
794
  console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
package/src/mutations.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // create` (read-only bypass), and listed `restmethods`, a command that was
9
9
  // never registered. Derived data can't drift that way.
10
10
 
11
- import { mutationPaths } from './capabilities.js';
11
+ import { mutationPaths, isLocalPreview } from './capabilities.js';
12
12
 
13
13
  /**
14
14
  * Back-compat export: the derived mutation path list, refreshed by
@@ -42,6 +42,7 @@ function currentPaths(paths) {
42
42
  * @returns {boolean}
43
43
  */
44
44
  export function isMutationCommand(argv, paths) {
45
+ if (isLocalPreview(argv)) return false;
45
46
  const cmd = argv._ || [];
46
47
  for (const pattern of currentPaths(paths)) {
47
48
  if (pattern.length > cmd.length) continue;
package/src/sdk.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { batchRead } from './sdk-batch.js';
4
4
  import { gunzipSync } from 'node:zlib';
5
- import { CodeAuth, errAuth, errAPI, errNetwork } from './errors.js';
5
+ import { AppError, CodeAPI, CodeAuth, errAuth, errAPI, errNetwork, errUsage } from './errors.js';
6
6
  import { getStringField } from './helpers.js';
7
7
  import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
8
8
  import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
@@ -768,6 +768,72 @@ export class SDKClient {
768
768
  });
769
769
  }
770
770
 
771
+ /** Load through the native XML upload processor, then verify the exact envelope. */
772
+ async importUpdateSet(xml, inspection, filename = 'update-set.xml') {
773
+ const { sys_id: id, remote_sys_id: sourceID } = inspection;
774
+ const params = new URLSearchParams({
775
+ sysparm_query: `sys_id=${id}^ORremote_sys_id=${sourceID}`,
776
+ sysparm_fields: 'sys_id,name,state', sysparm_limit: '2', sysparm_display_value: 'false',
777
+ });
778
+ if ((await this.list('sys_remote_update_set', params)).length) {
779
+ throw errUsage('This retrieved update set already exists on the target; refusing to overwrite it. Inspect it before retrying.');
780
+ }
781
+ // Native INSERT_OR_UPDATE must not move/overwrite a member belonging to another set.
782
+ for (let offset = 0; offset < inspection.members.length; offset += 50) {
783
+ const ids = inspection.members.slice(offset, offset + 50).map(member => member.sys_id).join(',');
784
+ const existing = await this.list('sys_update_xml', new URLSearchParams({
785
+ sysparm_query: `sys_idIN${ids}`, sysparm_fields: 'sys_id', sysparm_limit: '1',
786
+ }));
787
+ if (existing.length) throw errUsage('An XML member already exists on the target; refusing to overwrite or move existing updates.');
788
+ }
789
+ const cookies = await this._warmSession();
790
+ const formURL = `${this.baseURL}/upload.do?sysparm_target=sys_remote_update_set`;
791
+ const html = await this.rawRequest(formURL, { method: 'GET', headers: cookies ? { Cookie: cookies } : {} });
792
+ const csrf = html.match(/name=["']sysparm_ck["'][^>]*value=["']([^"']+)["']/i)?.[1];
793
+ if (!csrf || !/action=["']\/sys_upload\.do["']/i.test(html) || !/name=["']attachFile["']/i.test(html)) {
794
+ throw errUsage('The native XML upload form is unavailable; no XML was uploaded. Check admin access and session authentication.');
795
+ }
796
+ const form = new FormData();
797
+ form.set('sysparm_ck', csrf);
798
+ form.set('sysparm_target', 'sys_remote_update_set');
799
+ form.set('sysparm_upload_prefix', '');
800
+ form.set('sysparm_referring_url', 'sys_remote_update_set_list.do');
801
+ form.set('attachFile', new Blob([xml], { type: 'application/xml' }), filename);
802
+ let outcome = 'unknown';
803
+ try {
804
+ await this.rawRequest(`${this.baseURL}/sys_upload.do`, { method: 'POST', body: form,
805
+ headers: { ...(cookies ? { Cookie: cookies } : {}), 'X-UserToken': csrf, Referer: formURL },
806
+ });
807
+ const records = await this.list('sys_remote_update_set', params);
808
+ if (records.length !== 1) throw new Error('Could not identify exactly one uploaded retrieved update set');
809
+ const record = records[0];
810
+ const importedID = getStringField(record, 'sys_id');
811
+ const state = getStringField(record, 'state');
812
+ if (state !== 'loaded' || getStringField(record, 'name') !== inspection.name) throw new Error('Uploaded update set name/state did not match the loaded export');
813
+ const members = [];
814
+ for (let offset = 0; ; offset += 500) {
815
+ const page = await this.list('sys_update_xml', new URLSearchParams({
816
+ sysparm_query: `remote_update_set=${importedID}^ORDERBYsys_id`, sysparm_limit: '500',
817
+ sysparm_offset: String(offset), sysparm_display_value: 'false', sysparm_fields: 'sys_id,name,action',
818
+ }));
819
+ members.push(...page);
820
+ if (members.length > inspection.count) throw new Error('Uploaded update set contains unexpected extra members');
821
+ if (page.length < 500) break;
822
+ }
823
+ const actual = new Map(members.map(member => [getStringField(member, 'sys_id'), member]));
824
+ if (actual.size !== inspection.count || inspection.members.some(expected => {
825
+ const member = actual.get(expected.sys_id);
826
+ return !member || getStringField(member, 'name') !== expected.name || getStringField(member, 'action') !== expected.action;
827
+ })) throw new Error('Uploaded update set members did not match the XML export');
828
+ outcome = 'succeeded';
829
+ return { sys_id: importedID, state, count: members.length, link: `${this.baseURL}/sys_remote_update_set.do?sys_id=${importedID}` };
830
+ } catch (error) {
831
+ throw new AppError(CodeAPI, `XML import could not be verified: ${error.message}. It may have partially loaded; inspect Retrieved Update Sets before retrying. No automatic retry was attempted.`, 'Inspect Retrieved Update Sets before retrying.', 0, error);
832
+ } finally {
833
+ if (this.onMutation) await this._reportMutation({ table: 'sys_remote_update_set', sys_id: id, operation: 'create', outcome });
834
+ }
835
+ }
836
+
771
837
  /**
772
838
  * Execute a background script on the ServiceNow instance via sys.scripts.do.
773
839
  * Uses a 3-step session-establishment flow compatible with OAuth tokens:
@@ -0,0 +1,89 @@
1
+ // Parse the export envelope without evaluating its payload or resolving entities.
2
+ import { SaxesParser } from 'saxes';
3
+ import { errUsage } from './errors.js';
4
+
5
+ export const MAX_UPDATE_SET_BYTES = 50 * 1024 * 1024;
6
+ const ID = /^[a-f0-9]{32}$/i;
7
+
8
+ export function updateSetFilename(name) {
9
+ const stem = String(name).normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 180);
10
+ return `${stem || 'update-set'}.xml`;
11
+ }
12
+
13
+ export function inspectUpdateSetXML(xml) {
14
+ if (Buffer.byteLength(xml, 'utf8') > MAX_UPDATE_SET_BYTES) throw errUsage('Update set XML exceeds the 50 MiB limit');
15
+ const parser = new SaxesParser();
16
+ const stack = [], sets = [], updates = [];
17
+ let record;
18
+ parser.on('doctype', () => { throw errUsage('DOCTYPE and external entities are not allowed in update set XML'); });
19
+ parser.on('opentag', tag => {
20
+ stack.push(tag.name);
21
+ if (stack.length === 1 && tag.name !== 'unload') throw errUsage('Expected an update set export with an <unload> root');
22
+ if (stack.length === 2) {
23
+ if (!['sys_remote_update_set', 'sys_update_xml'].includes(tag.name) || tag.attributes.action !== 'INSERT_OR_UPDATE') {
24
+ throw errUsage('Only INSERT_OR_UPDATE sys_remote_update_set and sys_update_xml envelope records may be imported');
25
+ }
26
+ record = {};
27
+ }
28
+ if (stack.length === 3) {
29
+ if (Object.hasOwn(record, tag.name)) throw errUsage(`Duplicate XML field: ${tag.name}`);
30
+ record[tag.name] = '';
31
+ }
32
+ if (stack.length > 3) throw errUsage('Update payloads must be XML text or CDATA, not nested envelope elements');
33
+ });
34
+ const text = value => { if (stack.length === 3) record[stack[2]] += value; };
35
+ parser.on('text', text); parser.on('cdata', text);
36
+ parser.on('closetag', () => {
37
+ if (stack.length === 2) (stack[1] === 'sys_remote_update_set' ? sets : updates).push(record);
38
+ stack.pop();
39
+ });
40
+ try { parser.write(xml).close(); } catch (error) { throw errUsage(`Invalid update set XML: ${error.message}`); }
41
+ if (sets.length !== 1) throw errUsage('Import exactly one retrieved update set per XML file');
42
+ const set = sets[0];
43
+ if (!ID.test(set.sys_id || '') || !ID.test(set.remote_sys_id || '') || !set.name?.trim()) {
44
+ throw errUsage('Update set requires a name, sys_id, and remote_sys_id');
45
+ }
46
+ if (set.sys_class_name && set.sys_class_name !== 'sys_remote_update_set') throw errUsage('Invalid retrieved update set class');
47
+ if (set.state !== 'loaded' || set.update_set?.trim()) throw errUsage('Expected a loaded, uncommitted retrieved update set export');
48
+ const seen = new Set();
49
+ const summary = new Map(), payloadOperations = new Map();
50
+ for (const update of updates) {
51
+ if (!ID.test(update.sys_id || '') || seen.has(update.sys_id.toLowerCase()) || update.remote_update_set !== set.sys_id
52
+ || update.update_set?.trim() || !update.name || !update.payload?.trim()
53
+ || !['INSERT_OR_UPDATE', 'DELETE'].includes(update.action)) {
54
+ throw errUsage('Invalid, duplicate, or unrelated update XML member');
55
+ }
56
+ if (update.sys_class_name && update.sys_class_name !== 'sys_update_xml') throw errUsage('Invalid update XML class');
57
+ seen.add(update.sys_id.toLowerCase());
58
+ // Payload XML stays inert; parse it solely to describe the affected table.
59
+ const payload = new SaxesParser(); let table = '', depth = 0, primary = '';
60
+ payload.on('doctype', () => { throw errUsage('DOCTYPE is not allowed in update payloads'); });
61
+ payload.on('opentag', tag => {
62
+ depth++;
63
+ if (depth === 1 && tag.name !== 'record_update') throw errUsage('Expected a record_update payload');
64
+ if (depth === 1) table = tag.attributes.table || '';
65
+ if (depth === 2) {
66
+ if (!primary) {
67
+ primary = tag.name;
68
+ if (table && table !== primary) throw errUsage('Payload table does not match its primary record');
69
+ table = primary;
70
+ if (tag.attributes.action && tag.attributes.action !== update.action) throw errUsage('Payload action does not match the update member action');
71
+ }
72
+ const action = tag.attributes.action || 'platform_default';
73
+ if (!['INSERT_OR_UPDATE', 'INSERT', 'UPDATE', 'DELETE', 'delete_multiple', 'platform_default'].includes(action)) throw errUsage('Unsupported payload record action');
74
+ const key = `${tag.name}:${action}`;
75
+ const operation = payloadOperations.get(key) || { table: tag.name, action, count: 0 };
76
+ operation.count++; payloadOperations.set(key, operation);
77
+ }
78
+ });
79
+ payload.on('closetag', () => { depth--; });
80
+ try { payload.write(update.payload).close(); } catch (error) { throw errUsage(`Invalid update payload: ${error.message}`); }
81
+ if (!primary || !/^[a-z][a-z0-9_]*$/i.test(table)) throw errUsage(`Missing or invalid payload table for ${update.name}`);
82
+ const key = `${table}:${update.action}`;
83
+ const row = summary.get(key) || { table, action: update.action, count: 0 };
84
+ row.count++; summary.set(key, row);
85
+ }
86
+ return { name: set.name, sys_id: set.sys_id, remote_sys_id: set.remote_sys_id,
87
+ state: set.state, count: updates.length, summary: [...summary.values()], payload_operations: [...payloadOperations.values()],
88
+ members: updates.map(update => ({ sys_id: update.sys_id, name: update.name, target_name: update.target_name || '', action: update.action })) };
89
+ }