@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 +2 -1
- package/skills/servicenow/SKILL.md +20 -141
- package/src/capabilities.js +5 -0
- package/src/cli.js +2 -1
- package/src/commands/updatesets.js +62 -11
- package/src/mutations.js +2 -1
- package/src/sdk.js +67 -1
- package/src/update-set-xml.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.3.13-dev.
|
|
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.
|
|
7
|
+
version: "4.3.13"
|
|
18
8
|
repository: https://github.com/jacebenson/jsn
|
|
19
9
|
---
|
|
20
10
|
|
|
21
|
-
#
|
|
11
|
+
# ServiceNow with JSN
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
Discover commands with `jsn --help`, `jsn discover "search words"`, and
|
|
14
|
+
`jsn <command> --help`.
|
|
24
15
|
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
jsn --help # All commands grouped by domain
|
|
29
|
-
jsn <command> --help # Flags, subcommands, and examples
|
|
30
|
-
```
|
|
22
|
+
For development:
|
|
31
23
|
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
package/src/capabilities.js
CHANGED
|
@@ -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 ${
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
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
|
|
520
|
+
out,
|
|
513
521
|
bytes,
|
|
514
|
-
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${
|
|
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
|
+
}
|