@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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -1
  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 -79
  7. package/src/commands/_ticket.js +36 -92
  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 +15 -133
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +62 -110
  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 +40 -4
  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 +254 -9
  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 +92 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +22 -11
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +187 -61
  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,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
- 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({
@@ -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');
@@ -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
  }),
@@ -1,4 +1,8 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
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
- assertSafeExactMatch(id);
61
- const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
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 isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
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
- assertSafeExactMatch(id);
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}`,
@@ -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`;