@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
package/src/cli.js CHANGED
@@ -5,16 +5,20 @@ import { hideBin } from 'yargs/helpers';
5
5
  import process from 'node:process';
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
- import { loadConfig, getActiveProfile, globalConfigDir } from './config.js';
8
+ import { loadConfig, globalConfigDir } from './config.js';
9
9
  import { App } from './app.js';
10
+ import { resolveSession, applySession } from './session.js';
10
11
  import { renderHelp } from './help.js';
11
- import { isMutationCommand } from './mutations.js';
12
- import { guardExit } from './errors.js';
12
+ import { isMutationCommand, refreshMutationCommands } from './mutations.js';
13
+ import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
14
+ import { guardExit, exitWithError } from './errors.js';
13
15
 
14
16
  // Command modules
15
17
  import { setupCmd } from './commands/setup.js';
16
18
  import { authCmd } from './commands/auth.js';
17
19
  import { recordsCmd } from './commands/records.js';
20
+ import { cmdbCmd } from './commands/cmdb.js';
21
+ import { snippetsCmd } from './commands/snippets.js';
18
22
  import { catalogCmd } from './commands/catalog.js';
19
23
  import { incidentsCmd } from './commands/incidents.js';
20
24
  import { changesCmd } from './commands/changes.js';
@@ -25,6 +29,7 @@ import { groupsCmd } from './commands/groups.js';
25
29
  import { groupMembersCmd } from './commands/groupmembers.js';
26
30
  import { groupRolesCmd } from './commands/grouproles.js';
27
31
  import { ticketsCmd } from './commands/tickets.js';
32
+ import { codesearchCmd } from './commands/codesearch.js';
28
33
  import { versionCmd } from './commands/version.js';
29
34
  import { devCmd } from './commands/dev.js';
30
35
  import { skillCmd, checkSkill } from './commands/skill.js';
@@ -34,7 +39,7 @@ import { docsCmd } from './commands/docs/docs.js';
34
39
  // Dev subcommands promoted to root level for progressive disclosure
35
40
  import {
36
41
  actionsCmd, includesCmd, rulesCmd,
37
- clientScriptsCmd, uiActionsCmd, uiPoliciesCmd,
42
+ clientScriptsCmd, uiActionsCmd,
38
43
  tablesCmd, columnsCmd, importCmd,
39
44
  spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
40
45
  aclsCmd, rolesCmd, propertiesCmd,
@@ -59,6 +64,8 @@ import { restCmd } from './commands/dev/rest.js';
59
64
  import { logsCmd } from './commands/dev/logs.js';
60
65
  import { scrapiCmd } from './commands/dev/scrapi.js';
61
66
  import { b4rulesCmd } from './commands/dev/b4rules.js';
67
+ import { atfCmd } from './commands/atf.js';
68
+ import { approvalsCmd } from './commands/approvals.js';
62
69
 
63
70
  function wrap(handler) {
64
71
  return async (argv) => {
@@ -70,24 +77,11 @@ function wrap(handler) {
70
77
  }
71
78
  await handler(argv, app);
72
79
  } catch (err) {
73
- if (err.code === 'not_found') {
74
- const id = argv._.slice(1).join(' ') || argv.id || argv.name || argv.sysID || '';
75
- process.stderr.write(`Error (${err.code}): ${err.message}\n`);
76
- if (id) {
77
- process.stderr.write(`\nHint: The identifier "${id}" was not found. Check the name or sys_id.\n`);
78
- }
79
- process.exit(1);
80
- }
81
- if (err.code === 'usage') {
82
- process.stderr.write(`Error (usage): ${err.message}\n`);
83
- process.exit(2);
84
- }
85
- if (err.code === 'system_error') {
86
- process.stderr.write(`Error (system): ${err.message}\n`);
87
- process.exit(3);
88
- }
89
- process.stderr.write(`Error: ${err.message}\n`);
90
- process.exit(1);
80
+ // All error rendering goes through the unified renderer in
81
+ // src/errors.js — exit codes, stream choice, and the JSON envelope
82
+ // for confirmation_required live there, not here.
83
+ const identifier = argv._.slice(1).join(' ') || argv.id || argv.name || argv.sysID || '';
84
+ exitWithError(err, { app: argv.app, argv, identifier });
91
85
  }
92
86
  };
93
87
  }
@@ -95,15 +89,17 @@ function wrap(handler) {
95
89
  export function buildCLI() {
96
90
  const cfg = loadConfig();
97
91
  const app = new App(cfg);
92
+ // Root command names for the completion filter — captured from the yargs
93
+ // registry after the chain below finishes registering all commands.
94
+ let rootCommands = [];
98
95
 
99
- return yargs(hideBin(process.argv))
96
+ const cliInstance = yargs(hideBin(process.argv))
100
97
  .scriptName('jsn')
101
98
  .usage('Usage: jsn <command> [options]')
102
99
  .middleware([
103
100
  // Attach App instance to every command
104
101
  (argv) => {
105
102
  argv.app = app;
106
- argv._profile = cfg.profiles || {};
107
103
  },
108
104
  ])
109
105
  .options({
@@ -119,13 +115,21 @@ export function buildCLI() {
119
115
  },
120
116
  format: {
121
117
  type: 'string',
122
- description: 'Output format: auto, json, markdown, styled, quiet',
123
- choices: ['auto', 'json', 'markdown', 'styled', 'quiet'],
118
+ description: 'Output format: auto, json, markdown, styled, quiet, csv',
119
+ choices: ['auto', 'json', 'markdown', 'styled', 'quiet', 'csv'],
124
120
  },
125
121
  json: {
126
122
  type: 'boolean',
127
123
  description: 'Output in JSON format (shortcut for --format=json)',
128
124
  },
125
+ csv: {
126
+ type: 'boolean',
127
+ description: 'Output in CSV format (shortcut for --format=csv)',
128
+ },
129
+ get: {
130
+ type: 'string',
131
+ description: 'Extract a JSON path from the output envelope (e.g. --get "data.records.0.number"); no jq needed',
132
+ },
129
133
  quiet: {
130
134
  type: 'boolean',
131
135
  description: 'Output only data, no envelope (shortcut for --format=quiet)',
@@ -147,42 +151,37 @@ export function buildCLI() {
147
151
  if (argv.quiet) argv.format = 'quiet';
148
152
  if (argv.styled) argv.format = 'styled';
149
153
  if (argv.markdown) argv.format = 'markdown';
154
+ if (argv.csv) argv.format = 'csv';
150
155
  if (argv.format) {
151
156
  app.output.setFormat(argv.format);
152
157
  }
153
- // --profile: resolve profile name to its instance URL
154
- // --instance wins if both are specified (explicit URL > profile reference)
155
- if (argv.profile) {
156
- const profile = (cfg.profiles || {})[argv.profile];
157
- if (!profile) {
158
- guardExit(argv, {
159
- code: 'unknown_profile',
160
- message: `Unknown profile "${argv.profile}"`,
161
- hint: 'Run "jsn auth status" to list profiles.',
162
- });
163
- }
164
- const url = profile.instance_url;
165
- app._overrideInstance = url;
166
- argv._overrideInstance = url;
167
- // Temporarily switch active profile so auth picks up the right user
168
- cfg.activeProfile = argv.profile;
158
+ if (argv.get) {
159
+ app.output.setJqFilter(argv.get);
169
160
  }
170
- if (argv.instance) {
171
- app._overrideInstance = argv.instance;
172
- argv._overrideInstance = argv.instance;
161
+ },
162
+ // --profile / --instance resolve through the SESSION (src/session.js).
163
+ // resolveSession is pure; applySession is the ONLY place cfg.activeProfile
164
+ // / app._overrideInstance change — no more argv._overrideInstance mirror
165
+ // or duplicated --instance branches across middlewares.
166
+ (argv) => {
167
+ const session = resolveSession(argv, cfg);
168
+ if (session.unknownProfile) {
169
+ guardExit(argv, {
170
+ code: 'unknown_profile',
171
+ message: `Unknown profile "${session.unknownProfile}"`,
172
+ hint: 'Run "jsn auth status" to list profiles.',
173
+ });
173
174
  }
175
+ applySession(app, session);
174
176
  },
175
177
  // Guard mutation commands: require an instance, block read-only profiles
176
178
  (argv) => {
177
179
  const cmd = (argv._[0] || '').toString();
178
- if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'skill', 'docs'].includes(cmd)) {
180
+ // Skip-list is derived from command capability declarations
181
+ // (noInstance: true), not a hand-maintained name list.
182
+ if (!cmd || noInstanceCommands().has(cmd)) {
179
183
  return;
180
184
  }
181
- // Check for --instance override
182
- if (argv.instance) {
183
- app._overrideInstance = argv.instance;
184
- argv._overrideInstance = argv.instance;
185
- }
186
185
  if (isMutationCommand(argv)) {
187
186
  try {
188
187
  app.requireInstance();
@@ -192,12 +191,10 @@ export function buildCLI() {
192
191
  // raw stack trace).
193
192
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
194
193
  }
195
- const profile = getActiveProfile(cfg);
196
- if (profile?.read_only === true) {
197
- const name = cfg.activeProfile || cfg.defaultProfile;
194
+ if (app.session?.readOnly === true) {
198
195
  guardExit(argv, {
199
196
  code: 'read_only',
200
- message: `Profile "${name}" is read-only. Mutations are blocked.`,
197
+ message: `Profile "${app.session.profileName}" is read-only. Mutations are blocked.`,
201
198
  hint: 'Switch to a write-enabled profile first:\n jsn auth switch <name>',
202
199
  });
203
200
  }
@@ -205,22 +202,22 @@ export function buildCLI() {
205
202
  },
206
203
  ])
207
204
  .middleware(async (argv) => {
208
- // Override instance for this command run
209
- if (argv._overrideInstance) {
210
- app.setEffectiveInstance(argv._overrideInstance);
211
- }
212
-
205
+ // Session (incl. --instance/--profile overrides) was already applied
206
+ // by the middleware above — nothing to re-apply here.
213
207
  const cmd = (argv._[0] || '').toString();
208
+ // Daily-check + context-header skip-list is derived from command
209
+ // capability declarations (skipDailyChecks: true).
210
+ const dailySkips = dailyCheckSkipCommands();
214
211
 
215
212
  // Daily npm version check (fire-and-forget, non-blocking)
216
213
  // Checks npm for newer jsn releases, at most once per 24 hours.
217
- const skipNpmCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
214
+ const skipNpmCheck = dailySkips.has(cmd)
218
215
  || process.env.JSN_NO_VERSION_CHECK === '1'
219
216
  || argv.json
220
217
  || argv.quiet;
221
218
 
222
219
  // Auto-check skill on every command (once per 24h, non-blocking)
223
- const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
220
+ const skipSkillCheck = dailySkips.has(cmd)
224
221
  || process.env.JSN_NO_SKILL_CHECK === '1'
225
222
  || argv['no-skill-check'];
226
223
  if (!skipSkillCheck) {
@@ -279,7 +276,7 @@ export function buildCLI() {
279
276
  }
280
277
 
281
278
  // Print context header for interactive terminals
282
- if (!['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)) {
279
+ if (!dailySkips.has(cmd)) {
283
280
  await argv.app.printContextHeader(argv);
284
281
  }
285
282
  })
@@ -326,11 +323,10 @@ export function buildCLI() {
326
323
  .command(formsCmd(wrap))
327
324
  .command(listsCmd(wrap))
328
325
  .command(clientScriptsCmd(wrap))
329
- .command(uiPoliciesCmd(wrap))
326
+ .command(uipoliciesCmd(wrap))
330
327
  .command(uiActionsCmd(wrap))
331
328
  .command(viewsCmd(wrap))
332
329
  .command(catalogscriptsCmd(wrap))
333
- .command(uipoliciesCmd(wrap))
334
330
  .command(cataloguipoliciesCmd(wrap))
335
331
  // USER EXPERIENCE — Core UI
336
332
  .command(uiPagesCmd(wrap))
@@ -356,9 +352,14 @@ export function buildCLI() {
356
352
  .command(logsCmd(wrap))
357
353
  .command(propertiesCmd(wrap))
358
354
  // DATA
355
+ .command(cmdbCmd(wrap))
359
356
  .command(recordsCmd(wrap))
357
+ .command(snippetsCmd(wrap))
360
358
  .command(ticketsCmd(wrap))
359
+ .command(codesearchCmd(wrap))
361
360
  // DEVELOPER
361
+ .command(atfCmd(wrap))
362
+ .command(approvalsCmd(wrap))
362
363
  .command(evalCmd(wrap))
363
364
  .command(restCmd(wrap))
364
365
  .command(skillCmd(wrap))
@@ -367,6 +368,59 @@ export function buildCLI() {
367
368
  // Legacy
368
369
  .command(devCmd(wrap))
369
370
  .demandCommand(1, 'You must specify a command')
371
+ // Completion plumbing under a hidden command name, so the explicit
372
+ // 'completion' command below owns the UX. (#176)
373
+ // The custom filter fixes two yargs behaviors:
374
+ // 1. Under strictCommands(), yargs' default completion handler aborts
375
+ // when the current word isn't an exact command match. Delegating to
376
+ // completionFilter (the default generator) sidesteps that.
377
+ // 2. When the current word is an exact ALIAS (e.g. "inc" for incidents),
378
+ // yargs descends into that command's subcommands, so a still-being-
379
+ // typed "jsn inc<TAB>" offers subcommands that can't match the prefix.
380
+ // When completing the FIRST word (depth 1, the command slot), merge
381
+ // in the root command list — the shell's compgen prefix-filters the
382
+ // final list anyway.
383
+ .completion('__completion', false, (current, argv, completionFilter, done) => {
384
+ completionFilter((err, completions) => {
385
+ let out = completions || [];
386
+ const depth = (argv._ || []).length;
387
+ if (current && !current.startsWith('-') && depth <= 2) {
388
+ out = [...new Set([...out, ...rootCommands])];
389
+ }
390
+ done([...new Set(out)]);
391
+ });
392
+ })
393
+ // User-facing completion command with a real help page (install docs).
394
+ // yargs' built-in completion command prints the script even for --help,
395
+ // which is why the plumbing above uses the hidden '__completion' name.
396
+ .command({
397
+ command: 'completion',
398
+ describe: 'Generate shell completion script',
399
+ builder: (y) => y
400
+ .usage('Usage: jsn completion > <file for your shell>')
401
+ .epilogue([
402
+ 'Install for your shell:',
403
+ '',
404
+ ' # bash (auto-loaded if the bash-completion package is installed)',
405
+ ' jsn completion > ~/.local/share/bash-completion/jsn',
406
+ '',
407
+ ' # bash (simple)',
408
+ ' jsn completion >> ~/.bashrc',
409
+ '',
410
+ ' # fish',
411
+ ' jsn completion > ~/.config/fish/completions/jsn.fish',
412
+ '',
413
+ ' # zsh — the generated script is bash-style, load via bashcompinit.',
414
+ ' # Add to ~/.zshrc:',
415
+ ' autoload -U +X bashcompinit && bashcompinit',
416
+ ' source <(jsn completion)',
417
+ '',
418
+ 'Restart your shell (or source the file) after installing.',
419
+ ].join('\n')),
420
+ handler: () => {
421
+ cliInstance.showCompletionScript();
422
+ },
423
+ })
370
424
  .help('help', 'Show help')
371
425
  .version(false)
372
426
  .strictCommands()
@@ -396,6 +450,16 @@ export function buildCLI() {
396
450
  process.stderr.write(msg + '\n');
397
451
  process.exit(1);
398
452
  });
453
+
454
+ // Snapshot the root command names (plus aliases) for the completion
455
+ // filter. Done before any parse — command builders mutate the registry.
456
+ rootCommands = cliInstance.getInternalMethods().getCommandInstance().getCommands();
457
+
458
+ // The capability registry is fully populated now that every command
459
+ // factory has run — generate the mutation guard's path list.
460
+ refreshMutationCommands();
461
+
462
+ return cliInstance;
399
463
  }
400
464
 
401
465
  // Run if invoked directly (bin/jsn.js calls cli.parse())
@@ -1,11 +1,15 @@
1
1
  // Generic command builder for CRUD operations on a ServiceNow table
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
- import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
5
- import { search } from '@inquirer/prompts';
6
- import { isTTY, FormatAuto } from '../output.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
5
+ import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
+ import { FormatAuto } from '../output.js';
7
+ import { declareCapabilities } from '../capabilities.js';
7
8
 
8
9
  export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
10
+ // Ticket-style CRUD: list/show are reads; create/update/delete mutate.
11
+ // Root-level only (no `jsn dev` spelling).
12
+ declareCapabilities(displayName, { mutationSubcommands: ['create', 'update', 'delete'] });
9
13
  return {
10
14
  command: `${displayName} [subcommand]`,
11
15
  aliases: [table, alias],
@@ -28,18 +32,22 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
28
32
  const offset = argv.offset;
29
33
 
30
34
  // Interactive picker in TTY with auto format and no explicit query/offset
31
- const isInteractive = isTTY(process.stdout) && isTTY(process.stdin);
32
- if (isInteractive && app.output.getFormat() === FormatAuto && !query && offset === 0) {
35
+ if (canPrompt() && app.output.getFormat() === FormatAuto && !query && offset === 0) {
33
36
  const pickerColumns = ['sys_id', 'number', 'short_description', 'state', 'assigned_to'];
34
- const pickerParams = new URLSearchParams();
35
- pickerParams.set('sysparm_limit', String(limit));
36
- pickerParams.set('sysparm_offset', '0');
37
- pickerParams.set('sysparm_display_value', 'all');
38
- pickerParams.set('sysparm_fields', pickerColumns.join(','));
39
- pickerParams.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
40
-
41
- const pickerRecords = await app.sdk.list(table, pickerParams);
42
- if (pickerRecords.length === 0) {
37
+ const picked = await interactiveList({
38
+ app, table, singular: displayName.slice(0, -1), columns: pickerColumns, limit,
39
+ labelField: 'number',
40
+ formatLabel: (r) => {
41
+ let label = `${getStringField(r, 'number')} ${getStringField(r, 'short_description')} | ${getStringField(r, 'state')}`;
42
+ const assigned = getStringField(r, 'assigned_to');
43
+ if (assigned) label += ` → ${assigned}`;
44
+ return label;
45
+ },
46
+ formatValue: (r) => r,
47
+ });
48
+ if (picked === undefined) return; // user cancelled
49
+ if (picked === null) {
50
+ // canPrompt() passed, so null means the table is empty.
43
51
  app.ok({
44
52
  table,
45
53
  count: 0,
@@ -56,58 +64,19 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
56
64
  return;
57
65
  }
58
66
 
59
- const choices = pickerRecords.map((record) => {
60
- const number = getStringField(record, 'number');
61
- const desc = getStringField(record, 'short_description');
62
- const state = getStringField(record, 'state');
63
- const assigned = getStringField(record, 'assigned_to');
64
- let label = `${number} ${desc} | ${state}`;
65
- if (assigned) {
66
- label += ` → ${assigned}`;
67
- }
68
- return { name: label, value: number };
67
+ const record = picked;
68
+ const number = getStringField(record, 'number');
69
+ record._context = {
70
+ instance_url: app.getEffectiveInstance(),
71
+ table,
72
+ };
73
+ app.ok(record, {
74
+ summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
75
+ breadcrumbs: [
76
+ { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
77
+ { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
78
+ ],
69
79
  });
70
-
71
- let selectedNumber;
72
- try {
73
- selectedNumber = await search({
74
- message: `Select a ${displayName.slice(0, -1)}:`,
75
- source: async (input) => {
76
- if (!input) return choices;
77
- const term = input.toLowerCase();
78
- return choices.filter(c => c.name.toLowerCase().includes(term));
79
- },
80
- });
81
- } catch (err) {
82
- if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
83
- return;
84
- }
85
- throw err;
86
- }
87
-
88
- if (selectedNumber) {
89
- assertSafeExactMatch(selectedNumber);
90
- const showParams = new URLSearchParams();
91
- showParams.set('sysparm_query', `number=${selectedNumber}`);
92
- showParams.set('sysparm_display_value', 'all');
93
- showParams.set('sysparm_limit', '1');
94
- const showRecords = await app.sdk.list(table, showParams);
95
- if (showRecords.length === 0) {
96
- throw new Error(`${displayName} not found: ${selectedNumber}`);
97
- }
98
- const record = showRecords[0];
99
- record._context = {
100
- instance_url: app.getEffectiveInstance(),
101
- table,
102
- };
103
- app.ok(record, {
104
- summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${selectedNumber}`,
105
- breadcrumbs: [
106
- { action: 'update', cmd: `jsn ${alias} update ${selectedNumber} --data '{...}'`, description: `Update this ${displayName}` },
107
- { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
108
- ],
109
- });
110
- }
111
80
  return;
112
81
  }
113
82
 
@@ -162,16 +131,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
162
131
  describe: `Show a specific ${displayName}`,
163
132
  handler: wrap(async (argv, app) => {
164
133
  const number = argv.number;
165
- assertSafeExactMatch(number);
166
- const params = new URLSearchParams();
167
- params.set('sysparm_query', `number=${number}`);
168
- params.set('sysparm_display_value', 'all');
169
- params.set('sysparm_limit', '1');
170
- const records = await app.sdk.list(table, params);
171
- if (records.length === 0) {
172
- throw new Error(`${displayName} not found: ${number}`);
173
- }
174
- const record = records[0];
134
+ const record = await resolveRecord(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
175
135
  record._context = {
176
136
  instance_url: app.getEffectiveInstance(),
177
137
  table,
@@ -218,16 +178,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
218
178
  .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
219
179
  handler: wrap(async (argv, app) => {
220
180
  const number = argv.number;
221
- assertSafeExactMatch(number);
222
181
  const recordData = JSON.parse(argv.data);
223
- const findParams = new URLSearchParams();
224
- findParams.set('sysparm_query', `number=${number}`);
225
- findParams.set('sysparm_limit', '1');
226
- const records = await app.sdk.list(table, findParams);
227
- if (records.length === 0) {
228
- throw new Error(`${displayName} not found: ${number}`);
229
- }
230
- const sysID = getStringField(records[0], 'sys_id');
182
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
231
183
  const updated = await app.sdk.update(table, sysID, recordData);
232
184
  app.ok(updated, {
233
185
  summary: `Updated ${displayName} ${number}`,
@@ -244,15 +196,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
244
196
  handler: wrap(async (argv, app) => {
245
197
  const number = argv.number;
246
198
  await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
247
- assertSafeExactMatch(number);
248
- const findParams = new URLSearchParams();
249
- findParams.set('sysparm_query', `number=${number}`);
250
- findParams.set('sysparm_limit', '1');
251
- const records = await app.sdk.list(table, findParams);
252
- if (records.length === 0) {
253
- throw new Error(`${displayName} not found: ${number}`);
254
- }
255
- const sysID = getStringField(records[0], 'sys_id');
199
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
256
200
  await app.sdk.delete(table, sysID);
257
201
  app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
258
202
  summary: `Deleted ${displayName} ${number}`,