@jacebenson/jsn 4.1.0 → 4.2.1

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 +49 -65
  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
package/src/cli.js CHANGED
@@ -5,17 +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';
13
- import { FormatJSON } from './output.js';
12
+ import { isMutationCommand, refreshMutationCommands } from './mutations.js';
13
+ import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
14
+ import { guardExit, exitWithError } from './errors.js';
14
15
 
15
16
  // Command modules
16
17
  import { setupCmd } from './commands/setup.js';
17
18
  import { authCmd } from './commands/auth.js';
18
19
  import { recordsCmd } from './commands/records.js';
20
+ import { cmdbCmd } from './commands/cmdb.js';
21
+ import { snippetsCmd } from './commands/snippets.js';
19
22
  import { catalogCmd } from './commands/catalog.js';
20
23
  import { incidentsCmd } from './commands/incidents.js';
21
24
  import { changesCmd } from './commands/changes.js';
@@ -26,6 +29,7 @@ import { groupsCmd } from './commands/groups.js';
26
29
  import { groupMembersCmd } from './commands/groupmembers.js';
27
30
  import { groupRolesCmd } from './commands/grouproles.js';
28
31
  import { ticketsCmd } from './commands/tickets.js';
32
+ import { codesearchCmd } from './commands/codesearch.js';
29
33
  import { versionCmd } from './commands/version.js';
30
34
  import { devCmd } from './commands/dev.js';
31
35
  import { skillCmd, checkSkill } from './commands/skill.js';
@@ -35,7 +39,7 @@ import { docsCmd } from './commands/docs/docs.js';
35
39
  // Dev subcommands promoted to root level for progressive disclosure
36
40
  import {
37
41
  actionsCmd, includesCmd, rulesCmd,
38
- clientScriptsCmd, uiActionsCmd, uiPoliciesCmd,
42
+ clientScriptsCmd, uiActionsCmd,
39
43
  tablesCmd, columnsCmd, importCmd,
40
44
  spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
41
45
  aclsCmd, rolesCmd, propertiesCmd,
@@ -60,6 +64,8 @@ import { restCmd } from './commands/dev/rest.js';
60
64
  import { logsCmd } from './commands/dev/logs.js';
61
65
  import { scrapiCmd } from './commands/dev/scrapi.js';
62
66
  import { b4rulesCmd } from './commands/dev/b4rules.js';
67
+ import { atfCmd } from './commands/atf.js';
68
+ import { approvalsCmd } from './commands/approvals.js';
63
69
 
64
70
  function wrap(handler) {
65
71
  return async (argv) => {
@@ -71,37 +77,11 @@ function wrap(handler) {
71
77
  }
72
78
  await handler(argv, app);
73
79
  } catch (err) {
74
- if (err.code === 'not_found') {
75
- const id = argv._.slice(1).join(' ') || argv.id || argv.name || argv.sysID || '';
76
- process.stderr.write(`Error (${err.code}): ${err.message}\n`);
77
- if (id) {
78
- process.stderr.write(`\nHint: The identifier "${id}" was not found. Check the name or sys_id.\n`);
79
- }
80
- process.exit(1);
81
- }
82
- if (err.code === 'usage') {
83
- process.stderr.write(`Error (usage): ${err.message}\n`);
84
- process.exit(2);
85
- }
86
- if (err.code === 'system_error') {
87
- process.stderr.write(`Error (system): ${err.message}\n`);
88
- process.exit(3);
89
- }
90
- if (err.code === 'confirmation_required') {
91
- // Structured error for AI agents / piped consumers: emit the
92
- // JSON envelope on stdout (where tooling reads) so the agent can
93
- // surface the decision to its user or re-run with --force.
94
- const app = argv.app;
95
- if (app && app.output.effectiveFormat() === FormatJSON) {
96
- app.err(err);
97
- } else {
98
- process.stderr.write(`Error: ${err.message}\n`);
99
- if (err.hint) process.stderr.write(`\n${err.hint}\n`);
100
- }
101
- process.exit(1);
102
- }
103
- process.stderr.write(`Error: ${err.message}\n`);
104
- 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 });
105
85
  }
106
86
  };
107
87
  }
@@ -109,15 +89,17 @@ function wrap(handler) {
109
89
  export function buildCLI() {
110
90
  const cfg = loadConfig();
111
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 = [];
112
95
 
113
- return yargs(hideBin(process.argv))
96
+ const cliInstance = yargs(hideBin(process.argv))
114
97
  .scriptName('jsn')
115
98
  .usage('Usage: jsn <command> [options]')
116
99
  .middleware([
117
100
  // Attach App instance to every command
118
101
  (argv) => {
119
102
  argv.app = app;
120
- argv._profile = cfg.profiles || {};
121
103
  },
122
104
  ])
123
105
  .options({
@@ -133,13 +115,21 @@ export function buildCLI() {
133
115
  },
134
116
  format: {
135
117
  type: 'string',
136
- description: 'Output format: auto, json, markdown, styled, quiet',
137
- choices: ['auto', 'json', 'markdown', 'styled', 'quiet'],
118
+ description: 'Output format: auto, json, markdown, styled, quiet, csv',
119
+ choices: ['auto', 'json', 'markdown', 'styled', 'quiet', 'csv'],
138
120
  },
139
121
  json: {
140
122
  type: 'boolean',
141
123
  description: 'Output in JSON format (shortcut for --format=json)',
142
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
+ },
143
133
  quiet: {
144
134
  type: 'boolean',
145
135
  description: 'Output only data, no envelope (shortcut for --format=quiet)',
@@ -161,42 +151,37 @@ export function buildCLI() {
161
151
  if (argv.quiet) argv.format = 'quiet';
162
152
  if (argv.styled) argv.format = 'styled';
163
153
  if (argv.markdown) argv.format = 'markdown';
154
+ if (argv.csv) argv.format = 'csv';
164
155
  if (argv.format) {
165
156
  app.output.setFormat(argv.format);
166
157
  }
167
- // --profile: resolve profile name to its instance URL
168
- // --instance wins if both are specified (explicit URL > profile reference)
169
- if (argv.profile) {
170
- const profile = (cfg.profiles || {})[argv.profile];
171
- if (!profile) {
172
- guardExit(argv, {
173
- code: 'unknown_profile',
174
- message: `Unknown profile "${argv.profile}"`,
175
- hint: 'Run "jsn auth status" to list profiles.',
176
- });
177
- }
178
- const url = profile.instance_url;
179
- app._overrideInstance = url;
180
- argv._overrideInstance = url;
181
- // Temporarily switch active profile so auth picks up the right user
182
- cfg.activeProfile = argv.profile;
158
+ if (argv.get) {
159
+ app.output.setJqFilter(argv.get);
183
160
  }
184
- if (argv.instance) {
185
- app._overrideInstance = argv.instance;
186
- 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
+ });
187
174
  }
175
+ applySession(app, session);
188
176
  },
189
177
  // Guard mutation commands: require an instance, block read-only profiles
190
178
  (argv) => {
191
179
  const cmd = (argv._[0] || '').toString();
192
- 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)) {
193
183
  return;
194
184
  }
195
- // Check for --instance override
196
- if (argv.instance) {
197
- app._overrideInstance = argv.instance;
198
- argv._overrideInstance = argv.instance;
199
- }
200
185
  if (isMutationCommand(argv)) {
201
186
  try {
202
187
  app.requireInstance();
@@ -206,12 +191,10 @@ export function buildCLI() {
206
191
  // raw stack trace).
207
192
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
208
193
  }
209
- const profile = getActiveProfile(cfg);
210
- if (profile?.read_only === true) {
211
- const name = cfg.activeProfile || cfg.defaultProfile;
194
+ if (app.session?.readOnly === true) {
212
195
  guardExit(argv, {
213
196
  code: 'read_only',
214
- message: `Profile "${name}" is read-only. Mutations are blocked.`,
197
+ message: `Profile "${app.session.profileName}" is read-only. Mutations are blocked.`,
215
198
  hint: 'Switch to a write-enabled profile first:\n jsn auth switch <name>',
216
199
  });
217
200
  }
@@ -219,22 +202,22 @@ export function buildCLI() {
219
202
  },
220
203
  ])
221
204
  .middleware(async (argv) => {
222
- // Override instance for this command run
223
- if (argv._overrideInstance) {
224
- app.setEffectiveInstance(argv._overrideInstance);
225
- }
226
-
205
+ // Session (incl. --instance/--profile overrides) was already applied
206
+ // by the middleware above — nothing to re-apply here.
227
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();
228
211
 
229
212
  // Daily npm version check (fire-and-forget, non-blocking)
230
213
  // Checks npm for newer jsn releases, at most once per 24 hours.
231
- const skipNpmCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
214
+ const skipNpmCheck = dailySkips.has(cmd)
232
215
  || process.env.JSN_NO_VERSION_CHECK === '1'
233
216
  || argv.json
234
217
  || argv.quiet;
235
218
 
236
219
  // Auto-check skill on every command (once per 24h, non-blocking)
237
- const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
220
+ const skipSkillCheck = dailySkips.has(cmd)
238
221
  || process.env.JSN_NO_SKILL_CHECK === '1'
239
222
  || argv['no-skill-check'];
240
223
  if (!skipSkillCheck) {
@@ -293,7 +276,7 @@ export function buildCLI() {
293
276
  }
294
277
 
295
278
  // Print context header for interactive terminals
296
- if (!['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)) {
279
+ if (!dailySkips.has(cmd)) {
297
280
  await argv.app.printContextHeader(argv);
298
281
  }
299
282
  })
@@ -340,11 +323,10 @@ export function buildCLI() {
340
323
  .command(formsCmd(wrap))
341
324
  .command(listsCmd(wrap))
342
325
  .command(clientScriptsCmd(wrap))
343
- .command(uiPoliciesCmd(wrap))
326
+ .command(uipoliciesCmd(wrap))
344
327
  .command(uiActionsCmd(wrap))
345
328
  .command(viewsCmd(wrap))
346
329
  .command(catalogscriptsCmd(wrap))
347
- .command(uipoliciesCmd(wrap))
348
330
  .command(cataloguipoliciesCmd(wrap))
349
331
  // USER EXPERIENCE — Core UI
350
332
  .command(uiPagesCmd(wrap))
@@ -370,9 +352,14 @@ export function buildCLI() {
370
352
  .command(logsCmd(wrap))
371
353
  .command(propertiesCmd(wrap))
372
354
  // DATA
355
+ .command(cmdbCmd(wrap))
373
356
  .command(recordsCmd(wrap))
357
+ .command(snippetsCmd(wrap))
374
358
  .command(ticketsCmd(wrap))
359
+ .command(codesearchCmd(wrap))
375
360
  // DEVELOPER
361
+ .command(atfCmd(wrap))
362
+ .command(approvalsCmd(wrap))
376
363
  .command(evalCmd(wrap))
377
364
  .command(restCmd(wrap))
378
365
  .command(skillCmd(wrap))
@@ -381,6 +368,59 @@ export function buildCLI() {
381
368
  // Legacy
382
369
  .command(devCmd(wrap))
383
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
+ })
384
424
  .help('help', 'Show help')
385
425
  .version(false)
386
426
  .strictCommands()
@@ -410,6 +450,16 @@ export function buildCLI() {
410
450
  process.stderr.write(msg + '\n');
411
451
  process.exit(1);
412
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;
413
463
  }
414
464
 
415
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, canPrompt } from '../helpers.js';
5
- import { search } from '@inquirer/prompts';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
5
+ import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
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 = canPrompt();
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}`,