@jacebenson/jsn 4.0.4 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/package.json +4 -3
- package/src/app.js +48 -64
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -65
- package/src/commands/_ticket.js +37 -93
- package/src/commands/approvals.js +350 -0
- package/src/commands/atf.js +635 -0
- package/src/commands/auth.js +93 -13
- package/src/commands/catalog/enrich.js +139 -0
- package/src/commands/catalog.js +25 -81
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +63 -111
- package/src/commands/dev/_simple.js +16 -12
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/domains.js +4 -0
- package/src/commands/dev/eval.js +65 -7
- package/src/commands/dev/flows.js +3 -0
- package/src/commands/dev/logs.js +97 -0
- package/src/commands/dev/rest.js +6 -0
- package/src/commands/dev/restmessage.js +1 -0
- package/src/commands/dev/scopes.js +49 -25
- package/src/commands/dev/scrapi.js +5 -0
- package/src/commands/dev/soapmessages.js +1 -0
- package/src/commands/dev/updatesets.js +145 -15
- package/src/commands/dev.js +3 -2
- package/src/commands/docs/db.js +14 -0
- package/src/commands/docs/docs.js +9 -21
- package/src/commands/docs/search.js +33 -10
- package/src/commands/groups.js +3 -0
- package/src/commands/records.js +274 -12
- package/src/commands/setup.js +5 -0
- package/src/commands/skill.js +3 -0
- package/src/commands/snippets.js +155 -0
- package/src/commands/tickets.js +9 -29
- package/src/commands/users.js +9 -35
- package/src/commands/version.js +3 -0
- package/src/errors.js +107 -1
- package/src/help.js +1 -0
- package/src/helpers.js +131 -15
- package/src/mutations.js +36 -91
- package/src/output.js +195 -64
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
package/src/output.js
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
// Output formatting: JSON, Markdown, Styled, Quiet
|
|
2
2
|
|
|
3
3
|
import process from 'node:process';
|
|
4
|
-
import chalk from 'chalk';
|
|
5
4
|
|
|
6
5
|
export const FormatAuto = 'auto';
|
|
7
6
|
export const FormatJSON = 'json';
|
|
8
7
|
export const FormatMarkdown = 'markdown';
|
|
9
8
|
export const FormatStyled = 'styled';
|
|
10
9
|
export const FormatQuiet = 'quiet';
|
|
10
|
+
export const FormatCSV = 'csv';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a dotpath (`.data[0].number`, `data.records.0.sys_id`) against an
|
|
14
|
+
* object. Supports dotted keys and `[N]` array indexes. Returns undefined
|
|
15
|
+
* when the path doesn't resolve. Dependency-free — not a jq replacement.
|
|
16
|
+
*/
|
|
17
|
+
export function resolvePath(obj, path) {
|
|
18
|
+
const s = String(path || '').trim().replace(/^\./, '');
|
|
19
|
+
if (!s) return undefined;
|
|
20
|
+
// Tokenize on dots, '[' and ']'. Pure-integer segments become array indexes.
|
|
21
|
+
const tokens = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < s.length) {
|
|
24
|
+
const ch = s[i];
|
|
25
|
+
if (ch === '.' || ch === '[' || ch === ']') { i += 1; continue; }
|
|
26
|
+
let j = i;
|
|
27
|
+
while (j < s.length && s[j] !== '.' && s[j] !== '[' && s[j] !== ']') j += 1;
|
|
28
|
+
const part = s.slice(i, j);
|
|
29
|
+
const n = Number(part);
|
|
30
|
+
tokens.push(Number.isInteger(n) && String(n) === part ? n : part);
|
|
31
|
+
i = j;
|
|
32
|
+
}
|
|
33
|
+
let cur = obj;
|
|
34
|
+
for (const tok of tokens) {
|
|
35
|
+
if (cur == null) return undefined;
|
|
36
|
+
cur = cur[tok];
|
|
37
|
+
}
|
|
38
|
+
return cur;
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
export function hyperlink(text, url) {
|
|
13
42
|
if (!url) return text;
|
|
@@ -28,6 +57,37 @@ export function isTTY(writer) {
|
|
|
28
57
|
return writer.isTTY === true;
|
|
29
58
|
}
|
|
30
59
|
|
|
60
|
+
// Default styled-table column widths for well-known ServiceNow fields.
|
|
61
|
+
// Pure presentation defaults — commands can override per envelope via
|
|
62
|
+
// `data.columnWidths` (see writeRecordsTable).
|
|
63
|
+
const DEFAULT_COLUMN_WIDTHS = {
|
|
64
|
+
number: 14,
|
|
65
|
+
short_description: 48,
|
|
66
|
+
priority: 14,
|
|
67
|
+
state: 10,
|
|
68
|
+
assigned_to: 15,
|
|
69
|
+
risk: 10,
|
|
70
|
+
name: 30,
|
|
71
|
+
user_name: 15,
|
|
72
|
+
email: 30,
|
|
73
|
+
sys_id: 32,
|
|
74
|
+
sys_updated_on: 22,
|
|
75
|
+
sys_created_on: 22,
|
|
76
|
+
opened_at: 22,
|
|
77
|
+
closed_at: 22,
|
|
78
|
+
sys_updated_by: 20,
|
|
79
|
+
sys_created_by: 20,
|
|
80
|
+
opened_by: 20,
|
|
81
|
+
u_category: 20,
|
|
82
|
+
u_subcategory: 20,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Minimal ANSI colors — replaces chalk (only used for error/hint styling,
|
|
86
|
+
// which is already TTY-gated by callers).
|
|
87
|
+
const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
|
|
88
|
+
const colorRed = ansi('31');
|
|
89
|
+
const colorYellow = ansi('33');
|
|
90
|
+
|
|
31
91
|
export class OutputWriter {
|
|
32
92
|
constructor(opts = {}) {
|
|
33
93
|
this.format = opts.format || FormatAuto;
|
|
@@ -44,6 +104,16 @@ export class OutputWriter {
|
|
|
44
104
|
return this;
|
|
45
105
|
}
|
|
46
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Set a dotpath extractor. When set, successful output is reduced to the
|
|
109
|
+
* value at the path (resolved against the JSON envelope) and printed as-is.
|
|
110
|
+
* Gives pipelines a dependency-free `--get` without needing jq.
|
|
111
|
+
*/
|
|
112
|
+
setJqFilter(path) {
|
|
113
|
+
this.jqFilter = path || '';
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
47
117
|
effectiveFormat() {
|
|
48
118
|
if (this.format === FormatAuto) {
|
|
49
119
|
return isTTY(this.writer) ? FormatStyled : FormatJSON;
|
|
@@ -52,6 +122,16 @@ export class OutputWriter {
|
|
|
52
122
|
}
|
|
53
123
|
|
|
54
124
|
ok(data, opts = {}) {
|
|
125
|
+
// --get extractor: resolve against the JSON envelope and print just that
|
|
126
|
+
// value as JSON. Takes precedence over styling/table formats — a path
|
|
127
|
+
// extract is inherently scriptable, not presentational.
|
|
128
|
+
if (this.jqFilter) {
|
|
129
|
+
const envelope = { ok: true, data, summary: opts.summary || '', breadcrumbs: opts.breadcrumbs || [] };
|
|
130
|
+
const value = resolvePath(envelope, this.jqFilter);
|
|
131
|
+
this.writer.write(JSON.stringify(value, null, 2) + '\n');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
55
135
|
const format = this.effectiveFormat();
|
|
56
136
|
switch (format) {
|
|
57
137
|
case FormatJSON:
|
|
@@ -60,6 +140,8 @@ export class OutputWriter {
|
|
|
60
140
|
return this.writeMarkdown(data, opts);
|
|
61
141
|
case FormatQuiet:
|
|
62
142
|
return this.writeQuiet(data);
|
|
143
|
+
case FormatCSV:
|
|
144
|
+
return this.writeCSV(data, opts);
|
|
63
145
|
case FormatStyled:
|
|
64
146
|
return this.writeStyled(data, opts);
|
|
65
147
|
default:
|
|
@@ -82,8 +164,8 @@ export class OutputWriter {
|
|
|
82
164
|
break;
|
|
83
165
|
default:
|
|
84
166
|
if (isTTY(this.writer)) {
|
|
85
|
-
this.writer.write(
|
|
86
|
-
if (e.hint) this.writer.write(
|
|
167
|
+
this.writer.write(colorRed(`Error (${e.code}): ${e.message}\n`));
|
|
168
|
+
if (e.hint) this.writer.write(colorYellow(`Hint: ${e.hint}\n`));
|
|
87
169
|
} else {
|
|
88
170
|
this.writer.write(JSON.stringify(resp, null, 2) + '\n');
|
|
89
171
|
}
|
|
@@ -155,7 +237,58 @@ export class OutputWriter {
|
|
|
155
237
|
}
|
|
156
238
|
}
|
|
157
239
|
|
|
240
|
+
writeCSV(data, opts) {
|
|
241
|
+
if (opts.summary && isTTY(this.writer)) {
|
|
242
|
+
this.writer.write(opts.summary + '\n\n');
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(data)) {
|
|
245
|
+
this.writeCSVTable(data);
|
|
246
|
+
} else if (data && typeof data === 'object' && Array.isArray(data.records)) {
|
|
247
|
+
this.writeCSVTable(data.records);
|
|
248
|
+
} else {
|
|
249
|
+
this.writeCSVTable([data]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
writeCSVTable(rows) {
|
|
254
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
255
|
+
this.writer.write('');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const columns = Object.keys(rows[0]);
|
|
259
|
+
this.writer.write(columns.map(csvEscape).join(',') + '\n');
|
|
260
|
+
for (const row of rows) {
|
|
261
|
+
const cells = columns.map((col) => {
|
|
262
|
+
const val = row[col];
|
|
263
|
+
if (val == null) return '';
|
|
264
|
+
if (typeof val === 'object') {
|
|
265
|
+
if (val.display_value != null) return csvEscape(val.display_value);
|
|
266
|
+
if (val.value != null) return csvEscape(val.value);
|
|
267
|
+
return csvEscape(JSON.stringify(val));
|
|
268
|
+
}
|
|
269
|
+
return csvEscape(val);
|
|
270
|
+
});
|
|
271
|
+
this.writer.write(cells.join(',') + '\n');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
158
275
|
writeStyled(data, opts) {
|
|
276
|
+
// ── The `_formatted` interface ──────────────────────────────────────
|
|
277
|
+
// Commands may ship a pre-rendered styled view as `data._formatted`
|
|
278
|
+
// (a plain string). The writer prints it verbatim and suppresses both
|
|
279
|
+
// opts.summary and the records table / field-dump renderers — the
|
|
280
|
+
// curated visual IS the output. JSON/quiet/markdown/csv formats ignore
|
|
281
|
+
// `_formatted` entirely and serialize the full envelope as usual, so
|
|
282
|
+
// scripting (`--json`, `--get`) is unaffected.
|
|
283
|
+
//
|
|
284
|
+
// This is the documented escape hatch for command-curated visuals
|
|
285
|
+
// (auth status, cmdb show cards/trees, docs search results, …). View
|
|
286
|
+
// logic belongs in the command that owns the domain — output.js stays
|
|
287
|
+
// generic. Other underscore-prefixed side-channel keys commands may
|
|
288
|
+
// attach: `_context` ({instance_url, table} consumed by the record
|
|
289
|
+
// renderer), `_attachments`, `_variables` (record card sections), plus
|
|
290
|
+
// `records`/`columns`/`table`/`relationships` for the generic table and
|
|
291
|
+
// record views below.
|
|
159
292
|
const hasFormatted = data && typeof data === 'object' && data._formatted;
|
|
160
293
|
|
|
161
294
|
if (opts.summary && !hasFormatted) {
|
|
@@ -173,48 +306,18 @@ export class OutputWriter {
|
|
|
173
306
|
}
|
|
174
307
|
|
|
175
308
|
const { isRecord, tableName } = detectRecord(data);
|
|
176
|
-
if (isRecord) {
|
|
309
|
+
if (isRecord && !data._formatted) {
|
|
310
|
+
// _formatted (e.g. cmdb show card) replaces the raw field dump —
|
|
311
|
+
// the visual is the curated view; JSON/quiet keep full data.
|
|
177
312
|
this.writeFormattedRecord(data, tableName);
|
|
178
313
|
}
|
|
179
314
|
|
|
180
|
-
if (Array.isArray(data.profiles) && data.profiles.length > 0) {
|
|
181
|
-
this.writer.write('\n');
|
|
182
|
-
for (const p of data.profiles) {
|
|
183
|
-
const prefix = p.default ? '* ' : ' ';
|
|
184
|
-
const authIcon = p.authenticated ? '✓' : '✗';
|
|
185
|
-
|
|
186
|
-
// Show verified status if we got one
|
|
187
|
-
let verifiedStr = '';
|
|
188
|
-
if (p.verified === true) {
|
|
189
|
-
verifiedStr = ' ✅';
|
|
190
|
-
} else if (p.verified === false) {
|
|
191
|
-
verifiedStr = ' ⚠️';
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Show stale hint if >7 days since last seen
|
|
195
|
-
let staleStr = '';
|
|
196
|
-
if (p.stale && p.days_since_last_seen) {
|
|
197
|
-
staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Show lock icon for read-only profiles
|
|
201
|
-
const lockIcon = p.read_only ? ' 🔒' : '';
|
|
202
|
-
|
|
203
|
-
// Show confirmations-off badge for skip_confirmations profiles
|
|
204
|
-
const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
|
|
205
|
-
|
|
206
|
-
if (p.name) {
|
|
207
|
-
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
208
|
-
} else if (p.username) {
|
|
209
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
210
|
-
} else {
|
|
211
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
315
|
if (Array.isArray(data.records) && data.records.length > 0) {
|
|
217
|
-
|
|
316
|
+
// A _formatted visual (e.g. cmdb relationship tree) replaces the
|
|
317
|
+
// flat table in styled mode — showing both is noise.
|
|
318
|
+
if (!data._formatted) {
|
|
319
|
+
this.writeRecordsTable(data);
|
|
320
|
+
}
|
|
218
321
|
}
|
|
219
322
|
} else {
|
|
220
323
|
if (!opts.summary) {
|
|
@@ -236,27 +339,10 @@ export class OutputWriter {
|
|
|
236
339
|
const table = data.table || '';
|
|
237
340
|
const instanceURL = data.context?.instance_url || '';
|
|
238
341
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
state: 10,
|
|
244
|
-
assigned_to: 15,
|
|
245
|
-
risk: 10,
|
|
246
|
-
name: 30,
|
|
247
|
-
user_name: 15,
|
|
248
|
-
email: 30,
|
|
249
|
-
sys_id: 32,
|
|
250
|
-
sys_updated_on: 22,
|
|
251
|
-
sys_created_on: 22,
|
|
252
|
-
opened_at: 22,
|
|
253
|
-
closed_at: 22,
|
|
254
|
-
sys_updated_by: 20,
|
|
255
|
-
sys_created_by: 20,
|
|
256
|
-
opened_by: 20,
|
|
257
|
-
u_category: 20,
|
|
258
|
-
u_subcategory: 20,
|
|
259
|
-
};
|
|
342
|
+
// Default per-column widths for known ServiceNow fields; unknown columns
|
|
343
|
+
// fall back to 20. Commands may override/extend per call by attaching
|
|
344
|
+
// `data.columnWidths = { <col>: <width> }` — hints win over defaults.
|
|
345
|
+
const colWidths = { ...DEFAULT_COLUMN_WIDTHS, ...(data.columnWidths || {}) };
|
|
260
346
|
|
|
261
347
|
this.writer.write('\n');
|
|
262
348
|
|
|
@@ -280,7 +366,9 @@ export class OutputWriter {
|
|
|
280
366
|
const row = records[i];
|
|
281
367
|
for (let j = 0; j < columns.length; j++) {
|
|
282
368
|
const col = columns[j];
|
|
283
|
-
|
|
369
|
+
// Coerce to string — rows can carry ServiceNow reference objects
|
|
370
|
+
// ({display_value, value}) and numeric cells (e.g. cmdb depth)
|
|
371
|
+
let val = getDisplayValue(row[col]);
|
|
284
372
|
const width = colWidths[col] || 20;
|
|
285
373
|
|
|
286
374
|
if (j === 0 && instanceURL && table && row.sys_id) {
|
|
@@ -369,7 +457,7 @@ export class OutputWriter {
|
|
|
369
457
|
}
|
|
370
458
|
}
|
|
371
459
|
|
|
372
|
-
const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_')).sort();
|
|
460
|
+
const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_') && f !== 'relationships').sort();
|
|
373
461
|
if (otherFields.length > 0) {
|
|
374
462
|
this.writer.write('─ Other ─\n');
|
|
375
463
|
for (const field of otherFields) {
|
|
@@ -386,6 +474,33 @@ export class OutputWriter {
|
|
|
386
474
|
this.writer.write('\n');
|
|
387
475
|
}
|
|
388
476
|
|
|
477
|
+
// Inline relationships (e.g. cmdb show). Grouped shape:
|
|
478
|
+
// { parents: [], siblings: [], children: [], counts: {} } renders as
|
|
479
|
+
// sections; a flat array renders as one compact list.
|
|
480
|
+
if (data.relationships && typeof data.relationships === 'object' && !Array.isArray(data.relationships)) {
|
|
481
|
+
const renderGroup = (label, rows, total, arrow, viaKey) => {
|
|
482
|
+
if ((!rows || rows.length === 0) && !total) return;
|
|
483
|
+
const capNote = total && total > (rows?.length || 0) ? ` (showing ${rows.length} of ${total})` : '';
|
|
484
|
+
this.writer.write(`─ ${label}${capNote} ─\n`);
|
|
485
|
+
for (const rel of rows || []) {
|
|
486
|
+
const via = viaKey && rel[viaKey] ? ` (via ${rel[viaKey]})` : '';
|
|
487
|
+
this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}${via}\n`);
|
|
488
|
+
}
|
|
489
|
+
this.writer.write('\n');
|
|
490
|
+
};
|
|
491
|
+
const counts = data.relationships.counts || {};
|
|
492
|
+
renderGroup('Parents', data.relationships.parents, counts.parents, '↑');
|
|
493
|
+
renderGroup('Siblings', data.relationships.siblings, counts.siblings, '↔', 'via');
|
|
494
|
+
renderGroup('Children', data.relationships.children, counts.children, '↓');
|
|
495
|
+
} else if (Array.isArray(data.relationships) && data.relationships.length > 0) {
|
|
496
|
+
this.writer.write('─ Relationships ─\n');
|
|
497
|
+
for (const rel of data.relationships) {
|
|
498
|
+
const arrow = rel.direction === 'upstream' ? '↑' : '↓';
|
|
499
|
+
this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}\n`);
|
|
500
|
+
}
|
|
501
|
+
this.writer.write('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
389
504
|
// ── Attachments ──
|
|
390
505
|
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
391
506
|
this.writer.write('─ Attachments ─\n');
|
|
@@ -453,6 +568,12 @@ function detectRecord(data) {
|
|
|
453
568
|
}
|
|
454
569
|
}
|
|
455
570
|
|
|
571
|
+
// Explicit table stamp set by command handlers (e.g. _context.table on
|
|
572
|
+
// cmdb_rel_ci rows, which have neither sys_class_name nor number).
|
|
573
|
+
if (!tableName && data._context && typeof data._context === 'object' && data._context.table) {
|
|
574
|
+
tableName = data._context.table;
|
|
575
|
+
}
|
|
576
|
+
|
|
456
577
|
return { isRecord: hasSysID && (hasSysClass || tableName), tableName };
|
|
457
578
|
}
|
|
458
579
|
|
|
@@ -481,3 +602,13 @@ export function getDisplayValue(val) {
|
|
|
481
602
|
}
|
|
482
603
|
return String(val);
|
|
483
604
|
}
|
|
605
|
+
|
|
606
|
+
/** Escape a single CSV field: quote when it contains a delimiter, quote, or newline. */
|
|
607
|
+
export function csvEscape(val) {
|
|
608
|
+
if (val == null) return '';
|
|
609
|
+
const s = String(val);
|
|
610
|
+
if (/[",\n\r]/.test(s)) {
|
|
611
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
612
|
+
}
|
|
613
|
+
return s;
|
|
614
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Shared resolver for "find one record by a human identifier".
|
|
2
|
+
//
|
|
3
|
+
// Every command that accepts a number / name / user_name / sys_id used to
|
|
4
|
+
// hand-roll the same five steps (classify the identifier, guard the query,
|
|
5
|
+
// build params, fetch, unwrap sys_id) — and they drifted: hex detection
|
|
6
|
+
// differed per file and some lookups forgot sysparm_display_value=all, so a
|
|
7
|
+
// wrapped {value, display_value} sys_id leaked into update/delete calls.
|
|
8
|
+
// This module is the single deep seam; commands shrink to
|
|
9
|
+
// resolve → mutate → report.
|
|
10
|
+
|
|
11
|
+
import { assertSafeExactMatch, isHexString } from './helpers.js';
|
|
12
|
+
import { errNotFound } from './errors.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when the identifier looks like a ServiceNow sys_id (32 hex chars).
|
|
16
|
+
* Exported so call sites that only need classification share one definition.
|
|
17
|
+
*/
|
|
18
|
+
export function isSysId(identifier) {
|
|
19
|
+
return typeof identifier === 'string' && identifier.length === 32 && isHexString(identifier);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Unwrap a record's sys_id, which may be a plain string or a
|
|
24
|
+
* {value, display_value} object when display values were requested.
|
|
25
|
+
*/
|
|
26
|
+
export function unwrapSysId(record) {
|
|
27
|
+
const sysId = record?.sys_id;
|
|
28
|
+
if (sysId && typeof sysId === 'object') return sysId.value || sysId.display_value || '';
|
|
29
|
+
return sysId || '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a single record by human identifier.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} sdk — SDK handle (uses sdk.list)
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string} opts.table — ServiceNow table
|
|
38
|
+
* @param {string} opts.identifier — sys_id (32 hex) or value of matchField
|
|
39
|
+
* @param {string} opts.matchField — field to match non-sys_id identifiers on
|
|
40
|
+
* (e.g. 'number', 'name', 'user_name')
|
|
41
|
+
* @param {string} [opts.resource] — label for the not-found error
|
|
42
|
+
* (e.g. 'User' → "User not found: <id>"); defaults to matchField
|
|
43
|
+
* @param {string[]} [opts.fields] — restrict sysparm_fields when set
|
|
44
|
+
* @returns {Promise<object>} the record (display values requested)
|
|
45
|
+
* @throws {AppError} code not_found when no record matches
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveRecord(sdk, { table, identifier, matchField, resource, fields }) {
|
|
48
|
+
assertSafeExactMatch(identifier);
|
|
49
|
+
const queryField = isSysId(identifier) ? 'sys_id' : matchField;
|
|
50
|
+
const params = new URLSearchParams();
|
|
51
|
+
params.set('sysparm_query', `${queryField}=${identifier}`);
|
|
52
|
+
params.set('sysparm_limit', '1');
|
|
53
|
+
params.set('sysparm_display_value', 'all');
|
|
54
|
+
if (fields && fields.length > 0) {
|
|
55
|
+
params.set('sysparm_fields', fields.join(','));
|
|
56
|
+
}
|
|
57
|
+
const records = await sdk.list(table, params);
|
|
58
|
+
if (records.length === 0) {
|
|
59
|
+
throw errNotFound(resource || matchField, identifier);
|
|
60
|
+
}
|
|
61
|
+
return records[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a record and return just its raw sys_id.
|
|
66
|
+
* Same options as resolveRecord; defaults to fetching only sys_id.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveSysId(sdk, opts) {
|
|
69
|
+
const record = await resolveRecord(sdk, { fields: ['sys_id'], ...opts });
|
|
70
|
+
return unwrapSysId(record);
|
|
71
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -6,6 +6,23 @@ import { getStringField } from './helpers.js';
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TIMEOUT = 30000;
|
|
8
8
|
|
|
9
|
+
// Build the sys.scripts.do POST body for background script execution.
|
|
10
|
+
// Extracted for testability (issue #177): maps script-mode options to the
|
|
11
|
+
// form fields on ServiceNow's Script Background form. Defaults preserve the
|
|
12
|
+
// historical behavior (rollback recording on, quota-managed transaction on).
|
|
13
|
+
export function buildScriptFormBody({ script, csrf, scope, rollback = true, quotaManagedTransaction = true, sandbox = false, scriptlet = false }) {
|
|
14
|
+
const formBody = new URLSearchParams();
|
|
15
|
+
formBody.set('script', script);
|
|
16
|
+
formBody.set('sysparm_ck', csrf);
|
|
17
|
+
formBody.set('runscript', 'Run script');
|
|
18
|
+
formBody.set('sys_scope', scope || 'global');
|
|
19
|
+
if (rollback) formBody.set('record_for_rollback', 'on');
|
|
20
|
+
if (quotaManagedTransaction) formBody.set('quota_managed_transaction', 'on');
|
|
21
|
+
if (sandbox) formBody.set('sandbox', 'on');
|
|
22
|
+
if (scriptlet) formBody.set('scriptlet', 'on');
|
|
23
|
+
return formBody;
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
export class SDKClient {
|
|
10
27
|
constructor(baseURL, authProvider, opts = {}) {
|
|
11
28
|
this.baseURL = baseURL.replace(/\/$/, '');
|
|
@@ -177,6 +194,55 @@ export class SDKClient {
|
|
|
177
194
|
await this.request(endpoint, { method: 'DELETE' });
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
// ─── Attachments ───
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* List attachments on a record.
|
|
201
|
+
* @param {string} sysID - record sys_id
|
|
202
|
+
* @returns {Promise<Array>} attachment metadata rows
|
|
203
|
+
*/
|
|
204
|
+
async listAttachments(sysID) {
|
|
205
|
+
const params = new URLSearchParams();
|
|
206
|
+
params.set('sysparm_query', `table_sys_id=${sysID}`);
|
|
207
|
+
params.set('sysparm_display_value', 'all');
|
|
208
|
+
params.set('sysparm_fields', 'sys_id,file_name,size_bytes,content_type,sys_created_by,sys_created_on');
|
|
209
|
+
const endpoint = `${this.baseURL}/api/now/attachment?${params.toString()}`;
|
|
210
|
+
const result = await this.request(endpoint, { method: 'GET' });
|
|
211
|
+
return result?.result || [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Download an attachment's file contents as a Buffer.
|
|
216
|
+
* @param {string} sysID - attachment sys_id
|
|
217
|
+
* @returns {Promise<Buffer>}
|
|
218
|
+
*/
|
|
219
|
+
async getAttachment(sysID) {
|
|
220
|
+
const endpoint = `${this.baseURL}/api/now/attachment/${sysID}/file`;
|
|
221
|
+
const resp = await this._fetchWithAuth(endpoint, { method: 'GET', headers: { Accept: 'application/octet-stream' } });
|
|
222
|
+
if (!resp.ok) {
|
|
223
|
+
throw errAPI(resp.status, String(resp.statusText));
|
|
224
|
+
}
|
|
225
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
226
|
+
return buf;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Upload a file as a new attachment on a record.
|
|
231
|
+
* @param {string} table - parent table name (e.g. 'incident')
|
|
232
|
+
* @param {string} sysID - parent record sys_id
|
|
233
|
+
* @param {Buffer|string} content - file bytes or path
|
|
234
|
+
* @param {string} fileName - display file name
|
|
235
|
+
* @returns {Promise<object>} created attachment row
|
|
236
|
+
*/
|
|
237
|
+
async addAttachment(table, sysID, content, fileName) {
|
|
238
|
+
const form = new FormData();
|
|
239
|
+
const blob = Buffer.isBuffer(content) ? new Blob([content]) : new Blob([String(content)]);
|
|
240
|
+
form.append('file', blob, fileName);
|
|
241
|
+
const endpoint = `${this.baseURL}/api/now/attachment?table_name=${encodeURIComponent(table)}&table_sys_id=${encodeURIComponent(sysID)}`;
|
|
242
|
+
const result = await this.request(endpoint, { method: 'POST', body: form });
|
|
243
|
+
return result?.result || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
180
246
|
async getCurrentUser() {
|
|
181
247
|
const params = new URLSearchParams();
|
|
182
248
|
params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
|
|
@@ -433,6 +499,31 @@ export class SDKClient {
|
|
|
433
499
|
|
|
434
500
|
// ─── Background Script Execution (3-step OAuth session flow) ───
|
|
435
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Export an update set to XML via the fluent export endpoint.
|
|
504
|
+
* Same session dance as executeScript: warm the session for cookies,
|
|
505
|
+
* grab the CSRF token from sys.scripts.do, then GET the export endpoint.
|
|
506
|
+
*
|
|
507
|
+
* @param {string} updateSetSysId - sys_id of the sys_update_set record
|
|
508
|
+
* @param {string} appSysId - sys_scope sys_id (scope of the update set)
|
|
509
|
+
* @returns {Promise<string>} The update set XML document
|
|
510
|
+
*/
|
|
511
|
+
async exportUpdateSet(updateSetSysId, appSysId = '') {
|
|
512
|
+
const cookies = await this._warmSession();
|
|
513
|
+
const csrfToken = await this._getScriptsPageCSRF(cookies);
|
|
514
|
+
|
|
515
|
+
const params = new URLSearchParams();
|
|
516
|
+
params.set('sysparm_ck', csrfToken);
|
|
517
|
+
params.set('sysparm_sys_id', updateSetSysId);
|
|
518
|
+
params.set('sysparm_app_sys_id', appSysId);
|
|
519
|
+
|
|
520
|
+
const endpoint = `${this.baseURL}/fluent_update_set_export.do?${params.toString()}`;
|
|
521
|
+
return this.rawRequest(endpoint, {
|
|
522
|
+
method: 'GET',
|
|
523
|
+
headers: cookies ? { Cookie: cookies } : {},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
436
527
|
/**
|
|
437
528
|
* Execute a background script on the ServiceNow instance via sys.scripts.do.
|
|
438
529
|
* Uses a 3-step session-establishment flow compatible with OAuth tokens:
|
|
@@ -441,9 +532,16 @@ export class SDKClient {
|
|
|
441
532
|
* 3. POST /sys.scripts.do with the script, CSRF token, and cookies
|
|
442
533
|
*
|
|
443
534
|
* @param {string} script - JavaScript code to execute
|
|
535
|
+
* @param {string} scope - Scope sys_id to run under ('' = global)
|
|
536
|
+
* @param {object} [opts] - Script-mode flags (issue #177), mapping to the
|
|
537
|
+
* Script Background form checkboxes:
|
|
538
|
+
* - rollback (default true) → record_for_rollback
|
|
539
|
+
* - quotaManagedTransaction (default true) → quota_managed_transaction
|
|
540
|
+
* - sandbox (default false) → sandbox (KittyScript, single expression)
|
|
541
|
+
* - scriptlet (default false) → scriptlet
|
|
444
542
|
* @returns {Promise<string>} The script's output text
|
|
445
543
|
*/
|
|
446
|
-
async executeScript(script, scope) {
|
|
544
|
+
async executeScript(script, scope, opts = {}) {
|
|
447
545
|
// Step 1: Warm up the session by hitting any REST API — this makes
|
|
448
546
|
// ServiceNow issue session cookies for subsequent UI page requests.
|
|
449
547
|
// We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
|
|
@@ -454,13 +552,15 @@ export class SDKClient {
|
|
|
454
552
|
|
|
455
553
|
// Step 3: POST the script with form data including the CSRF token.
|
|
456
554
|
const endpoint = `${this.baseURL}/sys.scripts.do`;
|
|
457
|
-
const formBody =
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
555
|
+
const formBody = buildScriptFormBody({
|
|
556
|
+
script,
|
|
557
|
+
csrf: csrfToken,
|
|
558
|
+
scope,
|
|
559
|
+
rollback: opts.rollback,
|
|
560
|
+
quotaManagedTransaction: opts.quotaManagedTransaction,
|
|
561
|
+
sandbox: opts.sandbox,
|
|
562
|
+
scriptlet: opts.scriptlet,
|
|
563
|
+
});
|
|
464
564
|
|
|
465
565
|
const html = await this.rawRequest(endpoint, {
|
|
466
566
|
method: 'POST',
|