@jacebenson/jsn 4.1.0 → 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 -1
  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 -79
  7. package/src/commands/_ticket.js +36 -92
  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 +15 -133
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +62 -110
  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 +40 -4
  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 +254 -9
  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 +92 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +22 -11
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +187 -61
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -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',
package/src/session.js ADDED
@@ -0,0 +1,214 @@
1
+ // Session resolver — one module owns the answer to "which instance am I
2
+ // talking to, as whom, with what profile flags?"
3
+ //
4
+ // Before this module existed, that composite answer was assembled across
5
+ // four files with shared mutable state:
6
+ // - cli.js middleware resolved --profile by mutating cfg.activeProfile as
7
+ // a side effect, and poked BOTH app._overrideInstance and
8
+ // argv._overrideInstance (the --instance branch duplicated across two
9
+ // adjacent middlewares);
10
+ // - app.js carried _overrideInstance + getEffectiveInstance +
11
+ // setEffectiveInstance, re-deriving the active profile three ways;
12
+ // - auth.js reached into configProvider.config.profiles to re-derive the
13
+ // username for credential keying;
14
+ // - config.js's getEffectiveInstance duplicated the
15
+ // activeProfile || defaultProfile incantation.
16
+ //
17
+ // Now: resolveSession({argv-ish}, config) → a session object (pure — no
18
+ // mutation of argv OR config), applySession(app, session) is the SINGLE
19
+ // point where a flag override changes app/config state, and App keeps the
20
+ // resolved session on app.session for anything that needs identity
21
+ // (AuthManager is handed a provider over it, so it no longer spelunks
22
+ // config internals).
23
+ //
24
+ // Precedence (pinned by test/session.test.js — preserve exactly):
25
+ // --instance flag > --profile reference > active profile >
26
+ // default profile > legacy bare instanceURL
27
+ //
28
+ // Deliberately NOT here: env var loading (loadConfig already folds
29
+ // SERVICENOW_INSTANCE_URL into cfg.instanceURL before the resolver runs)
30
+ // and credential storage I/O (auth.js owns the keyring/file stores).
31
+
32
+ import { normalizeInstanceURL } from './config.js';
33
+ import { extractProfileName } from './helpers.js';
34
+ import { errUsage } from './errors.js';
35
+
36
+ /**
37
+ * Resolve the composite session from parsed argv flags + loaded config.
38
+ * Pure: never mutates argv or config.
39
+ *
40
+ * @param {object} [argv] — anything with optional { instance, profile }
41
+ * (a yargs argv, or a hand-built { instance, profile } shape)
42
+ * @param {object} config — the loaded config (from loadConfig())
43
+ * @returns {{
44
+ * instance: string, // effective instance URL ('' when unresolved)
45
+ * profileName: string|null,// active profile name after flags
46
+ * profile: object|null, // the profile entry (instance_url, flags, ...)
47
+ * username: string|null, // profile username for credential keying
48
+ * domain: string, // domain-separation sys_id ('' = no scoping)
49
+ * readOnly: boolean, // profile.read_only === true (the armed guard)
50
+ * override: string|null, // normalized flag override URL, null when none
51
+ * profileExplicit: boolean,// true when --profile named the active profile
52
+ * unknownProfile: string|null, // set when --profile named no known profile
53
+ * }}
54
+ */
55
+ export function resolveSession(argv = {}, config) {
56
+ const cfg = config || {};
57
+ const profiles = cfg.profiles || {};
58
+
59
+ // --profile selects the active profile for this run. Unknown names are
60
+ // surfaced as data (unknownProfile) — the caller (cli.js middleware)
61
+ // decides how to render the guard exit; resolving must not throw because
62
+ // App is constructed before argv exists.
63
+ let unknownProfile = null;
64
+ let profileName = null;
65
+ if (argv.profile) {
66
+ if (profiles[argv.profile]) {
67
+ profileName = argv.profile;
68
+ } else {
69
+ unknownProfile = argv.profile;
70
+ }
71
+ }
72
+ if (!profileName) {
73
+ const fallback = cfg.activeProfile || cfg.defaultProfile;
74
+ if (fallback && profiles[fallback]) profileName = fallback;
75
+ }
76
+
77
+ const profile = profileName ? profiles[profileName] : null;
78
+
79
+ // Instance: --instance flag > profile reference > active/default
80
+ // profile > legacy bare instanceURL. The override is normalized here so
81
+ // every downstream consumer (SDK client, context header links) sees the
82
+ // canonical form. --profile counts as an override too (the old code
83
+ // poked the profile's URL into app._overrideInstance and rebuilt the SDK
84
+ // via setEffectiveInstance's normalization) — session.instance is always
85
+ // the normalized effective URL either way.
86
+ let override = null;
87
+ if (argv.instance) {
88
+ override = normalizeInstanceURL(argv.instance) || null;
89
+ } else if (argv.profile && profile?.instance_url) {
90
+ override = normalizeInstanceURL(profile.instance_url) || null;
91
+ }
92
+
93
+ const instance = normalizeInstanceURL(override || (profile && profile.instance_url) || cfg.instanceURL || '');
94
+
95
+ return {
96
+ instance,
97
+ profileName,
98
+ profile,
99
+ username: (profile && profile.username) || null,
100
+ domain: (profile && profile.domain) || '',
101
+ readOnly: profile?.read_only === true,
102
+ override,
103
+ profileExplicit: Boolean(argv.profile) && !unknownProfile,
104
+ unknownProfile,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Derive the legacy context block (profileName + username for the context
110
+ * header) from a session. Kept separate from the session itself because
111
+ * printContextHeader() enriches it with live instance data (scope, update
112
+ * set) — the session stays a snapshot of resolution-time truth.
113
+ *
114
+ * Legacy fallback preserved: with no active profile NAME, find the profile
115
+ * whose instance_url matches the effective instance (legacy configs that
116
+ * predate named profiles), else derive a display name from the URL host.
117
+ */
118
+ export function contextFromSession(session, config) {
119
+ const context = { profileName: '', username: '', scope: '', updateSet: '' };
120
+ if (!session) return context;
121
+
122
+ if (session.profileName) {
123
+ context.profileName = session.profileName;
124
+ context.username = session.username || '';
125
+ return context;
126
+ }
127
+
128
+ if (!session.instance) return context;
129
+ context.profileName = extractProfileName(session.instance);
130
+ for (const [name, profile] of Object.entries(config?.profiles || {})) {
131
+ if (profile.instance_url === session.instance) {
132
+ context.profileName = name;
133
+ context.username = profile.username || '';
134
+ break;
135
+ }
136
+ }
137
+ return context;
138
+ }
139
+
140
+ /**
141
+ * Apply a resolved session to the App — the ONLY place flag overrides
142
+ * change app/config state. Explicit, greppable, and called from exactly
143
+ * one middleware (cli.js). Replaces the old scattered trio:
144
+ * cfg.activeProfile = argv.profile (buried side effect)
145
+ * app._overrideInstance = url; argv._overrideInstance = url (mirrored)
146
+ *
147
+ * @param {import('./app.js').App} app
148
+ * @param {object} sessionOrArgv — a resolveSession() result, or a bare
149
+ * { instance, profile } argv shape (resolved here for convenience)
150
+ * @returns {object} the applied session
151
+ */
152
+ export function applySession(app, sessionOrArgv) {
153
+ // A resolved session carries `override` (null when no flag); bare argv
154
+ // ({ instance, profile }) does not. (Object.hasOwn, not `in` — plain
155
+ // objects inherit Object.prototype, which has neither, but hasOwn is the
156
+ // intent-precise check.)
157
+ const isResolved = sessionOrArgv != null && Object.hasOwn(sessionOrArgv, 'override');
158
+ const session = isResolved
159
+ ? sessionOrArgv
160
+ : resolveSession(sessionOrArgv, app.config);
161
+
162
+ // --profile: switch the active profile on the config so anything still
163
+ // reading config directly (legacy callers, help rendering) sees the same
164
+ // answer the session computed. Flag args are pinned on _sessionArgv so
165
+ // refreshSession() (after config edits) keeps the same flags in force.
166
+ if (session.profileExplicit) {
167
+ app.config.activeProfile = session.profileName;
168
+ app._sessionArgv = { ...(app._sessionArgv || {}), profile: session.profileName };
169
+ }
170
+ if (!isResolved && sessionOrArgv?.instance !== undefined) {
171
+ app._sessionArgv = { ...(app._sessionArgv || {}), instance: sessionOrArgv.instance };
172
+ }
173
+
174
+ // Flag overrides (--instance, --profile) pin the effective instance for
175
+ // this run and rebuild the SDK client against it. No flags → clear any
176
+ // stale override so the config-sourced instance is authoritative. (The
177
+ // SDK must be rebuilt even at the same URL: the domain scope comes from
178
+ // the newly-active profile.)
179
+ app._overrideInstance = session.override;
180
+ if (session.instance) {
181
+ app._buildSdk(session.instance, session);
182
+ } else {
183
+ app.sdk = null;
184
+ }
185
+
186
+ app.session = session;
187
+ app.loadContext();
188
+ return session;
189
+ }
190
+
191
+ /**
192
+ * Re-resolve the session after config edits (profile switched, created,
193
+ * or removed by auth/setup commands). Explicit flag overrides are pinned
194
+ * by re-resolving against the pinned argv; without them the config is
195
+ * authoritative.
196
+ *
197
+ * @param {import('./app.js').App} app
198
+ * @returns {object} the refreshed session
199
+ */
200
+ export function refreshSession(app) {
201
+ return applySession(app, app._sessionArgv || {});
202
+ }
203
+
204
+ /**
205
+ * The missing-instance guard, backed by the session. The message and code
206
+ * (usage → exit 2 via the unified renderer) are observable behavior pinned
207
+ * by test/core.test.js — do not reword.
208
+ */
209
+ export function requireSessionInstance(session) {
210
+ if (!session || !session.instance) {
211
+ throw errUsage('Instance URL required. Set via --instance flag, SERVICENOW_INSTANCE_URL env, or config file.');
212
+ }
213
+ return session.instance;
214
+ }