@jacebenson/jsn 3.0.3 → 3.0.6
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/package.json +2 -2
- package/skills/servicenow/SKILL.md +4 -4
- package/src/cli.js +4 -1
- package/src/commands/dev/_simple.js +0 -1
- package/src/commands/dev/eval.js +19 -3
- package/src/commands/dev/scrapi.js +344 -0
- package/src/commands/dev.js +3 -2
- package/src/commands/records.js +13 -3
- package/src/commands/skill.js +122 -31
- package/src/errors.js +28 -1
- package/src/helpers.js +61 -6
- package/src/sdk.js +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
4
4
|
"description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,4 +43,4 @@
|
|
|
43
43
|
"README.md",
|
|
44
44
|
"LICENSE"
|
|
45
45
|
]
|
|
46
|
-
}
|
|
46
|
+
}
|
|
@@ -10,12 +10,12 @@ description: |
|
|
|
10
10
|
license: MIT
|
|
11
11
|
compatibility: |
|
|
12
12
|
Requires jsn CLI (github.com/jacebenson/jsn). Install via:
|
|
13
|
-
|
|
13
|
+
npm install -g @jacebenson/jsn
|
|
14
14
|
Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
|
|
15
15
|
|
|
16
|
-
Windows users:
|
|
17
|
-
command will automatically detect its location and show
|
|
18
|
-
|
|
16
|
+
Windows users: Install Node.js first, then the npm command above. If jsn is not
|
|
17
|
+
in your PATH, the setup command will automatically detect its location and show
|
|
18
|
+
the correct command path.
|
|
19
19
|
metadata:
|
|
20
20
|
author: jacebenson
|
|
21
21
|
version: "1.0.0"
|
package/src/cli.js
CHANGED
|
@@ -142,9 +142,12 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// Auto-check skill on every command (fire-and-forget, non-blocking)
|
|
145
|
+
// Skipped under --json, --quiet, --no-skill-check, or env var
|
|
145
146
|
const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
146
147
|
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
|
147
|
-
|| argv['no-skill-check']
|
|
148
|
+
|| argv['no-skill-check']
|
|
149
|
+
|| argv.json
|
|
150
|
+
|| argv.quiet;
|
|
148
151
|
if (!skipSkillCheck) {
|
|
149
152
|
// Fire check but don't await — it runs in background
|
|
150
153
|
checkSkill().then(result => {
|
|
@@ -81,7 +81,6 @@ export const spPagesCmd = (wrap) => buildDevCmd('sppages', 'sp_page', ['sp-pages
|
|
|
81
81
|
export const spWidgetsCmd = (wrap) => buildDevCmd('spwidgets', 'sp_widget', ['sp-widget', 'widgets'], ['id', 'name', 'sys_scope'], wrap, { singular: 'Service Portal widget', readOnly: true });
|
|
82
82
|
export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', scopeValidation: true });
|
|
83
83
|
export const appMenuCmd = (wrap) => buildDevCmd('appmenu', 'sys_app_application', ['app-menu', 'menu'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application menu', scopeValidation: true });
|
|
84
|
-
export const scRAPICmd = (wrap) => buildDevCmd('scrapi', 'sys_ws_operation', ['scripted-rest', 'rest-api'], ['name', 'sys_ws_definition', 'sys_scope'], wrap, { singular: 'scripted REST API', readOnly: true });
|
|
85
84
|
|
|
86
85
|
// Commands with full CRUD
|
|
87
86
|
export const aclsCmd = (wrap) => buildDevCmd('acls', 'sys_security_acl', ['acl'], ['name', 'operation', 'type', 'active', 'sys_scope'], wrap, { singular: 'ACL', scopeValidation: true });
|
package/src/commands/dev/eval.js
CHANGED
|
@@ -7,10 +7,19 @@ export function evalCmd(wrap) {
|
|
|
7
7
|
builder: (yargs) => {
|
|
8
8
|
return yargs
|
|
9
9
|
.option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
|
|
10
|
-
.option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
|
|
10
|
+
.option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
|
|
11
|
+
.option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: global)' });
|
|
11
12
|
},
|
|
12
13
|
handler: wrap(async (argv, app) => {
|
|
13
14
|
let script;
|
|
15
|
+
let scopeSysId = null;
|
|
16
|
+
|
|
17
|
+
if (argv.scope) {
|
|
18
|
+
scopeSysId = await app.sdk.resolveScope(argv.scope);
|
|
19
|
+
if (!scopeSysId) {
|
|
20
|
+
throw new Error(`Scope not found: ${argv.scope}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
if (argv.file) {
|
|
16
25
|
script = fs.readFileSync(argv.file, 'utf-8');
|
|
@@ -20,13 +29,20 @@ export function evalCmd(wrap) {
|
|
|
20
29
|
throw new Error('--script or --file is required');
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
if (scopeSysId) {
|
|
33
|
+
script = `var $scopeSysId = '${scopeSysId}';\n${script}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const warning = scopeSysId
|
|
37
|
+
? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
|
|
38
|
+
: '';
|
|
39
|
+
const output = await app.sdk.executeScript(script, argv.scope);
|
|
24
40
|
app.ok({
|
|
25
41
|
script,
|
|
26
42
|
output,
|
|
27
43
|
instance: app.getEffectiveInstance(),
|
|
28
44
|
}, {
|
|
29
|
-
summary: 'Script executed',
|
|
45
|
+
summary: 'Script executed' + warning,
|
|
30
46
|
breadcrumbs: [{
|
|
31
47
|
action: 'eval',
|
|
32
48
|
cmd: 'jsn dev eval --script \'...\'',
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Custom scraPI command: list/show individual operations + one-shot API definition creation
|
|
2
|
+
//
|
|
3
|
+
// The 'create' subcommand lets you build a full Scripted REST API in one call:
|
|
4
|
+
// jsn dev scrapi create \
|
|
5
|
+
// --name "SL Managed Services" --service-id managed_services --namespace slapi \
|
|
6
|
+
// --resource create=POST:/create:./create.js \
|
|
7
|
+
// --resource comment=POST:/comment:./comment.js
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { formatRecordForDisplay, getStringField, isHexString } from '../../helpers.js';
|
|
12
|
+
import { errUsageHint } from '../../errors.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a --resource flag value of the form:
|
|
16
|
+
* name=METHOD:/path:./script.js
|
|
17
|
+
* Returns { name, method, relativePath, scriptFile } or null.
|
|
18
|
+
*/
|
|
19
|
+
function parseResourceArg(raw) {
|
|
20
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
21
|
+
|
|
22
|
+
// Split on first '=' to get name and the rest
|
|
23
|
+
const eqIdx = raw.indexOf('=');
|
|
24
|
+
if (eqIdx === -1) return null;
|
|
25
|
+
const name = raw.slice(0, eqIdx).trim();
|
|
26
|
+
const rest = raw.slice(eqIdx + 1).trim();
|
|
27
|
+
|
|
28
|
+
// Rest should be METHOD:/path:./script.js
|
|
29
|
+
// Split on first ':' to separate METHOD from the path:file part
|
|
30
|
+
const colonIdx = rest.indexOf(':');
|
|
31
|
+
if (colonIdx === -1) return null;
|
|
32
|
+
const method = rest.slice(0, colonIdx).trim().toUpperCase();
|
|
33
|
+
const pathFile = rest.slice(colonIdx + 1).trim();
|
|
34
|
+
|
|
35
|
+
// Split pathAndFile on last ':' to separate relative_path from script file
|
|
36
|
+
const lastColon = pathFile.lastIndexOf(':');
|
|
37
|
+
if (lastColon === -1) return null;
|
|
38
|
+
const relativePath = pathFile.slice(0, lastColon).trim();
|
|
39
|
+
const scriptFile = pathFile.slice(lastColon + 1).trim();
|
|
40
|
+
|
|
41
|
+
if (!relativePath.startsWith('/')) {
|
|
42
|
+
return null; // relative_path must start with /
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { name, method, relativePath, scriptFile };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read script content from a file path (relative to cwd or absolute).
|
|
50
|
+
*/
|
|
51
|
+
function readScriptFile(filePath) {
|
|
52
|
+
const resolved = path.resolve(filePath);
|
|
53
|
+
if (!fs.existsSync(resolved)) {
|
|
54
|
+
throw errUsageHint(
|
|
55
|
+
`Script file not found: ${filePath}`,
|
|
56
|
+
`Checked: ${resolved}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return fs.readFileSync(resolved, 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function scrapiCmd(wrap) {
|
|
63
|
+
return {
|
|
64
|
+
command: 'scrapi [subcommand]',
|
|
65
|
+
aliases: ['scripted-rest', 'rest-api'],
|
|
66
|
+
describe: 'Manage Scripted REST APIs (list/show operations, create full APIs with resources)',
|
|
67
|
+
builder: (yargs) => {
|
|
68
|
+
return yargs
|
|
69
|
+
// ── list ──
|
|
70
|
+
.command({
|
|
71
|
+
command: 'list',
|
|
72
|
+
aliases: ['ls'],
|
|
73
|
+
describe: 'List API operations',
|
|
74
|
+
builder: (y) => y
|
|
75
|
+
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident")' })
|
|
76
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
|
|
77
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
|
|
78
|
+
handler: wrap(async (argv, app) => {
|
|
79
|
+
const columns = argv.columns ? argv.columns.split(',') : ['name', 'sys_ws_definition', 'http_method', 'relative_path', 'sys_scope'];
|
|
80
|
+
const query = argv.query || '';
|
|
81
|
+
const limit = argv.limit;
|
|
82
|
+
|
|
83
|
+
const params = new URLSearchParams();
|
|
84
|
+
params.set('sysparm_limit', String(limit));
|
|
85
|
+
params.set('sysparm_display_value', 'all');
|
|
86
|
+
params.set('sysparm_fields', ['sys_id', ...columns].join(','));
|
|
87
|
+
const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
88
|
+
params.set('sysparm_query', q);
|
|
89
|
+
const records = await app.sdk.list('sys_ws_operation', params);
|
|
90
|
+
|
|
91
|
+
app.ok({
|
|
92
|
+
table: 'sys_ws_operation',
|
|
93
|
+
count: records.length,
|
|
94
|
+
columns,
|
|
95
|
+
records: records.map(r => formatRecordForDisplay(r, columns)),
|
|
96
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
97
|
+
}, {
|
|
98
|
+
summary: `${records.length} operation(s)`,
|
|
99
|
+
breadcrumbs: [
|
|
100
|
+
{ action: 'create', cmd: 'jsn dev scrapi create --name "My API" --service-id my-api --namespace myapp --resource get=GET:/items:./script.js', description: 'Create a new REST API' },
|
|
101
|
+
{ action: 'show', cmd: 'jsn dev scrapi show <name>', description: 'Show operation details' },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
// ── show ──
|
|
107
|
+
.command({
|
|
108
|
+
command: 'show <identifier>',
|
|
109
|
+
aliases: ['get'],
|
|
110
|
+
describe: 'Show an API definition or operation by service_id, name, or sys_id — includes child resources',
|
|
111
|
+
handler: wrap(async (argv, app) => {
|
|
112
|
+
const id = argv.identifier;
|
|
113
|
+
const isSysId = isHexString(id) && id.length === 32;
|
|
114
|
+
|
|
115
|
+
// 1. Try to find a sys_ws_operation by name or sys_id
|
|
116
|
+
const opParams = new URLSearchParams();
|
|
117
|
+
opParams.set('sysparm_query', `${isSysId ? 'sys_id' : 'name'}=${id}`);
|
|
118
|
+
opParams.set('sysparm_limit', '1');
|
|
119
|
+
opParams.set('sysparm_display_value', 'all');
|
|
120
|
+
let records = await app.sdk.list('sys_ws_operation', opParams);
|
|
121
|
+
|
|
122
|
+
if (records.length > 0) {
|
|
123
|
+
// Found an operation — show it directly
|
|
124
|
+
records[0]._context = {
|
|
125
|
+
instance_url: app.getEffectiveInstance(),
|
|
126
|
+
table: 'sys_ws_operation',
|
|
127
|
+
};
|
|
128
|
+
return app.ok(records[0], {
|
|
129
|
+
summary: `Operation: ${getStringField(records[0], 'name') || id}`,
|
|
130
|
+
breadcrumbs: [
|
|
131
|
+
{ action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2. Try to find a sys_ws_definition by service_id, name, or sys_id
|
|
137
|
+
const defQuery = isSysId
|
|
138
|
+
? `sys_id=${id}`
|
|
139
|
+
: `service_id=${id}^ORname=${id}`;
|
|
140
|
+
const defParams = new URLSearchParams();
|
|
141
|
+
defParams.set('sysparm_query', defQuery);
|
|
142
|
+
defParams.set('sysparm_limit', '1');
|
|
143
|
+
defParams.set('sysparm_display_value', 'all');
|
|
144
|
+
const defRecords = await app.sdk.list('sys_ws_definition', defParams);
|
|
145
|
+
|
|
146
|
+
if (defRecords.length === 0) {
|
|
147
|
+
throw new Error(`No API or operation found matching: ${id}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const definition = defRecords[0];
|
|
151
|
+
const defName = getStringField(definition, 'name');
|
|
152
|
+
const defSysId = definition.sys_id?.value || definition.sys_id;
|
|
153
|
+
|
|
154
|
+
// 3. Fetch child operations for this definition
|
|
155
|
+
const childParams = new URLSearchParams();
|
|
156
|
+
childParams.set('sysparm_query', `web_service_definition=${defSysId}`);
|
|
157
|
+
childParams.set('sysparm_limit', '50');
|
|
158
|
+
childParams.set('sysparm_display_value', 'all');
|
|
159
|
+
childParams.set('sysparm_fields', 'sys_id,name,http_method,relative_path,active');
|
|
160
|
+
const childOps = await app.sdk.list('sys_ws_operation', childParams);
|
|
161
|
+
|
|
162
|
+
// Build a formatted display
|
|
163
|
+
const instanceURL = app.getEffectiveInstance();
|
|
164
|
+
const defLink = `${instanceURL}/sys_ws_definition.do?sys_id=${defSysId}`;
|
|
165
|
+
|
|
166
|
+
let formatted = `\n${defName} (Scripted REST API)\n`;
|
|
167
|
+
formatted += `${'─'.repeat(defName.length + 22)}\n\n`;
|
|
168
|
+
formatted += ` service_id: ${getStringField(definition, 'service_id')}\n`;
|
|
169
|
+
formatted += ` namespace: ${getStringField(definition, 'namespace')}\n`;
|
|
170
|
+
formatted += ` scope: ${getStringField(definition, 'sys_scope')}\n`;
|
|
171
|
+
formatted += ` base_uri: ${getStringField(definition, 'base_uri')}\n`;
|
|
172
|
+
formatted += ` description: ${(getStringField(definition, 'description') || '(none)').slice(0, 60)}\n`;
|
|
173
|
+
formatted += `\n Link: ${defLink}\n`;
|
|
174
|
+
|
|
175
|
+
if (childOps.length > 0) {
|
|
176
|
+
formatted += `\n ── Resources (${childOps.length}) ──\n\n`;
|
|
177
|
+
for (const op of childOps) {
|
|
178
|
+
const opName = getStringField(op, 'name');
|
|
179
|
+
const method = getStringField(op, 'http_method');
|
|
180
|
+
const relPath = getStringField(op, 'relative_path');
|
|
181
|
+
const active = getStringField(op, 'active');
|
|
182
|
+
formatted += ` ${(method + ' ').slice(0, 7)} ${relPath.padEnd(30)} ${opName}${active !== 'true' ? ' (inactive)' : ''}\n`;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
formatted += `\n (no resources — run jsn dev scrapi create --resource ... to add some)\n`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
app.ok({
|
|
189
|
+
_formatted: formatted,
|
|
190
|
+
definition: {
|
|
191
|
+
sys_id: defSysId,
|
|
192
|
+
name: defName,
|
|
193
|
+
service_id: getStringField(definition, 'service_id'),
|
|
194
|
+
namespace: getStringField(definition, 'namespace'),
|
|
195
|
+
base_uri: getStringField(definition, 'base_uri'),
|
|
196
|
+
scope: getStringField(definition, 'sys_scope'),
|
|
197
|
+
description: getStringField(definition, 'description'),
|
|
198
|
+
link: defLink,
|
|
199
|
+
},
|
|
200
|
+
resources: childOps.map(op => ({
|
|
201
|
+
name: getStringField(op, 'name'),
|
|
202
|
+
method: getStringField(op, 'http_method'),
|
|
203
|
+
relative_path: getStringField(op, 'relative_path'),
|
|
204
|
+
active: getStringField(op, 'active'),
|
|
205
|
+
})),
|
|
206
|
+
_context: { instance_url: instanceURL, table: 'sys_ws_definition' },
|
|
207
|
+
}, {
|
|
208
|
+
summary: `API: ${defName} — ${childOps.length} resource(s)`,
|
|
209
|
+
breadcrumbs: [
|
|
210
|
+
{ action: 'create', cmd: 'jsn dev scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
|
|
211
|
+
{ action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
// ── create (one-shot API + operations) ──
|
|
217
|
+
.command({
|
|
218
|
+
command: 'create',
|
|
219
|
+
describe: 'Create a new Scripted REST API with operations in one command',
|
|
220
|
+
builder: (y) => y
|
|
221
|
+
.option('name', { type: 'string', demandOption: true, describe: 'API name (e.g. "SL Managed Services")' })
|
|
222
|
+
.option('service-id', { type: 'string', demandOption: true, describe: 'Service ID (e.g. "managed_services")' })
|
|
223
|
+
.option('namespace', { type: 'string', demandOption: true, describe: 'API namespace (e.g. "slapi")' })
|
|
224
|
+
.option('resource', {
|
|
225
|
+
type: 'string',
|
|
226
|
+
array: true,
|
|
227
|
+
demandOption: true,
|
|
228
|
+
describe: 'Resource definition: name=METHOD:/path:./script.js (can be repeated)',
|
|
229
|
+
})
|
|
230
|
+
.option('description', { type: 'string', describe: 'API description' })
|
|
231
|
+
.option('scope', { type: 'string', describe: 'Scope name or sys_id (default: current scope)' }),
|
|
232
|
+
handler: wrap(async (argv, app) => {
|
|
233
|
+
if (!app.sdk) throw new Error('Not connected to a ServiceNow instance');
|
|
234
|
+
|
|
235
|
+
// 1. Parse and validate all resources first
|
|
236
|
+
const resources = [];
|
|
237
|
+
for (const raw of argv.resource) {
|
|
238
|
+
const parsed = parseResourceArg(raw);
|
|
239
|
+
if (!parsed) {
|
|
240
|
+
throw errUsageHint(
|
|
241
|
+
`Invalid --resource format: ${raw}`,
|
|
242
|
+
'Expected: name=METHOD:/path:./script.js e.g. create=POST:/create:./create.js'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const script = readScriptFile(parsed.scriptFile);
|
|
246
|
+
resources.push({ ...parsed, script });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const apiName = argv.name;
|
|
250
|
+
const serviceId = argv['service-id'];
|
|
251
|
+
const namespace = argv.namespace;
|
|
252
|
+
const description = argv.description || '';
|
|
253
|
+
|
|
254
|
+
// 2. Resolve scope if provided
|
|
255
|
+
let scopeSysId = null;
|
|
256
|
+
if (argv.scope) {
|
|
257
|
+
scopeSysId = await app.sdk.resolveScope(argv.scope);
|
|
258
|
+
if (!scopeSysId) {
|
|
259
|
+
throw new Error(`Scope not found: ${argv.scope}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 3. Create the API definition (sys_ws_definition)
|
|
264
|
+
const definitionData = {
|
|
265
|
+
name: apiName,
|
|
266
|
+
service_id: serviceId,
|
|
267
|
+
namespace,
|
|
268
|
+
description,
|
|
269
|
+
};
|
|
270
|
+
if (scopeSysId) {
|
|
271
|
+
definitionData.sys_scope = scopeSysId;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const definition = await app.sdk.create('sys_ws_definition', definitionData);
|
|
275
|
+
const defSysId = definition?.sys_id?.value || definition?.sys_id;
|
|
276
|
+
if (!defSysId) {
|
|
277
|
+
throw new Error('API definition creation returned no sys_id — check for validation errors');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 4. Create each operation (sys_ws_operation)
|
|
281
|
+
const operations = [];
|
|
282
|
+
for (const res of resources) {
|
|
283
|
+
const opData = {
|
|
284
|
+
name: res.name,
|
|
285
|
+
http_method: res.method,
|
|
286
|
+
relative_path: res.relativePath,
|
|
287
|
+
operation_script: res.script,
|
|
288
|
+
web_service_definition: defSysId,
|
|
289
|
+
};
|
|
290
|
+
// Set the scope so the operation inherits the API's scope
|
|
291
|
+
if (scopeSysId) {
|
|
292
|
+
opData.sys_scope = scopeSysId;
|
|
293
|
+
}
|
|
294
|
+
const operation = await app.sdk.create('sys_ws_operation', opData);
|
|
295
|
+
operations.push(operation?.result || operation);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 5. Report
|
|
299
|
+
const instanceURL = app.getEffectiveInstance();
|
|
300
|
+
const apiURL = `${instanceURL}/sys_ws_definition.do?sys_id=${defSysId}`;
|
|
301
|
+
|
|
302
|
+
app.ok({
|
|
303
|
+
api_definition: {
|
|
304
|
+
sys_id: defSysId,
|
|
305
|
+
name: apiName,
|
|
306
|
+
service_id: serviceId,
|
|
307
|
+
namespace,
|
|
308
|
+
link: apiURL,
|
|
309
|
+
},
|
|
310
|
+
operations: operations.map((op, i) => ({
|
|
311
|
+
name: resources[i].name,
|
|
312
|
+
method: resources[i].method,
|
|
313
|
+
relative_path: resources[i].relativePath,
|
|
314
|
+
})),
|
|
315
|
+
}, {
|
|
316
|
+
summary: `Created API "${apiName}" with ${operations.length} operation(s)`,
|
|
317
|
+
notice: `View API: ${apiURL}`,
|
|
318
|
+
breadcrumbs: [
|
|
319
|
+
{ action: 'show', cmd: `jsn dev scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
|
|
320
|
+
{ action: 'add', cmd: `jsn dev scrapi add-resource --api "${serviceId}" --name <name>=METHOD:/path:./script.js`, description: 'Add another resource' },
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
// Default handler when no subcommand matches
|
|
327
|
+
handler: () => {
|
|
328
|
+
console.log('Manage Scripted REST APIs.');
|
|
329
|
+
console.log('');
|
|
330
|
+
console.log('Available subcommands:');
|
|
331
|
+
console.log(' list List API operations');
|
|
332
|
+
console.log(' show Show an operation by name or sys_id');
|
|
333
|
+
console.log(' create Create a new API with operations (one-shot)');
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log('Examples:');
|
|
336
|
+
console.log(' jsn dev scrapi list');
|
|
337
|
+
console.log(' jsn dev scrapi show my_operation');
|
|
338
|
+
console.log(' jsn dev scrapi create \\');
|
|
339
|
+
console.log(' --name "My API" --service-id my-api --namespace myapp \\');
|
|
340
|
+
console.log(' --resource "create=POST:/create:./create.js" \\');
|
|
341
|
+
console.log(' --resource "list=GET:/list:./list.js"');
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
package/src/commands/dev.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
actionsCmd, includesCmd, rulesCmd,
|
|
5
5
|
clientScriptsCmd, uiActionsCmd, uiPoliciesCmd,
|
|
6
6
|
tablesCmd, columnsCmd, importCmd,
|
|
7
|
-
spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
|
|
7
|
+
spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
|
|
8
8
|
aclsCmd, rolesCmd, propertiesCmd,
|
|
9
9
|
relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
|
|
10
10
|
privilegesCmd, securitytypesCmd, uxscriptsCmd, aliasesCmd,
|
|
@@ -18,6 +18,7 @@ import { scopesCmd } from './dev/scopes.js';
|
|
|
18
18
|
import { evalCmd } from './dev/eval.js';
|
|
19
19
|
import { restCmd } from './dev/rest.js';
|
|
20
20
|
import { logsCmd } from './dev/logs.js';
|
|
21
|
+
import { scrapiCmd } from './dev/scrapi.js';
|
|
21
22
|
|
|
22
23
|
export function devCmd(wrap) {
|
|
23
24
|
return {
|
|
@@ -41,7 +42,7 @@ export function devCmd(wrap) {
|
|
|
41
42
|
.command(spWidgetsCmd(wrap))
|
|
42
43
|
.command(uiPagesCmd(wrap))
|
|
43
44
|
.command(appMenuCmd(wrap))
|
|
44
|
-
.command(
|
|
45
|
+
.command(scrapiCmd(wrap))
|
|
45
46
|
.command(aclsCmd(wrap))
|
|
46
47
|
.command(rolesCmd(wrap))
|
|
47
48
|
.command(updateSetsCmd(wrap))
|
package/src/commands/records.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
const tableDefaultColumns = {
|
|
4
4
|
incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
|
|
@@ -133,9 +133,14 @@ export function recordsCmd(wrap) {
|
|
|
133
133
|
builder: (y) => y
|
|
134
134
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
135
135
|
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
136
|
-
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
136
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
137
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
|
|
137
138
|
handler: wrap(async (argv, app) => {
|
|
138
139
|
const recordData = parseDataArg(argv);
|
|
140
|
+
const warnings = checkDerivedFields(argv.table, recordData);
|
|
141
|
+
for (const w of warnings) {
|
|
142
|
+
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
143
|
+
}
|
|
139
144
|
const record = await app.sdk.create(argv.table, recordData);
|
|
140
145
|
app.ok(record, { summary: `Created record in ${argv.table}` });
|
|
141
146
|
}),
|
|
@@ -147,9 +152,14 @@ export function recordsCmd(wrap) {
|
|
|
147
152
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
148
153
|
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
149
154
|
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
150
|
-
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
155
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
156
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
|
|
151
157
|
handler: wrap(async (argv, app) => {
|
|
152
158
|
const recordData = parseDataArg(argv);
|
|
159
|
+
const warnings = checkDerivedFields(argv.table, recordData);
|
|
160
|
+
for (const w of warnings) {
|
|
161
|
+
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
162
|
+
}
|
|
153
163
|
const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
|
|
154
164
|
app.ok(record, { summary: `Updated record in ${argv.table}` });
|
|
155
165
|
}),
|
package/src/commands/skill.js
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import process from 'node:process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
7
|
+
const SKILL_NAME = 'servicenow';
|
|
6
8
|
const SKILL_REPO_PATH = path.join(
|
|
7
9
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
8
|
-
'..', '..', 'skills',
|
|
10
|
+
'..', '..', 'skills', SKILL_NAME, 'SKILL.md'
|
|
9
11
|
);
|
|
10
12
|
|
|
13
|
+
const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
|
|
14
|
+
|
|
11
15
|
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
|
|
12
16
|
|
|
17
|
+
// Known agent skill directories (personal/user-level)
|
|
18
|
+
// Keyed by agent name for the --target flag
|
|
19
|
+
const AGENT_SKILL_DIRS = {
|
|
20
|
+
hermes: path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME),
|
|
21
|
+
copilot: path.join(os.homedir(), '.copilot', 'skills', SKILL_NAME),
|
|
22
|
+
claude: path.join(os.homedir(), '.claude', 'skills', SKILL_NAME),
|
|
23
|
+
cursor: path.join(os.homedir(), '.cursor', 'rules'),
|
|
24
|
+
agents: path.join(os.homedir(), '.agents', 'skills', SKILL_NAME),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TARGET_NAMES = {
|
|
28
|
+
hermes: 'Hermes Agent',
|
|
29
|
+
copilot: 'GitHub Copilot',
|
|
30
|
+
claude: 'Claude Code',
|
|
31
|
+
cursor: 'Cursor',
|
|
32
|
+
agents: 'Agents (open standard)',
|
|
33
|
+
};
|
|
34
|
+
|
|
13
35
|
function readBundledSkill() {
|
|
14
36
|
try {
|
|
15
37
|
return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
|
|
@@ -18,9 +40,23 @@ function readBundledSkill() {
|
|
|
18
40
|
}
|
|
19
41
|
}
|
|
20
42
|
|
|
43
|
+
function readInstalledSkill() {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readFileSync(HERMES_SKILL_PATH, 'utf-8');
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
export async function checkSkill() {
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
// Compare the Hermes-installed copy's version against GitHub (not the npm-bundled copy).
|
|
53
|
+
// Uses the YAML frontmatter "version" field so publishing jsn doesn't flag it.
|
|
54
|
+
const installed = readInstalledSkill() || readBundledSkill();
|
|
55
|
+
if (!installed) return { current: false, error: 'Skill file not found' };
|
|
56
|
+
|
|
57
|
+
const installedVersion = extractVersion(installed);
|
|
58
|
+
if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
|
|
59
|
+
|
|
24
60
|
try {
|
|
25
61
|
const controller = new AbortController();
|
|
26
62
|
const timer = setTimeout(() => controller.abort(), 10000);
|
|
@@ -28,18 +64,27 @@ export async function checkSkill() {
|
|
|
28
64
|
clearTimeout(timer);
|
|
29
65
|
if (!res.ok) return { current: false, error: `GitHub returned ${res.status}` };
|
|
30
66
|
const upstream = await res.text();
|
|
31
|
-
const
|
|
67
|
+
const upstreamVersion = extractVersion(upstream);
|
|
68
|
+
if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
|
|
69
|
+
|
|
70
|
+
const current = installedVersion === upstreamVersion;
|
|
32
71
|
return {
|
|
33
72
|
current,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
error: current ? null :
|
|
73
|
+
installed_version: installedVersion,
|
|
74
|
+
upstream_version: upstreamVersion,
|
|
75
|
+
error: current ? null : `Skill version ${installedVersion} vs GitHub ${upstreamVersion} — run "jsn skill install" to update`,
|
|
37
76
|
};
|
|
38
77
|
} catch {
|
|
39
78
|
return { current: false, error: 'Could not check — GitHub unreachable' };
|
|
40
79
|
}
|
|
41
80
|
}
|
|
42
81
|
|
|
82
|
+
function extractVersion(content) {
|
|
83
|
+
// Matches version in YAML frontmatter (top-level or nested under metadata:)
|
|
84
|
+
const m = content.match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
|
|
85
|
+
return m ? m[1] : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
export function skillCmd(wrap) {
|
|
44
89
|
return {
|
|
45
90
|
command: 'skill',
|
|
@@ -70,9 +115,9 @@ export function skillCmd(wrap) {
|
|
|
70
115
|
if (result.error && !result.current) {
|
|
71
116
|
app.ok(result, { summary: result.error });
|
|
72
117
|
} else if (result.current) {
|
|
73
|
-
app.ok(result, { summary: `✓ Skill is current (${result.
|
|
118
|
+
app.ok(result, { summary: `✓ Skill is current (v${result.installed_version})` });
|
|
74
119
|
} else {
|
|
75
|
-
app.ok(result, { summary: `⚠ Skill
|
|
120
|
+
app.ok(result, { summary: `⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version} — run "jsn skill install" to update` });
|
|
76
121
|
}
|
|
77
122
|
}),
|
|
78
123
|
})
|
|
@@ -90,18 +135,23 @@ export function skillCmd(wrap) {
|
|
|
90
135
|
command: 'path',
|
|
91
136
|
describe: 'Show skill file locations and install targets',
|
|
92
137
|
handler: wrap(async (_argv, app) => {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
138
|
+
const allPaths = {};
|
|
139
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
140
|
+
const file = key === 'cursor' ? path.join(dir, `${SKILL_NAME}.mdc`) : path.join(dir, 'SKILL.md');
|
|
141
|
+
allPaths[TARGET_NAMES[key]] = file;
|
|
142
|
+
}
|
|
143
|
+
|
|
95
144
|
app.ok({
|
|
96
145
|
bundled: SKILL_REPO_PATH,
|
|
97
|
-
|
|
146
|
+
targets: allPaths,
|
|
98
147
|
raw_url: SKILL_RAW_URL,
|
|
99
148
|
}, {
|
|
100
|
-
summary: 'Skill file locations',
|
|
149
|
+
summary: 'Skill file locations and install targets',
|
|
101
150
|
breadcrumbs: [
|
|
102
|
-
{ action: '
|
|
103
|
-
{ action: '
|
|
104
|
-
{ action: 'install', cmd: 'jsn skill install', description: '
|
|
151
|
+
{ action: 'install', cmd: 'jsn skill install', description: 'Install to Hermes (default)' },
|
|
152
|
+
{ action: 'install-all', cmd: 'jsn skill install --target all', description: 'Install to all supported agents' },
|
|
153
|
+
{ action: 'install-copilot', cmd: 'jsn skill install --target copilot', description: 'Install for GitHub Copilot' },
|
|
154
|
+
{ action: 'install-claude', cmd: 'jsn skill install --target claude', description: 'Install for Claude Code' },
|
|
105
155
|
],
|
|
106
156
|
});
|
|
107
157
|
}),
|
|
@@ -112,30 +162,71 @@ export function skillCmd(wrap) {
|
|
|
112
162
|
builder: (y) => y
|
|
113
163
|
.positional('dir', {
|
|
114
164
|
type: 'string',
|
|
115
|
-
describe: 'Target directory (
|
|
165
|
+
describe: 'Target directory (overrides --target; installs only to this dir)',
|
|
166
|
+
})
|
|
167
|
+
.option('target', {
|
|
168
|
+
type: 'string',
|
|
169
|
+
describe: 'Target agent(s): hermes, copilot, claude, cursor, agents, or "all" (comma-separated)',
|
|
170
|
+
default: 'hermes',
|
|
116
171
|
}),
|
|
117
172
|
handler: wrap(async (argv, app) => {
|
|
118
|
-
const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
|
|
119
|
-
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
120
|
-
|
|
121
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
122
|
-
|
|
123
173
|
const res = await fetch(SKILL_RAW_URL);
|
|
124
174
|
if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
|
|
125
175
|
const content = await res.text();
|
|
126
176
|
|
|
127
|
-
|
|
177
|
+
// Resolve target directories
|
|
178
|
+
let targets = [];
|
|
128
179
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
if (argv.dir) {
|
|
181
|
+
// Explicit --dir overrides targets — single install
|
|
182
|
+
const p = path.resolve(argv.dir);
|
|
183
|
+
fs.mkdirSync(p, { recursive: true });
|
|
184
|
+
const targetPath = path.join(p, 'SKILL.md');
|
|
185
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
186
|
+
targets.push({ name: path.basename(argv.dir), path: targetPath });
|
|
187
|
+
} else {
|
|
188
|
+
const rawTargets = argv.target.split(',').map(t => t.trim().toLowerCase());
|
|
189
|
+
const all = rawTargets.includes('all');
|
|
190
|
+
|
|
191
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
192
|
+
if (all || rawTargets.includes(key)) {
|
|
193
|
+
// For cursor, the skill goes in a .mdc file, not a subfolder
|
|
194
|
+
if (key === 'cursor') {
|
|
195
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
197
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
198
|
+
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
199
|
+
} else {
|
|
200
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
const targetPath = path.join(dir, 'SKILL.md');
|
|
202
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
203
|
+
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (targets.length === 0) {
|
|
210
|
+
throw new Error(`No targets matched. Valid targets: ${Object.keys(AGENT_SKILL_DIRS).join(', ')}, or "all"`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
|
|
214
|
+
const summary = targets.length === 1
|
|
215
|
+
? `Skill installed to ${targets[0].path}`
|
|
216
|
+
: `Skill installed to ${targets.length} target(s)`;
|
|
217
|
+
|
|
218
|
+
const okOpts = {
|
|
219
|
+
summary,
|
|
134
220
|
breadcrumbs: [
|
|
135
|
-
{ action: '
|
|
136
|
-
{ action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall' },
|
|
221
|
+
{ action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall to default target' },
|
|
137
222
|
],
|
|
138
|
-
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (targets.length > 1) {
|
|
226
|
+
okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
app.ok({ installed, from: SKILL_RAW_URL }, okOpts);
|
|
139
230
|
}),
|
|
140
231
|
});
|
|
141
232
|
},
|
package/src/errors.js
CHANGED
|
@@ -79,7 +79,34 @@ export function errAPI(status, msg) {
|
|
|
79
79
|
if (status >= 500) {
|
|
80
80
|
hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
// Try to extract structured error detail from JSON API error bodies
|
|
84
|
+
let displayMsg = msg;
|
|
85
|
+
if (typeof msg === 'string' && (msg.startsWith('{') || msg.startsWith('['))) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(msg);
|
|
88
|
+
const errObj = parsed.error || parsed;
|
|
89
|
+
const detail = errObj.detail || errObj.message || '';
|
|
90
|
+
const message = errObj.message || '';
|
|
91
|
+
|
|
92
|
+
if (detail && message) {
|
|
93
|
+
displayMsg = `${message}: ${detail}`;
|
|
94
|
+
} else if (detail) {
|
|
95
|
+
displayMsg = detail;
|
|
96
|
+
} else if (message) {
|
|
97
|
+
displayMsg = message;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Detect business rule abortion and add a hint
|
|
101
|
+
if (detail && detail.includes('Business Rule')) {
|
|
102
|
+
hint = 'A Business Rule rejected this operation. Check for overlapping routes, validation rules, or ACLs on this table.';
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Not valid JSON, use original message
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new AppError(CodeAPI, `API error (status ${status}): ${displayMsg}`, hint, status);
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
export function errAmbiguous(resource, matches) {
|
package/src/helpers.js
CHANGED
|
@@ -135,25 +135,80 @@ function vowelArticle(word) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* Known derived (read-only) ServiceNow fields that should not be set directly.
|
|
139
|
+
* Maps table name → array of field names that are computed by the platform.
|
|
140
|
+
* When a create/update payload contains these, a warning is emitted.
|
|
141
|
+
*/
|
|
142
|
+
export const DERIVED_FIELDS = {
|
|
143
|
+
sys_ws_operation: ['operation_uri'],
|
|
144
|
+
sys_ws_definition: ['base_uri'],
|
|
145
|
+
incident: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
|
|
146
|
+
change_request: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
|
|
147
|
+
// Generic — all sys_ fields are system-managed
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check a data payload for fields that appear to be derived/read-only.
|
|
152
|
+
* Returns an array of warning objects ({field, hint}) for any matches.
|
|
153
|
+
* @param {string} table - Table name (e.g. 'sys_ws_operation')
|
|
154
|
+
* @param {object} data - The JSON payload being sent
|
|
155
|
+
* @returns {Array<{field: string, hint: string}>}
|
|
156
|
+
*/
|
|
157
|
+
export function checkDerivedFields(table, data) {
|
|
158
|
+
if (!data || typeof data !== 'object') return [];
|
|
159
|
+
const warnings = [];
|
|
160
|
+
|
|
161
|
+
// Check explicitly known derived fields for this table
|
|
162
|
+
const knownFields = DERIVED_FIELDS[table] || [];
|
|
163
|
+
for (const field of knownFields) {
|
|
164
|
+
if (field in data && data[field] != null) {
|
|
165
|
+
warnings.push({
|
|
166
|
+
field,
|
|
167
|
+
hint: `${field} is a derived/read-only field. Setting it directly will be ignored.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Warn about any sys_* fields that look like system-managed metadata
|
|
173
|
+
// (but be careful — not all sys_ fields are read-only)
|
|
174
|
+
if (data.sys_created_on) {
|
|
175
|
+
warnings.push({
|
|
176
|
+
field: 'sys_created_on',
|
|
177
|
+
hint: 'sys_created_on is a system-managed field. Setting it directly will be ignored.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (data.sys_updated_on) {
|
|
181
|
+
warnings.push({
|
|
182
|
+
field: 'sys_updated_on',
|
|
183
|
+
hint: 'sys_updated_on is a system-managed field. Setting it directly will be ignored.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return warnings;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse --data, --data-file, or --data-stdin into a JSON object.
|
|
192
|
+
* Priority: --data-file > --data-stdin > --data
|
|
193
|
+
* Throws if none is provided or JSON is invalid.
|
|
141
194
|
*/
|
|
142
195
|
export function parseDataArg(argv) {
|
|
143
196
|
let raw;
|
|
144
197
|
if (argv['data-file']) {
|
|
145
198
|
raw = fs.readFileSync(argv['data-file'], 'utf-8');
|
|
146
|
-
// Strip UTF-8 BOM (
|
|
199
|
+
// Strip UTF-8 BOM (\\ufeff) which some editors (Windows/PowerShell) add
|
|
147
200
|
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
201
|
+
} else if (argv['data-stdin']) {
|
|
202
|
+
raw = fs.readFileSync(process.stdin.fd, 'utf-8');
|
|
148
203
|
} else if (argv.data) {
|
|
149
204
|
raw = argv.data;
|
|
150
205
|
} else {
|
|
151
|
-
throw new Error('--data or --data-
|
|
206
|
+
throw new Error('--data, --data-file, or --data-stdin is required');
|
|
152
207
|
}
|
|
153
208
|
try {
|
|
154
209
|
return JSON.parse(raw);
|
|
155
210
|
} catch (e) {
|
|
156
|
-
throw new Error(`Invalid JSON: ${e.message}\n\nHint:
|
|
211
|
+
throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
|
|
157
212
|
}
|
|
158
213
|
}
|
|
159
214
|
|
package/src/sdk.js
CHANGED
|
@@ -188,6 +188,25 @@ export class SDKClient {
|
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Resolve a scope name or sys_id to a sys_id.
|
|
193
|
+
* @param {string} scope - Scope name (e.g. "sn_notif_dashboard") or sys_id
|
|
194
|
+
* @returns {Promise<string|null>} The scope sys_id, or null if not found
|
|
195
|
+
*/
|
|
196
|
+
async resolveScope(scope) {
|
|
197
|
+
if (!scope) return null;
|
|
198
|
+
// If it looks like a sys_id (32 hex chars), try it directly
|
|
199
|
+
if (/^[0-9a-fA-F]{32}$/.test(scope)) return scope;
|
|
200
|
+
|
|
201
|
+
const params = new URLSearchParams();
|
|
202
|
+
params.set('sysparm_query', `scope=${scope}`);
|
|
203
|
+
params.set('sysparm_limit', '1');
|
|
204
|
+
params.set('sysparm_fields', 'sys_id,name');
|
|
205
|
+
const records = await this.list('sys_scope', params);
|
|
206
|
+
if (records.length === 0) return null;
|
|
207
|
+
return records[0].sys_id?.value || records[0].sys_id;
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
async inspectFlow(identifier) {
|
|
192
211
|
const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
|
|
193
212
|
|
|
@@ -372,7 +391,7 @@ export class SDKClient {
|
|
|
372
391
|
* @param {string} script - JavaScript code to execute
|
|
373
392
|
* @returns {Promise<string>} The script's output text
|
|
374
393
|
*/
|
|
375
|
-
async executeScript(script) {
|
|
394
|
+
async executeScript(script, scope) {
|
|
376
395
|
// Step 1: Warm up the session by hitting any REST API — this makes
|
|
377
396
|
// ServiceNow issue session cookies for subsequent UI page requests.
|
|
378
397
|
// We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
|
|
@@ -387,7 +406,7 @@ export class SDKClient {
|
|
|
387
406
|
formBody.set('script', script);
|
|
388
407
|
formBody.set('sysparm_ck', csrfToken);
|
|
389
408
|
formBody.set('runscript', 'Run script');
|
|
390
|
-
formBody.set('sys_scope', 'global');
|
|
409
|
+
formBody.set('sys_scope', scope || 'global');
|
|
391
410
|
formBody.set('record_for_rollback', 'on');
|
|
392
411
|
formBody.set('quota_managed_transaction', 'on');
|
|
393
412
|
|