@jacebenson/jsn 4.0.4 → 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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -1,4 +1,7 @@
1
1
  import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
2
+ import { declareCapabilities } from '../capabilities.js';
3
+
4
+ declareCapabilities('groups', { mutationSubcommands: ['create', 'update', 'delete'] });
2
5
 
3
6
  export function groupsCmd(wrap) {
4
7
  return {
@@ -1,4 +1,11 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch } from '../helpers.js';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
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
- return app.ok(picked, { summary: `Record from ${table}` });
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
- }, { summary: `${records.length} record(s) from ${table}`, breadcrumbs });
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
- app.ok(records[0], { summary: `Record from ${argv.table}` });
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({
@@ -135,7 +223,8 @@ export function recordsCmd(wrap) {
135
223
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
136
224
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
137
225
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
138
- .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
226
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
227
+ .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
139
228
  handler: wrap(async (argv, app) => {
140
229
  const recordData = parseDataArg(argv);
141
230
  const warnings = checkDerivedFields(argv.table, recordData);
@@ -143,6 +232,14 @@ export function recordsCmd(wrap) {
143
232
  process.stderr.write(`⚠ ${w.hint}\n`);
144
233
  }
145
234
  const record = await app.sdk.create(argv.table, recordData);
235
+ const sysID = getStringField(record, 'sys_id');
236
+ const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
237
+ for (const m of mismatches) {
238
+ process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
239
+ }
240
+ if (mismatches.length > 0 && argv.strict) {
241
+ throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after create`);
242
+ }
146
243
  app.ok(record, { summary: `Created record in ${argv.table}` });
147
244
  }),
148
245
  })
@@ -154,7 +251,8 @@ export function recordsCmd(wrap) {
154
251
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
155
252
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
156
253
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
157
- .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
254
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
255
+ .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
158
256
  handler: wrap(async (argv, app) => {
159
257
  const recordData = parseDataArg(argv);
160
258
  const warnings = checkDerivedFields(argv.table, recordData);
@@ -162,6 +260,13 @@ export function recordsCmd(wrap) {
162
260
  process.stderr.write(`⚠ ${w.hint}\n`);
163
261
  }
164
262
  const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
263
+ const mismatches = await verifyWriteBack(app, argv.table, argv['sys-id'], recordData);
264
+ for (const m of mismatches) {
265
+ process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
266
+ }
267
+ if (mismatches.length > 0 && argv.strict) {
268
+ throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after update`);
269
+ }
165
270
  app.ok(record, { summary: `Updated record in ${argv.table}` });
166
271
  }),
167
272
  })
@@ -178,6 +283,163 @@ export function recordsCmd(wrap) {
178
283
  app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
179
284
  }),
180
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
+ })
181
443
  .command({
182
444
  command: 'inspect <table> <identifier>',
183
445
  aliases: ['debug', 'diag'],
@@ -213,8 +475,8 @@ export function recordsCmd(wrap) {
213
475
  console.log('Query and manage records in any ServiceNow table.');
214
476
  console.log('');
215
477
  console.log('Available subcommands:');
216
- console.log(' list List records from a table');
217
- 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)');
218
480
  console.log(' create Create a record');
219
481
  console.log(' update Update a record');
220
482
  console.log(' delete Delete a record');
@@ -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() {
@@ -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
+ }
@@ -1,4 +1,8 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
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
- assertSafeExactMatch(argv.number);
64
- const params = new URLSearchParams();
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
- assertSafeExactMatch(argv.number);
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
- assertSafeExactMatch(argv.number);
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
  }),