@jacebenson/jsn 4.3.13-dev.89.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.89.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": {
@@ -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
+ }