@jacebenson/jsn 1.0.7 → 2.0.2
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 +196 -243
- package/bin/jsn.js +2 -57
- package/package.json +35 -32
- package/skills/servicenow/SKILL.md +137 -0
- package/src/app.js +194 -0
- package/src/auth.js +517 -0
- package/src/cli.js +174 -0
- package/src/commands/_ticket.js +260 -0
- package/src/commands/auth.js +227 -0
- package/src/commands/catalog.js +325 -0
- package/src/commands/changes.js +7 -0
- package/src/commands/dev/_generic.js +342 -0
- package/src/commands/dev/_simple.js +89 -0
- package/src/commands/dev/eval.js +38 -0
- package/src/commands/dev/flows.js +573 -0
- package/src/commands/dev/forms.js +313 -0
- package/src/commands/dev/lists.js +233 -0
- package/src/commands/dev/logs.js +64 -0
- package/src/commands/dev/rest.js +64 -0
- package/src/commands/dev/scopes.js +156 -0
- package/src/commands/dev/updatesets.js +184 -0
- package/src/commands/dev.js +53 -0
- package/src/commands/groupmembers.js +152 -0
- package/src/commands/grouproles.js +152 -0
- package/src/commands/groups.js +141 -0
- package/src/commands/incidents.js +7 -0
- package/src/commands/profiles.js +91 -0
- package/src/commands/records.js +171 -0
- package/src/commands/requests.js +59 -0
- package/src/commands/setup.js +39 -0
- package/src/commands/skill.js +144 -0
- package/src/commands/tasks.js +7 -0
- package/src/commands/tickets.js +121 -0
- package/src/commands/users.js +144 -0
- package/src/commands/version.js +81 -0
- package/src/config.js +154 -0
- package/src/context.js +62 -0
- package/src/errors.js +101 -0
- package/src/help.js +85 -0
- package/src/helpers.js +182 -0
- package/src/output.js +440 -0
- package/src/sdk.js +615 -0
- package/scripts/install.sh +0 -227
- package/scripts/npm-install.js +0 -235
- 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/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
|
+
}
|