@jacebenson/jsn 3.0.5 → 3.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 +60 -285
- package/package.json +3 -1
- package/scripts/postinstall.mjs +80 -0
- package/scripts/pre-commit-check.sh +61 -0
- package/scripts/release.js +62 -0
- package/scripts/tag-reminder.js +30 -0
- package/skills/servicenow/SKILL.md +41 -4
- package/src/app.js +5 -5
- package/src/cli.js +64 -1
- package/src/commands/_ticket.js +1 -1
- package/src/commands/dev/_generic.js +13 -13
- package/src/commands/dev/_simple.js +0 -1
- package/src/commands/dev/eval.js +1 -1
- package/src/commands/dev/flows.js +31 -8
- package/src/commands/dev/forms.js +16 -6
- package/src/commands/dev/lists.js +16 -6
- package/src/commands/dev/logs.js +11 -1
- package/src/commands/dev/scopes.js +4 -3
- package/src/commands/dev/scrapi.js +344 -0
- package/src/commands/dev/updatesets.js +6 -5
- package/src/commands/dev.js +3 -2
- package/src/commands/records.js +16 -6
- package/src/commands/skill.js +90 -24
- package/src/errors.js +28 -1
- package/src/help.js +52 -9
- package/src/helpers.js +95 -37
- package/src/paginated-search.js +178 -0
|
@@ -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 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: 50, 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 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 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 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 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 scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
|
|
211
|
+
{ action: 'list', cmd: 'jsn 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 scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
|
|
320
|
+
{ action: 'add', cmd: `jsn 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 scrapi list');
|
|
337
|
+
console.log(' jsn scrapi show my_operation');
|
|
338
|
+
console.log(' jsn 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
|
+
}
|
|
@@ -15,7 +15,7 @@ export function updateSetsCmd(wrap) {
|
|
|
15
15
|
builder: (y) => y
|
|
16
16
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
17
17
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
18
|
-
.option('limit', { alias: 'l', type: 'number', default:
|
|
18
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
19
19
|
handler: wrap(async (argv, app) => {
|
|
20
20
|
const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
|
|
21
21
|
const query = argv.query || '';
|
|
@@ -24,6 +24,7 @@ export function updateSetsCmd(wrap) {
|
|
|
24
24
|
app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
|
|
25
25
|
formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
|
|
26
26
|
});
|
|
27
|
+
if (picked === undefined) return; // user cancelled
|
|
27
28
|
if (picked) {
|
|
28
29
|
picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
|
|
29
30
|
return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
|
|
@@ -151,12 +152,12 @@ export function updateSetsCmd(wrap) {
|
|
|
151
152
|
breadcrumbs: [
|
|
152
153
|
{
|
|
153
154
|
action: 'set',
|
|
154
|
-
cmd: `jsn
|
|
155
|
+
cmd: `jsn updatesets set "${argv.name}"`,
|
|
155
156
|
description: 'Switch to this update set',
|
|
156
157
|
},
|
|
157
158
|
{
|
|
158
159
|
action: 'complete',
|
|
159
|
-
cmd: `jsn
|
|
160
|
+
cmd: `jsn updatesets complete "${argv.name}"`,
|
|
160
161
|
description: 'Mark as complete when done',
|
|
161
162
|
},
|
|
162
163
|
],
|
|
@@ -225,9 +226,9 @@ export function updateSetsCmd(wrap) {
|
|
|
225
226
|
console.log(' export <name> Export an update set to XML');
|
|
226
227
|
console.log(' complete <name> Mark an update set as complete (coming soon)');
|
|
227
228
|
console.log(' yolo Silence the "Default update set" warning');
|
|
228
|
-
console.log('\nRun "jsn
|
|
229
|
+
console.log('\nRun "jsn updatesets <command> --help" for details.');
|
|
229
230
|
console.log('\nTip: Create an update set first:');
|
|
230
|
-
console.log(' jsn
|
|
231
|
+
console.log(' jsn updatesets create --name "My Feature"');
|
|
231
232
|
console.log(' # → Auto-set as current, then record changes are captured.');
|
|
232
233
|
}
|
|
233
234
|
},
|
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'],
|
|
@@ -74,7 +74,7 @@ export function recordsCmd(wrap) {
|
|
|
74
74
|
const breadcrumbs = [
|
|
75
75
|
{ action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
|
|
76
76
|
{ action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
|
|
77
|
-
{ action: 'columns', cmd: `jsn
|
|
77
|
+
{ action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
|
|
78
78
|
];
|
|
79
79
|
if (argv['sys-id']) {
|
|
80
80
|
breadcrumbs.unshift({
|
|
@@ -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
|
}),
|
|
@@ -178,12 +188,12 @@ export function recordsCmd(wrap) {
|
|
|
178
188
|
const breadcrumbs = [
|
|
179
189
|
...result.flows.map(f => ({
|
|
180
190
|
action: 'show',
|
|
181
|
-
cmd: `jsn
|
|
191
|
+
cmd: `jsn flows show "${f.flow}"`,
|
|
182
192
|
description: `View flow details for ${f.flow}`,
|
|
183
193
|
})),
|
|
184
194
|
...result.businessRules.slice(0, 5).map(br => ({
|
|
185
195
|
action: 'show',
|
|
186
|
-
cmd: `jsn
|
|
196
|
+
cmd: `jsn rules show "${br.name}"`,
|
|
187
197
|
description: `View business rule: ${br.name}`,
|
|
188
198
|
})),
|
|
189
199
|
];
|
package/src/commands/skill.js
CHANGED
|
@@ -1,17 +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
|
|
|
11
|
-
const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills',
|
|
13
|
+
const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
|
|
12
14
|
|
|
13
15
|
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
|
|
14
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
|
+
|
|
15
35
|
function readBundledSkill() {
|
|
16
36
|
try {
|
|
17
37
|
return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
|
|
@@ -115,18 +135,23 @@ export function skillCmd(wrap) {
|
|
|
115
135
|
command: 'path',
|
|
116
136
|
describe: 'Show skill file locations and install targets',
|
|
117
137
|
handler: wrap(async (_argv, app) => {
|
|
118
|
-
const
|
|
119
|
-
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
|
+
|
|
120
144
|
app.ok({
|
|
121
145
|
bundled: SKILL_REPO_PATH,
|
|
122
|
-
|
|
146
|
+
targets: allPaths,
|
|
123
147
|
raw_url: SKILL_RAW_URL,
|
|
124
148
|
}, {
|
|
125
|
-
summary: 'Skill file locations',
|
|
149
|
+
summary: 'Skill file locations and install targets',
|
|
126
150
|
breadcrumbs: [
|
|
127
|
-
{ action: '
|
|
128
|
-
{ action: '
|
|
129
|
-
{ 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' },
|
|
130
155
|
],
|
|
131
156
|
});
|
|
132
157
|
}),
|
|
@@ -137,30 +162,71 @@ export function skillCmd(wrap) {
|
|
|
137
162
|
builder: (y) => y
|
|
138
163
|
.positional('dir', {
|
|
139
164
|
type: 'string',
|
|
140
|
-
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',
|
|
141
171
|
}),
|
|
142
172
|
handler: wrap(async (argv, app) => {
|
|
143
|
-
const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
|
|
144
|
-
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
145
|
-
|
|
146
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
147
|
-
|
|
148
173
|
const res = await fetch(SKILL_RAW_URL);
|
|
149
174
|
if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
|
|
150
175
|
const content = await res.text();
|
|
151
176
|
|
|
152
|
-
|
|
177
|
+
// Resolve target directories
|
|
178
|
+
let targets = [];
|
|
153
179
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|
|
159
220
|
breadcrumbs: [
|
|
160
|
-
{ action: '
|
|
161
|
-
{ 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' },
|
|
162
222
|
],
|
|
163
|
-
}
|
|
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);
|
|
164
230
|
}),
|
|
165
231
|
});
|
|
166
232
|
},
|