@jacebenson/jsn 0.0.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -280
- package/package.json +3 -1
- package/skills/servicenow/SKILL.md +137 -0
- package/src/app.js +1 -1
- package/src/auth.js +139 -2
- package/src/cli.js +18 -6
- package/src/commands/_ticket.js +8 -4
- package/src/commands/auth.js +182 -32
- package/src/commands/dev/_generic.js +129 -12
- package/src/commands/dev/eval.js +27 -6
- package/src/commands/dev/flows.js +46 -1
- package/src/commands/dev/logs.js +15 -2
- package/src/commands/dev/scopes.js +62 -2
- package/src/commands/dev/updatesets.js +45 -2
- package/src/commands/groupmembers.js +114 -1
- package/src/commands/grouproles.js +114 -1
- package/src/commands/groups.js +87 -3
- package/src/commands/profiles.js +14 -2
- package/src/commands/records.js +24 -6
- package/src/commands/requests.js +53 -1
- package/src/commands/skill.js +109 -0
- package/src/commands/tickets.js +1 -1
- package/src/commands/users.js +90 -3
- package/src/commands/version.js +58 -2
- package/src/help.js +85 -0
- package/src/helpers.js +84 -0
- package/src/output.js +30 -0
- package/src/sdk.js +258 -0
package/src/commands/version.js
CHANGED
|
@@ -2,6 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
+
const NPM_PKG = '@jacebenson/jsn';
|
|
6
|
+
const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
|
|
7
|
+
|
|
5
8
|
function getVersion() {
|
|
6
9
|
try {
|
|
7
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -13,13 +16,66 @@ function getVersion() {
|
|
|
13
16
|
}
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
async function checkLatest() {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(NPM_REGISTRY);
|
|
22
|
+
if (!res.ok) return null;
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
return data.version;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function versionCmd(wrap) {
|
|
17
31
|
return {
|
|
18
32
|
command: 'version',
|
|
19
33
|
describe: 'Show version information',
|
|
20
|
-
|
|
34
|
+
builder: (y) => y
|
|
35
|
+
.option('check', {
|
|
36
|
+
alias: 'c',
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
describe: 'Check npm registry for newer version',
|
|
39
|
+
}),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
21
41
|
const version = getVersion();
|
|
22
|
-
|
|
42
|
+
|
|
43
|
+
if (argv.check) {
|
|
44
|
+
const latest = await checkLatest();
|
|
45
|
+
if (!latest) {
|
|
46
|
+
app.ok({ version }, {
|
|
47
|
+
summary: `jsn ${version} (could not reach npm registry to check for updates)`,
|
|
48
|
+
notice: `Run \`npm view ${NPM_PKG}\` directly to check manually`,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isUpToDate = version === latest;
|
|
54
|
+
|
|
55
|
+
if (isUpToDate) {
|
|
56
|
+
app.ok({
|
|
57
|
+
version,
|
|
58
|
+
latest,
|
|
59
|
+
upToDate: true,
|
|
60
|
+
}, {
|
|
61
|
+
summary: `jsn ${version} — up to date`,
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
app.ok({
|
|
65
|
+
version,
|
|
66
|
+
latest,
|
|
67
|
+
upToDate: false,
|
|
68
|
+
installCmd: `npm install -g ${NPM_PKG}`,
|
|
69
|
+
}, {
|
|
70
|
+
summary: `jsn ${version} — newer version ${latest} available`,
|
|
71
|
+
breadcrumbs: [
|
|
72
|
+
{ action: 'update', cmd: `npm install -g ${NPM_PKG}`, description: `Update jsn from ${version} to ${latest}` },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
app.ok({ version }, { summary: `jsn ${version}` });
|
|
78
|
+
}
|
|
23
79
|
}),
|
|
24
80
|
};
|
|
25
81
|
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Custom grouped help renderer
|
|
2
|
+
// Mirrors the Go version's custom Cobra help template
|
|
3
|
+
|
|
4
|
+
const COMMAND_GROUPS = {
|
|
5
|
+
'CORE COMMANDS': [
|
|
6
|
+
{ name: 'incidents', alias: 'inc', desc: 'Manage incidents' },
|
|
7
|
+
{ name: 'changes', alias: 'chg', desc: 'Manage change requests' },
|
|
8
|
+
{ name: 'requests', alias: 'req', desc: 'Manage service catalog requests' },
|
|
9
|
+
{ name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
|
|
10
|
+
{ name: 'tickets', alias: 'ticket', desc: 'Query generic tickets' },
|
|
11
|
+
],
|
|
12
|
+
'DATA & ADMIN': [
|
|
13
|
+
{ name: 'records', desc: 'Generic Table API for any table' },
|
|
14
|
+
{ name: 'users', alias: 'user', desc: 'Manage ServiceNow users' },
|
|
15
|
+
{ name: 'groups', alias: 'group', desc: 'Manage groups' },
|
|
16
|
+
{ name: 'groupmembers', alias: 'gm', desc: 'Manage group memberships' },
|
|
17
|
+
{ name: 'grouproles', alias: 'gr', desc: 'Manage group roles' },
|
|
18
|
+
],
|
|
19
|
+
'DEVELOPMENT': [
|
|
20
|
+
{ name: 'dev', desc: 'Manage development artifacts (flows, includes, rules, ACLs, etc.)' },
|
|
21
|
+
],
|
|
22
|
+
'CONFIGURATION': [
|
|
23
|
+
{ name: 'setup', desc: 'Interactive first-time setup' },
|
|
24
|
+
{ name: 'auth', desc: 'Manage OAuth authentication' },
|
|
25
|
+
{ name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const UTILITY_COMMANDS = [
|
|
30
|
+
{ name: 'skill', desc: 'Manage the jsn AI agent skill file (show, fetch, install)' },
|
|
31
|
+
{ name: 'version', desc: 'Show version information (--check for npm updates)' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function renderGroup(name, commands) {
|
|
35
|
+
const lines = [`\n${name}`];
|
|
36
|
+
lines.push('─'.repeat(50));
|
|
37
|
+
for (const cmd of commands) {
|
|
38
|
+
const aliasPart = cmd.alias ? ` (${cmd.alias})` : '';
|
|
39
|
+
const padded = ` jsn ${cmd.name}`.padEnd(22);
|
|
40
|
+
lines.push(`${padded}${cmd.desc}${aliasPart}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderFlags() {
|
|
46
|
+
return `
|
|
47
|
+
FLAGS
|
|
48
|
+
--instance ServiceNow instance URL (e.g., https://dev12345.service-now.com) [string]
|
|
49
|
+
-p, --profile Configuration profile to use [string]
|
|
50
|
+
--format Output format: auto, json, markdown, styled, quiet [string]
|
|
51
|
+
--json Output in JSON format [boolean]
|
|
52
|
+
-q, --quiet Output only data, no envelope [boolean]
|
|
53
|
+
--styled Force styled output [boolean]
|
|
54
|
+
--markdown Output in Markdown format [boolean]
|
|
55
|
+
--help Show help [boolean]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderTips() {
|
|
59
|
+
return `
|
|
60
|
+
TIPS
|
|
61
|
+
--query is available on every list command (e.g. "incidents list --query priority=1")
|
|
62
|
+
Use "jsn <command> --help" for details, or "jsn <command> list --help" for list options
|
|
63
|
+
|
|
64
|
+
LEARN MORE
|
|
65
|
+
Use "jsn <command> --help" for more information about a command.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderHelp() {
|
|
69
|
+
const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
|
|
70
|
+
const utilitySection = renderGroup('UTILITY', UTILITY_COMMANDS);
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
'Usage: jsn <command> [options]',
|
|
74
|
+
'',
|
|
75
|
+
`Command-line interface for ServiceNow`,
|
|
76
|
+
'',
|
|
77
|
+
...sections,
|
|
78
|
+
'',
|
|
79
|
+
utilitySection,
|
|
80
|
+
'',
|
|
81
|
+
renderFlags(),
|
|
82
|
+
renderTips(),
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
package/src/helpers.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Shared helper utilities
|
|
2
2
|
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import search from '@inquirer/search';
|
|
5
|
+
import { isTTY, FormatAuto } from './output.js';
|
|
6
|
+
|
|
3
7
|
export function getStringField(record, field) {
|
|
4
8
|
if (!record || typeof record !== 'object') return '';
|
|
5
9
|
const val = record[field];
|
|
@@ -58,3 +62,83 @@ export function extractProfileName(instanceURL) {
|
|
|
58
62
|
export function buildQuerySuffix(query) {
|
|
59
63
|
return query ? ` --query "${query}"` : '';
|
|
60
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shared interactive list helper with search-as-you-type.
|
|
68
|
+
* All list commands that want an interactive TTY picker should use this.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
* @param {App} opts.app
|
|
72
|
+
* @param {string} opts.table — ServiceNow table name
|
|
73
|
+
* @param {string} opts.singular — e.g. "script include", "log entry"
|
|
74
|
+
* @param {string[]} opts.columns — default display columns
|
|
75
|
+
* @param {number} opts.limit — max records (default 20)
|
|
76
|
+
* @param {string} opts.query — optional encoded query
|
|
77
|
+
* @param {Function} opts.formatLabel — (record) => string for the picker display
|
|
78
|
+
* @param {string} opts.labelField — field used to match selection (default: 'name')
|
|
79
|
+
* @returns {Promise<void>|null} null if no selection made or non-interactive
|
|
80
|
+
*/
|
|
81
|
+
export async function interactiveList({ app, table, singular, columns, limit = 20, query = '', formatLabel, labelField = 'name' }) {
|
|
82
|
+
const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
|
|
83
|
+
if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin) || query) {
|
|
84
|
+
return null; // not interactive — caller should fall back to text/table
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id')];
|
|
88
|
+
const params = new URLSearchParams();
|
|
89
|
+
params.set('sysparm_limit', String(limit));
|
|
90
|
+
params.set('sysparm_display_value', 'all');
|
|
91
|
+
params.set('sysparm_fields', pickerColumns.join(','));
|
|
92
|
+
params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
|
|
93
|
+
|
|
94
|
+
const records = await app.sdk.list(table, params);
|
|
95
|
+
if (records.length === 0) return null;
|
|
96
|
+
|
|
97
|
+
const choices = records.map(r => ({
|
|
98
|
+
name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
|
|
99
|
+
value: r,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const selected = await search({
|
|
104
|
+
message: `Select ${vowelArticle(singular)} ${singular}:`,
|
|
105
|
+
source: async (input) => {
|
|
106
|
+
if (!input) return choices;
|
|
107
|
+
const term = input.toLowerCase();
|
|
108
|
+
return choices.filter(c => c.name.toLowerCase().includes(term));
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return selected; // the record object
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function vowelArticle(word) {
|
|
121
|
+
const first = word.charAt(0).toLowerCase();
|
|
122
|
+
return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse --data or --data-file into a JSON object.
|
|
127
|
+
* If --data-file is given, reads the file. Otherwise parses --data directly.
|
|
128
|
+
* Throws if neither is provided or JSON is invalid.
|
|
129
|
+
*/
|
|
130
|
+
export function parseDataArg(argv) {
|
|
131
|
+
let raw;
|
|
132
|
+
if (argv['data-file']) {
|
|
133
|
+
raw = fs.readFileSync(argv['data-file'], 'utf-8');
|
|
134
|
+
} else if (argv.data) {
|
|
135
|
+
raw = argv.data;
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error('--data or --data-file is required');
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(raw);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw new Error(`Invalid JSON: ${e.message}\n\nHint: On Windows PowerShell, use --data-file instead of --data to avoid quote mangling.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/output.js
CHANGED
|
@@ -358,6 +358,36 @@ export class OutputWriter {
|
|
|
358
358
|
this.writer.write('\n');
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// ── Attachments ──
|
|
362
|
+
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
363
|
+
this.writer.write('─ Attachments ─\n');
|
|
364
|
+
for (const att of data._attachments) {
|
|
365
|
+
const fileName = getDisplayValue(att.file_name);
|
|
366
|
+
const createdBy = getDisplayValue(att.sys_created_by);
|
|
367
|
+
const createdOn = getDisplayValue(att.sys_created_on);
|
|
368
|
+
this.writer.write(` ${fileName} (by ${createdBy}, ${createdOn})\n`);
|
|
369
|
+
}
|
|
370
|
+
this.writer.write('\n');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Catalog Variables ──
|
|
374
|
+
if (data._variables && Array.isArray(data._variables) && data._variables.length > 0) {
|
|
375
|
+
this.writer.write('─ Catalog Variables ─\n');
|
|
376
|
+
for (const v of data._variables) {
|
|
377
|
+
const q = v.question || '';
|
|
378
|
+
const val = v.value || '';
|
|
379
|
+
if (val.includes('\n')) {
|
|
380
|
+
this.writer.write(` ${q}:\n`);
|
|
381
|
+
for (const line of val.split('\n')) {
|
|
382
|
+
this.writer.write(` ${line}\n`);
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
this.writer.write(` ${q}: ${val}\n`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.writer.write('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
361
391
|
if (instanceURL && table) {
|
|
362
392
|
const sysID = getDisplayValue(data.sys_id);
|
|
363
393
|
if (sysID) {
|
package/src/sdk.js
CHANGED
|
@@ -79,6 +79,56 @@ export class SDKClient {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
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
|
+
|
|
82
132
|
async list(table, params = {}) {
|
|
83
133
|
const query = new URLSearchParams(params).toString();
|
|
84
134
|
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
@@ -115,6 +165,22 @@ export class SDKClient {
|
|
|
115
165
|
await this.request(endpoint, { method: 'DELETE' });
|
|
116
166
|
}
|
|
117
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
|
+
|
|
118
184
|
async inspectFlow(identifier) {
|
|
119
185
|
const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
|
|
120
186
|
|
|
@@ -286,6 +352,198 @@ export class SDKClient {
|
|
|
286
352
|
|
|
287
353
|
return 0;
|
|
288
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
|
+
}
|
|
289
547
|
}
|
|
290
548
|
|
|
291
549
|
function getBoolField(record, field) {
|