@jacebenson/jsn 4.1.0 → 4.2.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 +18 -2
- package/package.json +4 -1
- package/src/app.js +48 -64
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -79
- package/src/commands/_ticket.js +36 -92
- package/src/commands/approvals.js +350 -0
- package/src/commands/atf.js +635 -0
- package/src/commands/auth.js +93 -13
- package/src/commands/catalog/enrich.js +139 -0
- package/src/commands/catalog.js +15 -133
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +62 -110
- package/src/commands/dev/_simple.js +16 -12
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/domains.js +4 -0
- package/src/commands/dev/eval.js +40 -4
- package/src/commands/dev/flows.js +3 -0
- package/src/commands/dev/logs.js +97 -0
- package/src/commands/dev/rest.js +6 -0
- package/src/commands/dev/restmessage.js +1 -0
- package/src/commands/dev/scopes.js +49 -25
- package/src/commands/dev/scrapi.js +5 -0
- package/src/commands/dev/soapmessages.js +1 -0
- package/src/commands/dev/updatesets.js +145 -15
- package/src/commands/dev.js +3 -2
- package/src/commands/docs/db.js +14 -0
- package/src/commands/docs/docs.js +9 -21
- package/src/commands/docs/search.js +33 -10
- package/src/commands/groups.js +3 -0
- package/src/commands/records.js +254 -9
- package/src/commands/setup.js +5 -0
- package/src/commands/skill.js +3 -0
- package/src/commands/snippets.js +155 -0
- package/src/commands/tickets.js +9 -29
- package/src/commands/users.js +9 -35
- package/src/commands/version.js +3 -0
- package/src/errors.js +92 -1
- package/src/help.js +1 -0
- package/src/helpers.js +22 -11
- package/src/mutations.js +36 -91
- package/src/output.js +187 -61
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
package/src/commands/records.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
|
|
4
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
5
|
+
|
|
6
|
+
declareCapabilities('records', {
|
|
7
|
+
mutationSubcommands: ['create', 'update', 'delete', 'bulk', 'attachments add'],
|
|
8
|
+
});
|
|
2
9
|
|
|
3
10
|
const tableDefaultColumns = {
|
|
4
11
|
incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
|
|
@@ -12,9 +19,50 @@ const tableDefaultColumns = {
|
|
|
12
19
|
sys_user_group: ['name', 'manager', 'email'],
|
|
13
20
|
cmdb_ci: ['name', 'operational_status', 'ip_address'],
|
|
14
21
|
cmdb_ci_server: ['name', 'operational_status', 'ip_address'],
|
|
22
|
+
cmdb_rel_ci: ['parent', 'type', 'child'],
|
|
15
23
|
kb_knowledge: ['number', 'short_description', 'workflow_state', 'author'],
|
|
16
24
|
};
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Raw sys_id from a reference field (string or {display_value, link, value}).
|
|
28
|
+
*/
|
|
29
|
+
function refSysId(field) {
|
|
30
|
+
if (!field) return '';
|
|
31
|
+
if (typeof field === 'string') return field;
|
|
32
|
+
if (typeof field === 'object') {
|
|
33
|
+
if (field.value && typeof field.value === 'string') return field.value;
|
|
34
|
+
if (field.link && typeof field.link === 'string') {
|
|
35
|
+
const parts = field.link.split('/');
|
|
36
|
+
return parts[parts.length - 1];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Breadcrumbs from a cmdb_rel_ci record into the cmdb relationships walk.
|
|
44
|
+
*/
|
|
45
|
+
export function relationshipBreadcrumbs(record) {
|
|
46
|
+
const parentId = refSysId(record.parent);
|
|
47
|
+
const childId = refSysId(record.child);
|
|
48
|
+
const crumbs = [];
|
|
49
|
+
if (childId) {
|
|
50
|
+
crumbs.push({
|
|
51
|
+
action: 'relationships',
|
|
52
|
+
cmd: `jsn cmdb relationships --ci ${childId}`,
|
|
53
|
+
description: "Traverse from this relationship's child CI",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (parentId) {
|
|
57
|
+
crumbs.push({
|
|
58
|
+
action: 'impact',
|
|
59
|
+
cmd: `jsn cmdb relationships --ci ${parentId} --impact`,
|
|
60
|
+
description: 'Impact analysis upstream from the parent CI',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return crumbs;
|
|
64
|
+
}
|
|
65
|
+
|
|
18
66
|
function getDefaultColumns(table) {
|
|
19
67
|
return tableDefaultColumns[table] || ['sys_id'];
|
|
20
68
|
}
|
|
@@ -34,7 +82,8 @@ export function recordsCmd(wrap) {
|
|
|
34
82
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
35
83
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
36
84
|
.option('limit', { type: 'number', default: 20, describe: 'Max records' })
|
|
37
|
-
.option('offset', { type: 'number', default: 0, describe: 'Offset' })
|
|
85
|
+
.option('offset', { type: 'number', default: 0, describe: 'Offset' })
|
|
86
|
+
.option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' }),
|
|
38
87
|
handler: wrap(async (argv, app) => {
|
|
39
88
|
const table = argv.table;
|
|
40
89
|
const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
|
|
@@ -51,13 +100,24 @@ export function recordsCmd(wrap) {
|
|
|
51
100
|
const picked = await interactiveList({
|
|
52
101
|
app, table, singular: 'record', columns, limit: argv.limit, query, labelField: 'sys_id',
|
|
53
102
|
formatLabel: r => {
|
|
103
|
+
// Relationship rows read best as "parent <type> child [sys_id]"
|
|
104
|
+
if (table === 'cmdb_rel_ci') {
|
|
105
|
+
const parent = getStringField(r, 'parent');
|
|
106
|
+
const type = getStringField(r, 'type') || 'Related to';
|
|
107
|
+
const child = getStringField(r, 'child');
|
|
108
|
+
return `${parent || '?'} ${type} ${child || '?'} [${getStringField(r, 'sys_id')}]`;
|
|
109
|
+
}
|
|
54
110
|
const cols = getDefaultColumns(table);
|
|
55
111
|
return cols.map(c => `${c}: ${getStringField(r, c) || '-'}`).join(' | ');
|
|
56
112
|
},
|
|
57
113
|
});
|
|
58
114
|
if (picked) {
|
|
59
115
|
picked._context = { instance_url: app.getEffectiveInstance(), table };
|
|
60
|
-
|
|
116
|
+
const crumbs = table === 'cmdb_rel_ci' ? relationshipBreadcrumbs(picked) : [];
|
|
117
|
+
return app.ok(picked, {
|
|
118
|
+
summary: `Record from ${table}`,
|
|
119
|
+
...(crumbs.length ? { breadcrumbs: crumbs } : {}),
|
|
120
|
+
});
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
|
|
@@ -71,6 +131,16 @@ export function recordsCmd(wrap) {
|
|
|
71
131
|
if (query) params.set('sysparm_query', query);
|
|
72
132
|
const records = await app.sdk.list(table, params);
|
|
73
133
|
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
134
|
+
|
|
135
|
+
// include_counts: default opt-in on every list. Opt out per-profile
|
|
136
|
+
// (config include_counts:false) or per-invocation (--no-count).
|
|
137
|
+
const profile = (app.config.profiles || {})[app.config.activeProfile || app.config.defaultProfile] || {};
|
|
138
|
+
const countsOn = argv.count !== false && profile.include_counts !== false;
|
|
139
|
+
let total;
|
|
140
|
+
if (countsOn && !argv['sys-id']) {
|
|
141
|
+
try { total = await app.sdk.aggregateCount(table, query); } catch { total = undefined; }
|
|
142
|
+
}
|
|
143
|
+
|
|
74
144
|
const breadcrumbs = [
|
|
75
145
|
{ action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
|
|
76
146
|
{ action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
|
|
@@ -102,9 +172,12 @@ export function recordsCmd(wrap) {
|
|
|
102
172
|
count: records.length,
|
|
103
173
|
columns,
|
|
104
174
|
records: displayRecords,
|
|
105
|
-
pagination: { limit: argv.limit, offset: argv.offset },
|
|
175
|
+
pagination: { limit: argv.limit, offset: argv.offset, ...(total != null ? { total } : {}) },
|
|
106
176
|
context: { instance_url: app.getEffectiveInstance() },
|
|
107
|
-
}, {
|
|
177
|
+
}, {
|
|
178
|
+
summary: `${records.length} record(s) from ${table}${total != null ? ` of ${total}` : ''}`,
|
|
179
|
+
breadcrumbs,
|
|
180
|
+
});
|
|
108
181
|
}),
|
|
109
182
|
})
|
|
110
183
|
.command({
|
|
@@ -113,19 +186,34 @@ export function recordsCmd(wrap) {
|
|
|
113
186
|
builder: (y) => y
|
|
114
187
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
115
188
|
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
116
|
-
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
189
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
190
|
+
.option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' }),
|
|
117
191
|
handler: wrap(async (argv, app) => {
|
|
118
192
|
assertSafeExactMatch(argv['sys-id']);
|
|
119
193
|
const params = new URLSearchParams();
|
|
120
194
|
params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
|
|
121
195
|
params.set('sysparm_limit', '1');
|
|
122
196
|
params.set('sysparm_display_value', 'true');
|
|
123
|
-
if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', argv.columns);
|
|
197
|
+
if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', `sys_id,${argv.columns}`);
|
|
124
198
|
const records = await app.sdk.list(argv.table, params);
|
|
125
199
|
if (records.length === 0) {
|
|
126
200
|
throw new Error(`Record not found: ${argv['sys-id']}`);
|
|
127
201
|
}
|
|
128
|
-
|
|
202
|
+
const record = records[0];
|
|
203
|
+
if (argv.attachments) {
|
|
204
|
+
try {
|
|
205
|
+
const atts = await app.sdk.listAttachments(argv['sys-id']);
|
|
206
|
+
record._attachments = atts;
|
|
207
|
+
} catch {
|
|
208
|
+
record._attachments = [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
record._context = { instance_url: app.getEffectiveInstance(), table: argv.table };
|
|
212
|
+
const crumbs = argv.table === 'cmdb_rel_ci' ? relationshipBreadcrumbs(record) : [];
|
|
213
|
+
app.ok(record, {
|
|
214
|
+
summary: `Record from ${argv.table}${argv.attachments ? ` (${(record._attachments || []).length} attachment(s))` : ''}`,
|
|
215
|
+
...(crumbs.length ? { breadcrumbs: crumbs } : {}),
|
|
216
|
+
});
|
|
129
217
|
}),
|
|
130
218
|
})
|
|
131
219
|
.command({
|
|
@@ -195,6 +283,163 @@ export function recordsCmd(wrap) {
|
|
|
195
283
|
app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
|
|
196
284
|
}),
|
|
197
285
|
})
|
|
286
|
+
.command({
|
|
287
|
+
command: 'attachments',
|
|
288
|
+
describe: 'Work with attachments on a record',
|
|
289
|
+
builder: (y) => y
|
|
290
|
+
.command({
|
|
291
|
+
command: 'list',
|
|
292
|
+
describe: 'List attachments on a record',
|
|
293
|
+
builder: (yy) => yy
|
|
294
|
+
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
295
|
+
.option('table', { type: 'string', describe: 'Parent table name (context only)' }),
|
|
296
|
+
handler: wrap(async (argv, app) => {
|
|
297
|
+
app.requireInstance();
|
|
298
|
+
const attachments = await app.sdk.listAttachments(argv['sys-id']);
|
|
299
|
+
app.ok({
|
|
300
|
+
sys_id: argv['sys-id'],
|
|
301
|
+
table: argv.table || '',
|
|
302
|
+
count: attachments.length,
|
|
303
|
+
attachments,
|
|
304
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
305
|
+
}, { summary: `${attachments.length} attachment(s) on record ${argv['sys-id']}` });
|
|
306
|
+
}),
|
|
307
|
+
})
|
|
308
|
+
.command({
|
|
309
|
+
command: 'get <attachment-id>',
|
|
310
|
+
describe: 'Download an attachment to a local file',
|
|
311
|
+
builder: (yy) => yy
|
|
312
|
+
.positional('attachment-id', { describe: 'Attachment sys_id', type: 'string' })
|
|
313
|
+
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id (used to resolve the file name)' })
|
|
314
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Output file path (default: attachment file name in cwd)' }),
|
|
315
|
+
handler: wrap(async (argv, app) => {
|
|
316
|
+
app.requireInstance();
|
|
317
|
+
const buf = await app.sdk.getAttachment(argv['attachment-id']);
|
|
318
|
+
let name = argv.out;
|
|
319
|
+
if (!name) {
|
|
320
|
+
// Best-effort: derive the file name from the attachment row.
|
|
321
|
+
let fileName = `${argv['attachment-id']}.bin`;
|
|
322
|
+
try {
|
|
323
|
+
const rows = await app.sdk.listAttachments(argv['sys-id']);
|
|
324
|
+
const hit = (rows || []).find((a) => getStringField(a, 'sys_id') === argv['attachment-id']);
|
|
325
|
+
const fn = getStringField(hit, 'file_name');
|
|
326
|
+
if (fn) fileName = fn;
|
|
327
|
+
} catch { /* keep default */ }
|
|
328
|
+
name = fileName;
|
|
329
|
+
}
|
|
330
|
+
await fs.promises.writeFile(name, buf);
|
|
331
|
+
app.ok({ sys_id: argv['attachment-id'], out: name, bytes: buf.length }, { summary: `Wrote ${buf.length} bytes to ${name}` });
|
|
332
|
+
}),
|
|
333
|
+
})
|
|
334
|
+
.command({
|
|
335
|
+
command: 'add <file>',
|
|
336
|
+
describe: 'Upload a file as a new attachment on a record (mutation)',
|
|
337
|
+
builder: (yy) => yy
|
|
338
|
+
.positional('file', { describe: 'Local file path to upload', type: 'string' })
|
|
339
|
+
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
340
|
+
.option('table', { type: 'string', demandOption: true, describe: 'Parent table for the record (e.g. incident)' })
|
|
341
|
+
.option('name', { type: 'string', describe: 'Display file name (default: basename of --file)' })
|
|
342
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
343
|
+
handler: wrap(async (argv, app) => {
|
|
344
|
+
app.requireInstance();
|
|
345
|
+
await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${argv['sys-id']}?`);
|
|
346
|
+
const content = await fs.promises.readFile(argv.file);
|
|
347
|
+
const fileName = argv.name || path.basename(argv.file);
|
|
348
|
+
const created = await app.sdk.addAttachment(argv.table, argv['sys-id'], content, fileName);
|
|
349
|
+
app.ok({ sys_id: created?.sys_id, file_name: fileName, table: argv.table, recordsys_id: argv['sys-id'] }, { summary: `Attached ${fileName}` });
|
|
350
|
+
}),
|
|
351
|
+
})
|
|
352
|
+
.demandCommand(1, 'Specify an attachment action: list, get, or add'),
|
|
353
|
+
})
|
|
354
|
+
.command({
|
|
355
|
+
command: 'bulk',
|
|
356
|
+
describe: 'Bulk-update records matching a query. Dry-run by default — pass --execute to commit',
|
|
357
|
+
builder: (y) => y
|
|
358
|
+
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
359
|
+
.option('query', { type: 'string', demandOption: true, describe: 'Encoded query selecting the records to update (e.g. "priority=1^state=1")' })
|
|
360
|
+
.option('set', { type: 'string', demandOption: true, describe: 'JSON object of fields to set (e.g. \'{"state":"3"}\')' })
|
|
361
|
+
.option('dry-run', { type: 'boolean', default: true, describe: 'Preview the count + a sample without mutating (default)' })
|
|
362
|
+
.option('execute', { type: 'boolean', default: false, describe: 'Perform the update after confirmation' })
|
|
363
|
+
.option('limit', { type: 'number', default: 200, describe: 'Max records to update when executing' })
|
|
364
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation (with --execute)' }),
|
|
365
|
+
handler: wrap(async (argv, app) => {
|
|
366
|
+
app.requireInstance();
|
|
367
|
+
let set;
|
|
368
|
+
try {
|
|
369
|
+
set = JSON.parse(argv.set);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
throw new Error(`Invalid --set JSON: ${e.message}`, { cause: e });
|
|
372
|
+
}
|
|
373
|
+
if (!set || typeof set !== 'object' || Array.isArray(set)) {
|
|
374
|
+
throw new Error('--set must be a JSON object of field=value pairs');
|
|
375
|
+
}
|
|
376
|
+
if (!argv['dry-run'] && !argv.execute) {
|
|
377
|
+
argv.execute = true; // explicit --no-dry-run implies execute intent
|
|
378
|
+
}
|
|
379
|
+
const dryRun = argv['dry-run'] && !argv.execute;
|
|
380
|
+
const count = await app.sdk.aggregateCount(argv.table, argv.query);
|
|
381
|
+
|
|
382
|
+
if (dryRun) {
|
|
383
|
+
const params = new URLSearchParams();
|
|
384
|
+
params.set('sysparm_query', argv.query);
|
|
385
|
+
params.set('sysparm_limit', '5');
|
|
386
|
+
params.set('sysparm_fields', 'sys_id,number,name');
|
|
387
|
+
let sample = [];
|
|
388
|
+
try { sample = await app.sdk.list(argv.table, params); } catch { /* non-fatal */ }
|
|
389
|
+
app.ok({
|
|
390
|
+
table: argv.table,
|
|
391
|
+
query: argv.query,
|
|
392
|
+
set,
|
|
393
|
+
count,
|
|
394
|
+
dry_run: true,
|
|
395
|
+
sample,
|
|
396
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
397
|
+
}, { summary: `Dry run: ${count} record(s) in ${argv.table} would be updated` });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await confirmDelete(app, argv, `Bulk update ${count} record(s) in ${argv.table}?`);
|
|
402
|
+
const sysIDs = [];
|
|
403
|
+
const pageSize = Math.min(argv.limit, 1000);
|
|
404
|
+
const params = new URLSearchParams();
|
|
405
|
+
params.set('sysparm_query', argv.query);
|
|
406
|
+
params.set('sysparm_limit', String(pageSize));
|
|
407
|
+
params.set('sysparm_fields', 'sys_id');
|
|
408
|
+
let offset = 0;
|
|
409
|
+
while (sysIDs.length < argv.limit) {
|
|
410
|
+
params.set('sysparm_offset', String(offset));
|
|
411
|
+
let recs;
|
|
412
|
+
try { recs = await app.sdk.list(argv.table, params); } catch { break; }
|
|
413
|
+
if (!recs || recs.length === 0) break;
|
|
414
|
+
for (const r of recs) {
|
|
415
|
+
const id = getStringField(r, 'sys_id');
|
|
416
|
+
if (id) sysIDs.push(id);
|
|
417
|
+
}
|
|
418
|
+
if (recs.length < pageSize) break;
|
|
419
|
+
offset += recs.length;
|
|
420
|
+
}
|
|
421
|
+
const targets = sysIDs.slice(0, argv.limit);
|
|
422
|
+
let updated = 0;
|
|
423
|
+
const failures = [];
|
|
424
|
+
for (const id of targets) {
|
|
425
|
+
try {
|
|
426
|
+
await app.sdk.update(argv.table, id, set);
|
|
427
|
+
updated += 1;
|
|
428
|
+
} catch (e) {
|
|
429
|
+
failures.push({ sys_id: id, error: e.message || String(e) });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
app.ok({
|
|
433
|
+
table: argv.table,
|
|
434
|
+
query: argv.query,
|
|
435
|
+
set,
|
|
436
|
+
matched: count,
|
|
437
|
+
updated,
|
|
438
|
+
failed: failures,
|
|
439
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
440
|
+
}, { summary: `Updated ${updated} of ${count} matching record(s) in ${argv.table}` });
|
|
441
|
+
}),
|
|
442
|
+
})
|
|
198
443
|
.command({
|
|
199
444
|
command: 'inspect <table> <identifier>',
|
|
200
445
|
aliases: ['debug', 'diag'],
|
|
@@ -230,8 +475,8 @@ export function recordsCmd(wrap) {
|
|
|
230
475
|
console.log('Query and manage records in any ServiceNow table.');
|
|
231
476
|
console.log('');
|
|
232
477
|
console.log('Available subcommands:');
|
|
233
|
-
console.log(' list List records from a table');
|
|
234
|
-
console.log(' get Get a single record by sys_id');
|
|
478
|
+
console.log(' list List records from a table (includes total by default)');
|
|
479
|
+
console.log(' get Get a single record by sys_id (--attachments to include files)');
|
|
235
480
|
console.log(' create Create a record');
|
|
236
481
|
console.log(' update Update a record');
|
|
237
482
|
console.log(' delete Delete a record');
|
package/src/commands/setup.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
|
|
2
2
|
import { setActiveProfile } from '../config.js';
|
|
3
3
|
import { errUsage } from '../errors.js';
|
|
4
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
5
|
+
|
|
6
|
+
// Setup manages local profile config — runs without a configured instance.
|
|
7
|
+
// (Not in the daily-check skip-list: legacy behavior checks for updates here.)
|
|
8
|
+
declareCapabilities('setup', { noInstance: true });
|
|
4
9
|
|
|
5
10
|
/** Interactive hub: what do you want to do? Returns 'add' | 'switch' | 'remove' | 'modify'. */
|
|
6
11
|
export async function authHubMenu() {
|
package/src/commands/skill.js
CHANGED
|
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
|
|
9
9
|
import { globalConfigPath } from '../config.js';
|
|
10
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
11
|
+
|
|
12
|
+
declareCapabilities('skill', { noInstance: true, skipDailyChecks: true });
|
|
10
13
|
|
|
11
14
|
const SKILL_NAME = 'servicenow';
|
|
12
15
|
const SKILL_REPO_PATH = path.join(
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// jsn snippets — named query snippets stored locally in the config dir.
|
|
2
|
+
// Save a table+query (with optional columns) under a name, then run it later
|
|
3
|
+
// against the active profile. No instance contact for save/list/delete/show.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { globalConfigDir } from '../config.js';
|
|
8
|
+
import { resolveFieldsParam } from '../helpers.js';
|
|
9
|
+
|
|
10
|
+
function snippetsPath() {
|
|
11
|
+
const dir = globalConfigDir();
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
return path.join(dir, 'snippets.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadSnippets() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(snippetsPath(), 'utf-8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveSnippets(map) {
|
|
27
|
+
fs.writeFileSync(snippetsPath(), JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snippetsCmd(wrap) {
|
|
31
|
+
return {
|
|
32
|
+
command: 'snippets [subcommand]',
|
|
33
|
+
describe: 'Save and re-run named query snippets (stored locally, no instance contact except run)',
|
|
34
|
+
builder: (yargs) => {
|
|
35
|
+
return yargs
|
|
36
|
+
.command({
|
|
37
|
+
command: 'save <name>',
|
|
38
|
+
describe: 'Save a named query snippet',
|
|
39
|
+
builder: (y) => y
|
|
40
|
+
.positional('name', { describe: 'Snippet name', type: 'string' })
|
|
41
|
+
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
42
|
+
.option('query', { type: 'string', describe: 'Encoded query (e.g. "priority=1^active=true")' })
|
|
43
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
|
|
44
|
+
.option('limit', { type: 'number', describe: 'Max records (default: unlimited / SDK default)' }),
|
|
45
|
+
handler: wrap(async (argv, app) => {
|
|
46
|
+
const snippets = loadSnippets();
|
|
47
|
+
snippets[argv.name] = {
|
|
48
|
+
table: argv.table,
|
|
49
|
+
query: argv.query || '',
|
|
50
|
+
columns: argv.columns ? argv.columns.split(',') : undefined,
|
|
51
|
+
limit: argv.limit,
|
|
52
|
+
saved_at: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
saveSnippets(snippets);
|
|
55
|
+
app.ok({ name: argv.name, ...snippets[argv.name] }, { summary: `Saved snippet "${argv.name}"` });
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
.command({
|
|
59
|
+
command: 'run <name>',
|
|
60
|
+
describe: 'Run a saved query snippet against the active profile (read-only)',
|
|
61
|
+
builder: (y) => y
|
|
62
|
+
.positional('name', { describe: 'Snippet name', type: 'string' })
|
|
63
|
+
.option('query', { type: 'string', describe: 'Override the encoded query' })
|
|
64
|
+
.option('limit', { type: 'number', describe: 'Override max records' })
|
|
65
|
+
.option('json', { type: 'boolean', describe: 'Force JSON output' }),
|
|
66
|
+
handler: wrap(async (argv, app) => {
|
|
67
|
+
app.requireInstance();
|
|
68
|
+
const snippets = loadSnippets();
|
|
69
|
+
const snip = snippets[argv.name];
|
|
70
|
+
if (!snip) {
|
|
71
|
+
const err = new Error(`Snippet "${argv.name}" not found`);
|
|
72
|
+
err.code = 'not_found';
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
const columns = snip.columns || [];
|
|
76
|
+
const query = argv.query || snip.query || '';
|
|
77
|
+
const limit = argv.limit || snip.limit || 20;
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
params.set('sysparm_limit', String(limit));
|
|
80
|
+
params.set('sysparm_offset', '0');
|
|
81
|
+
params.set('sysparm_display_value', 'all');
|
|
82
|
+
const fields = columns.length ? resolveFieldsParam(columns) : undefined;
|
|
83
|
+
if (fields) params.set('sysparm_fields', fields);
|
|
84
|
+
if (query) params.set('sysparm_query', query);
|
|
85
|
+
const records = await app.sdk.list(snip.table, params);
|
|
86
|
+
app.ok({
|
|
87
|
+
table: snip.table,
|
|
88
|
+
query,
|
|
89
|
+
columns,
|
|
90
|
+
count: records.length,
|
|
91
|
+
records,
|
|
92
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
93
|
+
}, { summary: `${records.length} record(s) from ${snip.table} (snippet "${argv.name}")` });
|
|
94
|
+
}),
|
|
95
|
+
})
|
|
96
|
+
.command({
|
|
97
|
+
command: 'list',
|
|
98
|
+
describe: 'List saved snippets',
|
|
99
|
+
handler: wrap(async (argv, app) => {
|
|
100
|
+
const snippets = loadSnippets();
|
|
101
|
+
const entries = Object.entries(snippets).map(([name, s]) => ({
|
|
102
|
+
name,
|
|
103
|
+
table: s.table,
|
|
104
|
+
query: s.query || '',
|
|
105
|
+
}));
|
|
106
|
+
app.ok({ count: entries.length, snippets: entries }, { summary: `${entries.length} saved snippet(s)` });
|
|
107
|
+
}),
|
|
108
|
+
})
|
|
109
|
+
.command({
|
|
110
|
+
command: 'show <name>',
|
|
111
|
+
describe: 'Show a saved snippet definition',
|
|
112
|
+
handler: wrap(async (argv, app) => {
|
|
113
|
+
const snippets = loadSnippets();
|
|
114
|
+
const snip = snippets[argv.name];
|
|
115
|
+
if (!snip) {
|
|
116
|
+
const err = new Error(`Snippet "${argv.name}" not found`);
|
|
117
|
+
err.code = 'not_found';
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
app.ok({ name: argv.name, ...snip }, { summary: `Snippet "${argv.name}"` });
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
.command({
|
|
124
|
+
command: 'delete <name>',
|
|
125
|
+
describe: 'Delete a saved snippet',
|
|
126
|
+
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
127
|
+
handler: wrap(async (argv, app) => {
|
|
128
|
+
const { confirmDelete } = await import('../helpers.js');
|
|
129
|
+
await confirmDelete(app, argv, `Delete snippet "${argv.name}"?`);
|
|
130
|
+
const snippets = loadSnippets();
|
|
131
|
+
if (!(argv.name in snippets)) {
|
|
132
|
+
const err = new Error(`Snippet "${argv.name}" not found`);
|
|
133
|
+
err.code = 'not_found';
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
delete snippets[argv.name];
|
|
137
|
+
saveSnippets(snippets);
|
|
138
|
+
app.ok({ name: argv.name, deleted: true }, { summary: `Deleted snippet "${argv.name}"` });
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
handler: () => {
|
|
143
|
+
console.log('Named query snippets (stored locally).');
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log('Available subcommands:');
|
|
146
|
+
console.log(' save <name> Save a table+query snippet');
|
|
147
|
+
console.log(' run <name> Run a snippet against the active profile');
|
|
148
|
+
console.log(' list List saved snippets');
|
|
149
|
+
console.log(' show <name> Show a snippet definition');
|
|
150
|
+
console.log(' delete <name> Delete a snippet');
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log('Run "jsn snippets <command> --help" for details.');
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/commands/tickets.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam
|
|
1
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
|
|
2
|
+
import { resolveRecord, resolveSysId } from '../resolve-record.js';
|
|
3
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
4
|
+
|
|
5
|
+
declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
2
6
|
|
|
3
7
|
export function ticketsCmd(wrap) {
|
|
4
8
|
return {
|
|
@@ -60,16 +64,8 @@ export function ticketsCmd(wrap) {
|
|
|
60
64
|
aliases: ['get'],
|
|
61
65
|
describe: 'Show a ticket',
|
|
62
66
|
handler: wrap(async (argv, app) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
params.set('sysparm_query', `number=${argv.number}`);
|
|
66
|
-
params.set('sysparm_limit', '1');
|
|
67
|
-
params.set('sysparm_display_value', 'all');
|
|
68
|
-
const records = await app.sdk.list('task', params);
|
|
69
|
-
if (records.length === 0) {
|
|
70
|
-
throw new Error(`Ticket not found: ${argv.number}`);
|
|
71
|
-
}
|
|
72
|
-
app.ok(records[0], { summary: `Ticket ${argv.number}` });
|
|
67
|
+
const record = await resolveRecord(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
68
|
+
app.ok(record, { summary: `Ticket ${argv.number}` });
|
|
73
69
|
}),
|
|
74
70
|
})
|
|
75
71
|
.command({
|
|
@@ -88,15 +84,7 @@ export function ticketsCmd(wrap) {
|
|
|
88
84
|
builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
|
|
89
85
|
handler: wrap(async (argv, app) => {
|
|
90
86
|
const recordData = JSON.parse(argv.data);
|
|
91
|
-
|
|
92
|
-
const findParams = new URLSearchParams();
|
|
93
|
-
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
94
|
-
findParams.set('sysparm_limit', '1');
|
|
95
|
-
const records = await app.sdk.list('task', findParams);
|
|
96
|
-
if (records.length === 0) {
|
|
97
|
-
throw new Error(`Ticket not found: ${argv.number}`);
|
|
98
|
-
}
|
|
99
|
-
const sysID = getStringField(records[0], 'sys_id');
|
|
87
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
100
88
|
const updated = await app.sdk.update('task', sysID, recordData);
|
|
101
89
|
app.ok(updated, { summary: `Updated ticket ${argv.number}` });
|
|
102
90
|
}),
|
|
@@ -105,15 +93,7 @@ export function ticketsCmd(wrap) {
|
|
|
105
93
|
command: 'delete <number>',
|
|
106
94
|
describe: 'Delete a ticket',
|
|
107
95
|
handler: wrap(async (argv, app) => {
|
|
108
|
-
|
|
109
|
-
const findParams = new URLSearchParams();
|
|
110
|
-
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
111
|
-
findParams.set('sysparm_limit', '1');
|
|
112
|
-
const records = await app.sdk.list('task', findParams);
|
|
113
|
-
if (records.length === 0) {
|
|
114
|
-
throw new Error(`Ticket not found: ${argv.number}`);
|
|
115
|
-
}
|
|
116
|
-
const sysID = getStringField(records[0], 'sys_id');
|
|
96
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
117
97
|
await app.sdk.delete('task', sysID);
|
|
118
98
|
app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
|
|
119
99
|
}),
|
package/src/commands/users.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam
|
|
1
|
+
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
2
|
+
import { resolveRecord, resolveSysId } from '../resolve-record.js';
|
|
3
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
4
|
+
|
|
5
|
+
declareCapabilities('users', { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
2
6
|
|
|
3
7
|
export function usersCmd(wrap) {
|
|
4
8
|
return {
|
|
@@ -57,17 +61,8 @@ export function usersCmd(wrap) {
|
|
|
57
61
|
describe: 'Show a user by user_name or sys_id',
|
|
58
62
|
handler: wrap(async (argv, app) => {
|
|
59
63
|
const id = argv.identifier;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const params = new URLSearchParams();
|
|
63
|
-
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
|
64
|
-
params.set('sysparm_limit', '1');
|
|
65
|
-
params.set('sysparm_display_value', 'all');
|
|
66
|
-
const records = await app.sdk.list('sys_user', params);
|
|
67
|
-
if (records.length === 0) {
|
|
68
|
-
throw new Error(`User not found: ${id}`);
|
|
69
|
-
}
|
|
70
|
-
app.ok(records[0], { summary: `User ${id}` });
|
|
64
|
+
const record = await resolveRecord(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
|
|
65
|
+
app.ok(record, { summary: `User ${id}` });
|
|
71
66
|
}),
|
|
72
67
|
})
|
|
73
68
|
.command({
|
|
@@ -105,19 +100,8 @@ export function usersCmd(wrap) {
|
|
|
105
100
|
.option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
|
|
106
101
|
handler: wrap(async (argv, app) => {
|
|
107
102
|
const id = argv.identifier;
|
|
108
|
-
assertSafeExactMatch(id);
|
|
109
103
|
const recordData = JSON.parse(argv.data);
|
|
110
|
-
const
|
|
111
|
-
const params = new URLSearchParams();
|
|
112
|
-
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
|
113
|
-
params.set('sysparm_limit', '1');
|
|
114
|
-
params.set('sysparm_display_value', 'all');
|
|
115
|
-
params.set('sysparm_fields', 'sys_id');
|
|
116
|
-
const records = await app.sdk.list('sys_user', params);
|
|
117
|
-
if (records.length === 0) {
|
|
118
|
-
throw new Error(`User not found: ${id}`);
|
|
119
|
-
}
|
|
120
|
-
const sysID = getStringField(records[0], 'sys_id');
|
|
104
|
+
const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
|
|
121
105
|
const updated = await app.sdk.update('sys_user', sysID, recordData);
|
|
122
106
|
app.ok(updated, {
|
|
123
107
|
summary: `Updated user ${id}`,
|
|
@@ -132,17 +116,7 @@ export function usersCmd(wrap) {
|
|
|
132
116
|
describe: 'Delete a user by user_name or sys_id',
|
|
133
117
|
handler: wrap(async (argv, app) => {
|
|
134
118
|
const id = argv.identifier;
|
|
135
|
-
|
|
136
|
-
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
137
|
-
const params = new URLSearchParams();
|
|
138
|
-
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
|
139
|
-
params.set('sysparm_limit', '1');
|
|
140
|
-
params.set('sysparm_fields', 'sys_id');
|
|
141
|
-
const records = await app.sdk.list('sys_user', params);
|
|
142
|
-
if (records.length === 0) {
|
|
143
|
-
throw new Error(`User not found: ${id}`);
|
|
144
|
-
}
|
|
145
|
-
const sysID = getStringField(records[0], 'sys_id');
|
|
119
|
+
const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
|
|
146
120
|
await app.sdk.delete('sys_user', sysID);
|
|
147
121
|
app.ok({ identifier: id, message: 'User deleted' }, {
|
|
148
122
|
summary: `Deleted user ${id}`,
|
package/src/commands/version.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
5
|
+
|
|
6
|
+
declareCapabilities('version', { noInstance: true, skipDailyChecks: true });
|
|
4
7
|
|
|
5
8
|
const NPM_PKG = '@jacebenson/jsn';
|
|
6
9
|
const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
|