@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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. 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(chalk.red(`Error (${e.code}): ${e.message}\n`));
86
- if (e.hint) this.writer.write(chalk.yellow(`Hint: ${e.hint}\n`));
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
- this.writeRecordsTable(data);
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
- const colWidths = {
240
- number: 14,
241
- short_description: 48,
242
- priority: 14,
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
- let val = row[col] || '';
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 = new URLSearchParams();
458
- formBody.set('script', script);
459
- formBody.set('sysparm_ck', csrfToken);
460
- formBody.set('runscript', 'Run script');
461
- formBody.set('sys_scope', scope || 'global');
462
- formBody.set('record_for_rollback', 'on');
463
- formBody.set('quota_managed_transaction', 'on');
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',