@jacebenson/jsn 1.0.7 → 2.0.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.
Files changed (45) hide show
  1. package/README.md +196 -243
  2. package/bin/jsn.js +2 -57
  3. package/package.json +35 -32
  4. package/skills/servicenow/SKILL.md +137 -0
  5. package/src/app.js +172 -0
  6. package/src/auth.js +517 -0
  7. package/src/cli.js +174 -0
  8. package/src/commands/_ticket.js +260 -0
  9. package/src/commands/auth.js +227 -0
  10. package/src/commands/catalog.js +325 -0
  11. package/src/commands/changes.js +7 -0
  12. package/src/commands/dev/_generic.js +342 -0
  13. package/src/commands/dev/_simple.js +89 -0
  14. package/src/commands/dev/eval.js +38 -0
  15. package/src/commands/dev/flows.js +573 -0
  16. package/src/commands/dev/forms.js +313 -0
  17. package/src/commands/dev/lists.js +233 -0
  18. package/src/commands/dev/logs.js +64 -0
  19. package/src/commands/dev/rest.js +64 -0
  20. package/src/commands/dev/scopes.js +156 -0
  21. package/src/commands/dev/updatesets.js +184 -0
  22. package/src/commands/dev.js +53 -0
  23. package/src/commands/groupmembers.js +152 -0
  24. package/src/commands/grouproles.js +152 -0
  25. package/src/commands/groups.js +141 -0
  26. package/src/commands/incidents.js +7 -0
  27. package/src/commands/profiles.js +91 -0
  28. package/src/commands/records.js +171 -0
  29. package/src/commands/requests.js +59 -0
  30. package/src/commands/setup.js +39 -0
  31. package/src/commands/skill.js +144 -0
  32. package/src/commands/tasks.js +7 -0
  33. package/src/commands/tickets.js +121 -0
  34. package/src/commands/users.js +144 -0
  35. package/src/commands/version.js +81 -0
  36. package/src/config.js +154 -0
  37. package/src/context.js +62 -0
  38. package/src/errors.js +101 -0
  39. package/src/help.js +85 -0
  40. package/src/helpers.js +182 -0
  41. package/src/output.js +440 -0
  42. package/src/sdk.js +615 -0
  43. package/scripts/install.sh +0 -227
  44. package/scripts/npm-install.js +0 -235
  45. package/scripts/pre-commit-check.sh +0 -61
package/src/sdk.js ADDED
@@ -0,0 +1,615 @@
1
+ // ServiceNow REST API client
2
+
3
+ import { errAuth, errAPI, errNetwork } from './errors.js';
4
+ import { getStringField } from './helpers.js';
5
+
6
+ const DEFAULT_TIMEOUT = 30000;
7
+
8
+ export class SDKClient {
9
+ constructor(baseURL, authProvider, opts = {}) {
10
+ this.baseURL = baseURL.replace(/\/$/, '');
11
+ this.authProvider = authProvider;
12
+ this.timeout = opts.timeout || DEFAULT_TIMEOUT;
13
+ }
14
+
15
+ async _setAuth(req) {
16
+ if (!this.authProvider) {
17
+ throw errAuth('No authentication configured');
18
+ }
19
+ const creds = await this.authProvider.getCredentials();
20
+ if (!creds) {
21
+ throw errAuth('No valid credentials');
22
+ }
23
+ switch (creds.auth_method) {
24
+ case 'basic':
25
+ req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
26
+ break;
27
+ case 'token':
28
+ case 'oauth':
29
+ req.headers.set('Authorization', `Bearer ${creds.access_token}`);
30
+ break;
31
+ default:
32
+ if (creds.username && creds.password) {
33
+ req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
34
+ } else if (creds.access_token) {
35
+ req.headers.set('Authorization', `Bearer ${creds.access_token}`);
36
+ } else {
37
+ throw errAuth('No valid credentials');
38
+ }
39
+ }
40
+ }
41
+
42
+ async request(endpoint, opts = {}) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), this.timeout);
45
+
46
+ try {
47
+ const req = new Request(endpoint, {
48
+ ...opts,
49
+ signal: controller.signal,
50
+ });
51
+ req.headers.set('Accept', 'application/json');
52
+ if (opts.body && typeof opts.body === 'string') {
53
+ req.headers.set('Content-Type', 'application/json');
54
+ }
55
+ await this._setAuth(req);
56
+
57
+ const resp = await fetch(req);
58
+ const body = await resp.text();
59
+
60
+ if (!resp.ok) {
61
+ throw errAPI(resp.status, body || resp.statusText);
62
+ }
63
+
64
+ if (resp.status === 204 || body === '') {
65
+ return null;
66
+ }
67
+
68
+ return JSON.parse(body);
69
+ } catch (err) {
70
+ if (err.name === 'AbortError') {
71
+ throw errNetwork(new Error('Request timed out'));
72
+ }
73
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
74
+ throw errNetwork(err);
75
+ }
76
+ throw err;
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+
82
+ // _fetchWithAuth makes an authenticated fetch and returns the Response object.
83
+ async _fetchWithAuth(endpoint, opts = {}) {
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), this.timeout);
86
+ try {
87
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
88
+ await this._setAuth(req);
89
+ return await fetch(req);
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ }
94
+
95
+ // rawRequest is like request() but returns the response text as-is (no JSON parsing).
96
+ // Used for non-JSON endpoints like sys.scripts.do (returns HTML).
97
+ async rawRequest(endpoint, opts = {}) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), this.timeout);
100
+
101
+ try {
102
+ const req = new Request(endpoint, {
103
+ ...opts,
104
+ signal: controller.signal,
105
+ });
106
+ if (opts.body && typeof opts.body === 'string') {
107
+ req.headers.set('Content-Type', opts.headers?.['Content-Type'] || 'application/x-www-form-urlencoded');
108
+ }
109
+ await this._setAuth(req);
110
+
111
+ const resp = await fetch(req);
112
+ const body = await resp.text();
113
+
114
+ if (!resp.ok) {
115
+ throw errAPI(resp.status, body || resp.statusText);
116
+ }
117
+
118
+ return body;
119
+ } catch (err) {
120
+ if (err.name === 'AbortError') {
121
+ throw errNetwork(new Error('Request timed out'));
122
+ }
123
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
124
+ throw errNetwork(err);
125
+ }
126
+ throw err;
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+
132
+ async list(table, params = {}) {
133
+ const query = new URLSearchParams(params).toString();
134
+ const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
135
+ const result = await this.request(endpoint, { method: 'GET' });
136
+ return result?.result || [];
137
+ }
138
+
139
+ async get(table, sysID) {
140
+ const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
141
+ const result = await this.request(endpoint, { method: 'GET' });
142
+ return result?.result || null;
143
+ }
144
+
145
+ async create(table, data) {
146
+ const endpoint = `${this.baseURL}/api/now/table/${table}`;
147
+ const result = await this.request(endpoint, {
148
+ method: 'POST',
149
+ body: JSON.stringify(data),
150
+ });
151
+ return result?.result || null;
152
+ }
153
+
154
+ async update(table, sysID, data) {
155
+ const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
156
+ const result = await this.request(endpoint, {
157
+ method: 'PUT',
158
+ body: JSON.stringify(data),
159
+ });
160
+ return result?.result || null;
161
+ }
162
+
163
+ async delete(table, sysID) {
164
+ const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
165
+ await this.request(endpoint, { method: 'DELETE' });
166
+ }
167
+
168
+ async getCurrentUser() {
169
+ const params = new URLSearchParams();
170
+ params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
171
+ params.set('sysparm_limit', '1');
172
+ params.set('sysparm_display_value', 'all');
173
+ params.set('sysparm_fields', 'sys_id,user_name,name');
174
+ const records = await this.list('sys_user', params);
175
+ if (records.length === 0) return null;
176
+ const r = records[0];
177
+ return {
178
+ sys_id: r.sys_id?.value || r.sys_id,
179
+ user_name: r.user_name?.display_value || r.user_name,
180
+ name: r.name?.display_value || r.name,
181
+ };
182
+ }
183
+
184
+ async inspectFlow(identifier) {
185
+ const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
186
+
187
+ // 1) Resolve flow
188
+ const flowQuery = new URLSearchParams();
189
+ flowQuery.set('sysparm_display_value', 'all');
190
+ flowQuery.set('sysparm_limit', '1');
191
+ flowQuery.set('sysparm_query', isSysID ? `sys_id=${identifier}` : `name=${identifier}`);
192
+ flowQuery.set('sysparm_fields', 'sys_id,name,active,version,type');
193
+
194
+ const flowRecords = await this.list('sys_hub_flow', flowQuery);
195
+ if (!flowRecords || flowRecords.length === 0) {
196
+ const err = new Error(`flow not found: ${identifier}`);
197
+ err.code = 'not_found';
198
+ throw err;
199
+ }
200
+
201
+ const flow = flowRecords[0];
202
+ const flowSysID = getStringField(flow, 'sys_id');
203
+ let flowVersion = getStringField(flow, 'version');
204
+
205
+ const inspection = {
206
+ flow: {
207
+ name: getStringField(flow, 'name'),
208
+ active: getBoolField(flow, 'active'),
209
+ version: flowVersion,
210
+ type: getStringField(flow, 'type'),
211
+ sysID: flowSysID,
212
+ },
213
+ version: {},
214
+ payload: {},
215
+ triggerInstances: [],
216
+ actionInstances: [],
217
+ flowLogicInstances: [],
218
+ subFlowInstances: [],
219
+ flowInputs: [],
220
+ flowOutputs: [],
221
+ };
222
+
223
+ // 2) Fetch latest flow version
224
+ const versionQuery = new URLSearchParams();
225
+ versionQuery.set('sysparm_display_value', 'all');
226
+ versionQuery.set('sysparm_limit', '1');
227
+ versionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYDESCsys_updated_on`);
228
+ versionQuery.set('sysparm_fields', 'sys_id,flow,version,payload,sys_updated_on');
229
+
230
+ const versionRecords = await this.list('sys_hub_flow_version', versionQuery);
231
+ if (versionRecords && versionRecords.length > 0) {
232
+ inspection.version = versionRecords[0];
233
+ if (!inspection.flow.version) {
234
+ inspection.flow.version = getStringField(versionRecords[0], 'version');
235
+ }
236
+
237
+ const payload = getStringField(versionRecords[0], 'payload');
238
+ if (payload) {
239
+ try {
240
+ const payloadData = JSON.parse(payload);
241
+ inspection.payload = payloadData;
242
+ extractPayloadData(inspection, payloadData);
243
+ } catch {
244
+ // ignore parse error
245
+ }
246
+ }
247
+ }
248
+
249
+ // 3) Fetch trigger instances
250
+ const triggerQuery = new URLSearchParams();
251
+ triggerQuery.set('sysparm_display_value', 'all');
252
+ triggerQuery.set('sysparm_query', `flow=${flowSysID}`);
253
+ triggerQuery.set('sysparm_fields', 'sys_id,name,trigger_type,display_text,active,trigger_definition');
254
+ triggerQuery.set('sysparm_limit', '20');
255
+ const triggerRecords = await this.list('sys_hub_trigger_instance', triggerQuery);
256
+ if (triggerRecords) {
257
+ inspection.triggerInstances = triggerRecords;
258
+ }
259
+
260
+ // 4) Fallbacks when payload did not provide structure arrays
261
+ if (!inspection.actionInstances || inspection.actionInstances.length === 0) {
262
+ const actionQuery = new URLSearchParams();
263
+ actionQuery.set('sysparm_display_value', 'all');
264
+ actionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYorder`);
265
+ actionQuery.set('sysparm_fields', 'sys_id,order,name,display_text,comment,action_type');
266
+ actionQuery.set('sysparm_limit', '200');
267
+ const records = await this.list('sys_hub_action_instance', actionQuery);
268
+ if (records) inspection.actionInstances = records;
269
+ }
270
+
271
+ if (!inspection.flowLogicInstances || inspection.flowLogicInstances.length === 0) {
272
+ const logicTables = ['sys_hub_flow_logic', 'sys_hub_flow_logic_instance_v2'];
273
+ for (const table of logicTables) {
274
+ const logicQuery = new URLSearchParams();
275
+ logicQuery.set('sysparm_display_value', 'all');
276
+ logicQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYorder`);
277
+ logicQuery.set('sysparm_fields', 'sys_id,order,name,display_text,comment,parent_ui_id,logic_definition');
278
+ logicQuery.set('sysparm_limit', '200');
279
+ const records = await this.list(table, logicQuery);
280
+ if (records) inspection.flowLogicInstances.push(...records);
281
+ }
282
+ inspection.flowLogicInstances.sort((a, b) => parseOrderField(a) - parseOrderField(b));
283
+ }
284
+
285
+ if (!inspection.subFlowInstances || inspection.subFlowInstances.length === 0) {
286
+ const subflowQuery = new URLSearchParams();
287
+ subflowQuery.set('sysparm_display_value', 'all');
288
+ subflowQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYorder`);
289
+ subflowQuery.set('sysparm_fields', 'sys_id,order,subflow,name,display_text,comment,parent_ui_id');
290
+ subflowQuery.set('sysparm_limit', '200');
291
+ const records = await this.list('sys_hub_sub_flow_instance', subflowQuery);
292
+ if (records) inspection.subFlowInstances = records;
293
+ }
294
+
295
+ // Inputs/Outputs fallback
296
+ if (!inspection.flowInputs || inspection.flowInputs.length === 0) {
297
+ const inputsQuery = new URLSearchParams();
298
+ inputsQuery.set('sysparm_display_value', 'all');
299
+ inputsQuery.set('sysparm_query', `model=${flowSysID}^ORDERBYorder`);
300
+ inputsQuery.set('sysparm_fields', 'sys_id,name,label,type,mandatory,order');
301
+ inputsQuery.set('sysparm_limit', '200');
302
+ const records = await this.list('sys_hub_flow_input', inputsQuery);
303
+ if (records) inspection.flowInputs = records;
304
+ }
305
+
306
+ if (!inspection.flowOutputs || inspection.flowOutputs.length === 0) {
307
+ const outputsQuery = new URLSearchParams();
308
+ outputsQuery.set('sysparm_display_value', 'all');
309
+ outputsQuery.set('sysparm_query', `model=${flowSysID}^ORDERBYorder`);
310
+ outputsQuery.set('sysparm_fields', 'sys_id,name,label,type,order');
311
+ outputsQuery.set('sysparm_limit', '200');
312
+ const records = await this.list('sys_hub_flow_output', outputsQuery);
313
+ if (records) inspection.flowOutputs = records;
314
+ }
315
+
316
+ return inspection;
317
+ }
318
+
319
+ async aggregateCount(table, queryStr) {
320
+ const params = new URLSearchParams();
321
+ params.set('sysparm_count', 'true');
322
+ if (queryStr) params.set('sysparm_query', queryStr);
323
+ const endpoint = `${this.baseURL}/api/now/stats/${table}?${params.toString()}`;
324
+ const result = await this.request(endpoint, { method: 'GET' });
325
+ const stats = result?.result?.stats;
326
+ if (!stats) return 0;
327
+
328
+ let statsMap = stats;
329
+ if (typeof stats === 'string') {
330
+ try { statsMap = JSON.parse(stats); } catch { return 0; }
331
+ }
332
+
333
+ if (statsMap.count != null) {
334
+ const v = statsMap.count;
335
+ if (typeof v === 'number') return v;
336
+ if (typeof v === 'string') {
337
+ const n = parseInt(v, 10);
338
+ return isNaN(n) ? 0 : n;
339
+ }
340
+ }
341
+
342
+ for (const value of Object.values(statsMap)) {
343
+ if (value && typeof value === 'object' && value.count != null) {
344
+ const v = value.count;
345
+ if (typeof v === 'number') return v;
346
+ if (typeof v === 'string') {
347
+ const n = parseInt(v, 10);
348
+ return isNaN(n) ? 0 : n;
349
+ }
350
+ }
351
+ }
352
+
353
+ return 0;
354
+ }
355
+
356
+ // ─── Background Script Execution (3-step OAuth session flow) ───
357
+
358
+ /**
359
+ * Execute a background script on the ServiceNow instance via sys.scripts.do.
360
+ * Uses a 3-step session-establishment flow compatible with OAuth tokens:
361
+ * 1. Make a REST API call to get session cookies
362
+ * 2. GET /sys.scripts.do with cookies to extract the CSRF token (sysparm_ck)
363
+ * 3. POST /sys.scripts.do with the script, CSRF token, and cookies
364
+ *
365
+ * @param {string} script - JavaScript code to execute
366
+ * @returns {Promise<string>} The script's output text
367
+ */
368
+ async executeScript(script) {
369
+ // Step 1: Warm up the session by hitting any REST API — this makes
370
+ // ServiceNow issue session cookies for subsequent UI page requests.
371
+ // We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
372
+ const cookies = await this._warmSession();
373
+
374
+ // Step 2: GET /sys.scripts.do to extract the CSRF token from the HTML form.
375
+ const csrfToken = await this._getScriptsPageCSRF(cookies);
376
+
377
+ // Step 3: POST the script with form data including the CSRF token.
378
+ const endpoint = `${this.baseURL}/sys.scripts.do`;
379
+ const formBody = new URLSearchParams();
380
+ formBody.set('script', script);
381
+ formBody.set('sysparm_ck', csrfToken);
382
+ formBody.set('runscript', 'Run script');
383
+ formBody.set('sys_scope', 'global');
384
+ formBody.set('record_for_rollback', 'on');
385
+ formBody.set('quota_managed_transaction', 'on');
386
+
387
+ const html = await this.rawRequest(endpoint, {
388
+ method: 'POST',
389
+ headers: {
390
+ 'Content-Type': 'application/x-www-form-urlencoded',
391
+ ...(cookies ? { Cookie: cookies } : {}),
392
+ },
393
+ body: formBody.toString(),
394
+ });
395
+
396
+ return this._extractScriptOutput(html);
397
+ }
398
+
399
+ /**
400
+ * Fetch a record and related data from multiple tables.
401
+ * @param {string} table - Table to query
402
+ * @param {URLSearchParams} params - Query params for the main record
403
+ * @param {Array<{table: string, queryField: string, queryValue: string, fields: string[], displayAs: string}>} related - Related tables to fetch
404
+ * @returns {Promise<{_record: object, [key: string]: any}>}
405
+ */
406
+ async recordWithRelated(table, params, related) {
407
+ const records = await this.list(table, params);
408
+ if (records.length === 0) throw new Error('Record not found');
409
+
410
+ const result = { _record: records[0] };
411
+
412
+ for (const rel of related) {
413
+ try {
414
+ const relParams = new URLSearchParams();
415
+ relParams.set('sysparm_display_value', 'all');
416
+ relParams.set('sysparm_fields', (rel.fields || []).join(','));
417
+ relParams.set('sysparm_query', `${rel.queryField}=${rel.queryValue}`);
418
+ relParams.set('sysparm_limit', '100');
419
+ result[rel.displayAs] = await this.list(rel.table, relParams);
420
+ } catch {
421
+ result[rel.displayAs] = [];
422
+ }
423
+ }
424
+
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * Fetch attachments for a record.
430
+ * @param {string} tableName - Table name (e.g. sc_req_item)
431
+ * @param {string} tableSysID - Sys ID of the record
432
+ * @returns {Promise<Array>}
433
+ */
434
+ async fetchAttachments(tableName, tableSysID) {
435
+ const params = new URLSearchParams();
436
+ params.set('sysparm_display_value', 'all');
437
+ params.set('sysparm_fields', 'sys_id,file_name,sys_created_on,sys_created_by');
438
+ params.set('sysparm_query', `table_name=${tableName}^table_sys_id=${tableSysID}`);
439
+ return this.list('sys_attachment', params);
440
+ }
441
+
442
+ /**
443
+ * Fetch catalog variables for a request item (RITM).
444
+ * @param {string} ritmSysID - Sys ID of the request item
445
+ * @returns {Promise<Array<{question: string, value: string}>>}
446
+ */
447
+ async fetchCatalogVariables(ritmSysID) {
448
+ const params = new URLSearchParams();
449
+ params.set('sysparm_display_value', 'all');
450
+ params.set('sysparm_fields', 'item_option_new,value');
451
+ params.set('sysparm_query', `request_item=${ritmSysID}`);
452
+ params.set('sysparm_limit', '100');
453
+
454
+ const optRecords = await this.list('sc_item_option', params);
455
+ const variables = [];
456
+
457
+ for (const opt of optRecords) {
458
+ let question = '';
459
+ if (opt.item_option_new && typeof opt.item_option_new === 'object') {
460
+ question = opt.item_option_new.display_value || '';
461
+ }
462
+
463
+ let value = '';
464
+ if (opt.value && typeof opt.value === 'object') {
465
+ value = opt.value.display_value || opt.value.value || '';
466
+ } else if (typeof opt.value === 'string') {
467
+ value = opt.value;
468
+ }
469
+
470
+ if (question) {
471
+ variables.push({ question, value });
472
+ }
473
+ }
474
+
475
+ return variables;
476
+ }
477
+
478
+ async _warmSession() {
479
+ try {
480
+ const endpoint = `${this.baseURL}/api/now/table/sys_user?sysparm_limit=1`;
481
+ const resp = await this._fetchWithAuth(endpoint, { method: 'GET', headers: { Accept: 'application/json' } });
482
+ // Extract cookies for subsequent UI page requests
483
+ const setCookie = resp.headers.getSetCookie?.() || resp.headers.get('set-cookie');
484
+ if (setCookie) {
485
+ return Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;
486
+ }
487
+ return '';
488
+ } catch {
489
+ return '';
490
+ }
491
+ }
492
+
493
+ async _getScriptsPageCSRF(cookies) {
494
+ const endpoint = `${this.baseURL}/sys.scripts.do`;
495
+ const html = await this.rawRequest(endpoint, {
496
+ method: 'GET',
497
+ headers: cookies ? { Cookie: cookies } : {},
498
+ });
499
+
500
+ // Extract <input name="sysparm_ck" type="hidden" value="TOKEN"> from HTML
501
+ const marker = '<input name="sysparm_ck" type="hidden" value="';
502
+ const idx = html.indexOf(marker);
503
+ if (idx !== -1) {
504
+ const start = idx + marker.length;
505
+ const end = html.indexOf('"', start);
506
+ if (end !== -1) return html.substring(start, end);
507
+ const altEnd = html.indexOf('">', start);
508
+ if (altEnd !== -1) return html.substring(start, altEnd);
509
+ }
510
+
511
+ // Fallback: try without type attribute
512
+ const altMarker = 'name="sysparm_ck" value="';
513
+ const altIdx = html.indexOf(altMarker);
514
+ if (altIdx !== -1) {
515
+ const start = altIdx + altMarker.length;
516
+ const end = html.indexOf('"', start);
517
+ if (end !== -1) return html.substring(start, end);
518
+ }
519
+
520
+ // Not authorized or couldn't extract token
521
+ if (html.includes('not authorized') || html.includes('login.do')) {
522
+ throw new Error('Not authorized to access scripts page. Your OAuth token may not support UI sessions. Try the browser: ' + this.baseURL + '/sys.scripts.do');
523
+ }
524
+ throw new Error('Could not find CSRF token on scripts page (response: ' + html.substring(0, 200) + ')');
525
+ }
526
+
527
+ _extractScriptOutput(html) {
528
+ // Convert <BR> and <BR/> to newlines first
529
+ let out = html.replace(/<BR\s*\/?>/gi, '\n').replace(/<br\s*\/?>/gi, '\n');
530
+
531
+ // Find <PRE>...</PRE> content
532
+ const preMatch = out.match(/<PRE[^>]*>([\s\S]*?)<\/PRE>/i);
533
+ if (preMatch) {
534
+ out = preMatch[1];
535
+ }
536
+
537
+ // Strip remaining HTML tags
538
+ out = out.replace(/<[^>]+>/g, '');
539
+
540
+ // Clean up: decode HTML entities, trim lines
541
+ out = out.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
542
+
543
+ // Trim each line and remove empty lines
544
+ const lines = out.split('\n').map(l => l.trim()).filter(l => l.length > 0);
545
+ return lines.join('\n');
546
+ }
547
+ }
548
+
549
+ function getBoolField(record, field) {
550
+ const val = record?.[field];
551
+ if (val == null) return false;
552
+ if (typeof val === 'boolean') return val;
553
+ if (typeof val === 'string') return val === 'true' || val === '1';
554
+ if (typeof val === 'object') {
555
+ const dv = val.display_value;
556
+ if (dv != null) return String(dv) === 'true' || String(dv) === '1';
557
+ const v = val.value;
558
+ if (v != null) return String(v) === 'true' || String(v) === '1';
559
+ }
560
+ return false;
561
+ }
562
+
563
+ function extractPayloadData(inspection, payload) {
564
+ const actions = toMapSlice(payload.actionInstances);
565
+ if (actions) inspection.actionInstances = actions;
566
+
567
+ const logic = toMapSlice(payload.flowLogicInstances);
568
+ if (logic) inspection.flowLogicInstances = logic;
569
+
570
+ const subflows = toMapSlice(payload.subFlowInstances);
571
+ if (subflows) inspection.subFlowInstances = subflows;
572
+
573
+ const inputs = toMapSlice(payload.inputs);
574
+ if (inputs) inspection.flowInputs = inputs;
575
+
576
+ const outputs = toMapSlice(payload.outputs);
577
+ if (outputs) inspection.flowOutputs = outputs;
578
+
579
+ const triggerInstances = payload.triggerInstances;
580
+ if (!Array.isArray(triggerInstances) || triggerInstances.length === 0) return;
581
+
582
+ const first = triggerInstances[0];
583
+ if (!first || typeof first !== 'object') return;
584
+
585
+ if (!inspection.version || typeof inspection.version !== 'object') {
586
+ inspection.version = {};
587
+ }
588
+
589
+ const triggerName = getStringField(first, 'name');
590
+ if (triggerName) inspection.version.trigger_name = triggerName;
591
+
592
+ const triggerType = getStringField(first, 'type');
593
+ if (triggerType) inspection.version.trigger_type = triggerType;
594
+
595
+ if (Array.isArray(first.inputs)) {
596
+ for (const input of first.inputs) {
597
+ if (!input || typeof input !== 'object') continue;
598
+ const name = getStringField(input, 'name');
599
+ const value = getStringField(input, 'value');
600
+ if (name === 'table' && value) inspection.version.trigger_table = value;
601
+ if (name === 'time' && value) inspection.version.trigger_time = value;
602
+ }
603
+ }
604
+ }
605
+
606
+ function toMapSlice(v) {
607
+ if (!Array.isArray(v)) return null;
608
+ return v.filter(item => item && typeof item === 'object');
609
+ }
610
+
611
+ function parseOrderField(record) {
612
+ const order = getStringField(record, 'order');
613
+ const n = parseInt(order, 10);
614
+ return isNaN(n) ? 0 : n;
615
+ }