@jacebenson/jsn 3.4.3 → 4.0.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 (40) hide show
  1. package/README.md +5 -4
  2. package/package.json +3 -1
  3. package/skills/servicenow/SKILL.md +3 -3
  4. package/src/app.js +7 -7
  5. package/src/auth.js +34 -63
  6. package/src/cli.js +27 -20
  7. package/src/commands/_ticket.js +7 -1
  8. package/src/commands/auth.js +303 -40
  9. package/src/commands/catalog.js +3 -4
  10. package/src/commands/dev/_generic.js +7 -14
  11. package/src/commands/dev/flows.js +110 -2
  12. package/src/commands/dev/forms.js +0 -11
  13. package/src/commands/dev/import.js +1 -1
  14. package/src/commands/dev/lists.js +2 -2
  15. package/src/commands/dev/updatesets.js +0 -16
  16. package/src/commands/docs/community-sources.json +18 -0
  17. package/src/commands/docs/community.js +343 -0
  18. package/src/commands/docs/db.js +38 -2
  19. package/src/commands/docs/docs.js +41 -0
  20. package/src/commands/docs/ingest.js +4 -17
  21. package/src/commands/docs/refresh.js +4 -17
  22. package/src/commands/docs/search.js +3 -3
  23. package/src/commands/docs/serve.js +2 -2
  24. package/src/commands/grouproles.js +5 -1
  25. package/src/commands/groups.js +6 -3
  26. package/src/commands/records.js +5 -2
  27. package/src/commands/requests.js +2 -1
  28. package/src/commands/setup.js +10 -84
  29. package/src/commands/skill.js +5 -7
  30. package/src/commands/tickets.js +4 -1
  31. package/src/commands/users.js +4 -1
  32. package/src/config.js +9 -0
  33. package/src/errors.js +26 -0
  34. package/src/help.js +2 -4
  35. package/src/helpers.js +65 -8
  36. package/src/mutations.js +40 -9
  37. package/src/output.js +6 -3
  38. package/src/paginated-search.js +1 -1
  39. package/src/records/inspect.js +2 -1
  40. package/src/commands/profiles.js +0 -190
@@ -1,16 +1,179 @@
1
- import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
1
+ import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
2
2
  import { errUsage } from '../errors.js';
3
3
  import process from 'node:process';
4
4
 
5
+ /**
6
+ * Resolve a login/logout/refresh argument to a concrete instance URL.
7
+ *
8
+ * gh-style precision: a full URL is used as-is; a bare name is first checked
9
+ * against known profile names (gh: --hostname), then treated as a ServiceNow
10
+ * instance name with the canonical `.service-now.com` host appended. This kills
11
+ * the old trap where `jsn auth login dev99999` silently targeted
12
+ * `https://dev99999` (a dead host).
13
+ */
14
+ export function resolveInstanceArg(arg, cfg) {
15
+ if (!arg) return '';
16
+ const trimmed = arg.trim();
17
+ if (/^https?:\/\//i.test(trimmed)) return normalizeInstanceURL(trimmed);
18
+ const profile = cfg?.profiles?.[trimmed];
19
+ if (profile?.instance_url) return normalizeInstanceURL(profile.instance_url);
20
+ // Already looks like a host (has a dot) — add the scheme, nothing else
21
+ if (trimmed.includes('.')) return normalizeInstanceURL(trimmed);
22
+ // Bare ServiceNow instance name → canonical host
23
+ return normalizeInstanceURL(`${trimmed}.service-now.com`);
24
+ }
25
+
26
+ /**
27
+ * Interactive first-run wizard (formerly `jsn setup`).
28
+ *
29
+ * Walks: instance URL → profile name → auth method → optional OAuth login.
30
+ * Creates and activates the profile, then returns the result so the caller
31
+ * decides how to report it. Basic auth saves credentials directly (the OAuth
32
+ * path has no interactive username/password).
33
+ */
34
+ export async function loginWizard(app, argv = {}) {
35
+ const readline = (await import('node:readline')).default;
36
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
37
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
38
+
39
+ console.log('Welcome to JSN - ServiceNow CLI');
40
+ console.log();
41
+
42
+ let instance = getEffectiveInstance(app.config);
43
+ if (!instance) {
44
+ instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
45
+ instance = normalizeInstanceURL(instance);
46
+ }
47
+ console.log(`Instance: ${instance}`);
48
+
49
+ // Warn if another profile already uses this instance URL
50
+ const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
51
+ if (existing) {
52
+ console.log(`Note: instance is also used by profile "${existing[0]}".`);
53
+ }
54
+
55
+ const profileName = (await ask('Profile name (default): ')) || 'default';
56
+ const profile = { instance_url: instance };
57
+ if (argv['read-only']) {
58
+ profile.read_only = true;
59
+ }
60
+ if (argv['skip-confirmations']) {
61
+ profile.skip_confirmations = true;
62
+ }
63
+
64
+ const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
65
+ const useBasic = authMethod.toLowerCase().startsWith('b');
66
+
67
+ let loggedIn = false;
68
+ if (useBasic) {
69
+ const username = await ask('Username: ');
70
+ rl.close(); // Close outer readline before askHidden creates its own
71
+ const { loadCredentials, askHidden, saveCredentials } = await import('../auth.js');
72
+ const existingCreds = loadCredentials(instance) || loadCredentials(instance, username);
73
+ if (existingCreds && existingCreds.auth_method === 'basic') {
74
+ console.log(`Using existing credentials for ${instance}`);
75
+ } else {
76
+ const password = await askHidden('Password: ');
77
+ saveCredentials(instance, { auth_method: 'basic', username, password }, username);
78
+ console.log('Basic auth credentials saved');
79
+ }
80
+ profile.username = username;
81
+ profile.auth_method = 'basic';
82
+ } else {
83
+ profile.auth_method = 'oauth';
84
+ }
85
+
86
+ await setProfile(app.config, profileName, profile);
87
+ // Set as active profile so subsequent commands know which instance to use
88
+ app.config.activeProfile = profileName;
89
+ app.config.defaultProfile = profileName;
90
+ await saveConfig(app.config);
91
+
92
+ if (!useBasic) {
93
+ const loginNow = await ask('Login now? [Y/n]: ');
94
+ rl.close();
95
+ if (!loginNow || loginNow.toLowerCase() !== 'n') {
96
+ await app.auth.login(instance);
97
+ // Re-save credentials under <user>@<instance> after verifying username
98
+ try {
99
+ const { SDKClient } = await import('../sdk.js');
100
+ const sdk = new SDKClient(instance, app.auth);
101
+ const user = await sdk.getCurrentUser();
102
+ if (user && user.user_name) {
103
+ const { loadCredentials, saveCredentials } = await import('../auth.js');
104
+ const stored = loadCredentials(instance);
105
+ if (stored && stored.access_token) {
106
+ stored.username = user.user_name;
107
+ saveCredentials(instance, stored, user.user_name);
108
+ }
109
+ profile.username = user.user_name;
110
+ }
111
+ } catch {
112
+ // Non-fatal — token saved, username will be set on next auth login
113
+ }
114
+ console.log('Login successful!');
115
+ loggedIn = true;
116
+ }
117
+ } else {
118
+ rl.close();
119
+ }
120
+
121
+ return { instanceURL: instance, profileName, loggedIn };
122
+ }
123
+
124
+ /**
125
+ * gh-faithful login target picker: existing profiles plus an "add a new
126
+ * instance" row. Typing filters profiles; picking the + row uses what you
127
+ * typed (or prompts for it).
128
+ *
129
+ * Returns { addNew: true, instanceName } or { addNew: false, name }.
130
+ */
131
+ async function pickLoginTarget(app) {
132
+ const { search, input } = await import('@inquirer/prompts');
133
+ const ADD_NEW = '__add_new__';
134
+ const profileNames = Object.keys(app.config.profiles || {});
135
+ let lastInput = '';
136
+ const selected = await search({
137
+ message: 'Log in to which instance?',
138
+ source: (term) => {
139
+ const filter = (term || '').toLowerCase();
140
+ lastInput = term || '';
141
+ const profiles = profileNames
142
+ .filter((name) => {
143
+ const label = `${name} — ${app.config.profiles[name].instance_url}`.toLowerCase();
144
+ return !filter || label.includes(filter);
145
+ })
146
+ .map((name) => ({
147
+ name: `${name} — ${app.config.profiles[name].instance_url}`,
148
+ value: name,
149
+ }));
150
+ const addLabel = filter
151
+ ? `+ Add "${term.trim()}" as a new instance`
152
+ : '+ Add a new instance';
153
+ return [{ name: addLabel, value: ADD_NEW }, ...profiles];
154
+ },
155
+ });
156
+ if (selected === ADD_NEW) {
157
+ const typed = lastInput.trim();
158
+ if (typed) return { addNew: true, instanceName: typed };
159
+ const name = await input({
160
+ message: 'ServiceNow instance name or URL (e.g. dev12345):',
161
+ validate: (v) => (v.trim() ? true : 'Instance name or URL is required'),
162
+ });
163
+ return { addNew: true, instanceName: name.trim() };
164
+ }
165
+ return { addNew: false, name: selected };
166
+ }
167
+
5
168
  export function authCmd(wrap) {
6
169
  return {
7
- command: 'auth <subcommand>',
170
+ command: 'auth [subcommand]',
8
171
  describe: 'Manage authentication (OAuth or basic auth via env vars)',
9
172
  builder: (yargs) => {
10
173
  return yargs
11
174
  .command({
12
175
  command: 'login [instance]',
13
- describe: 'Authenticate with a ServiceNow instance via OAuth',
176
+ describe: 'Authenticate with a ServiceNow instance (interactive picker when run bare)',
14
177
  builder: (sub) => sub
15
178
  .option('code', {
16
179
  describe: 'Authorization code from browser (bypasses interactive prompt)',
@@ -27,38 +190,47 @@ export function authCmd(wrap) {
27
190
  .option('wait-file', {
28
191
  describe: 'File path to watch for authorization code (used with --print-url)',
29
192
  type: 'string',
193
+ })
194
+ .option('read-only', {
195
+ describe: 'Mark the created profile as read-only (blocks mutation commands)',
196
+ type: 'boolean',
197
+ default: false,
198
+ })
199
+ .option('skip-confirmations', {
200
+ describe: 'Skip delete confirmations on this profile (deletes run without prompting)',
201
+ type: 'boolean',
202
+ default: false,
30
203
  }),
31
204
  handler: wrap(async (argv, app) => {
32
205
  let instanceURL;
33
206
  if (argv.instance) {
34
- // Check if it's a profile name first
35
- const profiles = app.config.profiles || {};
36
- if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
37
- instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
207
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
208
+ } else if (app.isInteractive()) {
209
+ // gh-faithful: interactive terminals always get a picker —
210
+ // existing profiles plus "+ Add a new instance". Zero profiles
211
+ // goes straight to the setup wizard.
212
+ const profileNames = Object.keys(app.config.profiles || {});
213
+ if (profileNames.length === 0) {
214
+ const wizard = await loginWizard(app, argv);
215
+ if (wizard.loggedIn) {
216
+ app.ok({ authenticated: true, instance: wizard.instanceURL, profile: wizard.profileName },
217
+ { summary: `✓ Authenticated to ${wizard.instanceURL} (profile: ${wizard.profileName})` });
218
+ } else {
219
+ app.ok({ setup: true, instance: wizard.instanceURL, profile: wizard.profileName },
220
+ { summary: `Profile "${wizard.profileName}" created for ${wizard.instanceURL}. Run "jsn auth login" to authenticate.` });
221
+ }
222
+ return;
223
+ }
224
+ const target = await pickLoginTarget(app);
225
+ if (target.addNew) {
226
+ instanceURL = resolveInstanceArg(target.instanceName, app.config);
38
227
  } else {
39
- instanceURL = normalizeInstanceURL(argv.instance);
228
+ instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
40
229
  }
41
230
  } else {
42
231
  instanceURL = getEffectiveInstance(app.config);
43
232
  if (!instanceURL) {
44
- // Try interactive profile picker
45
- const profileNames = Object.keys(app.config.profiles || {});
46
- if (profileNames.length > 0 && app.isInteractive()) {
47
- const { search } = await import('@inquirer/prompts');
48
- const choices = profileNames.map(name => ({
49
- name: `${name} — ${app.config.profiles[name].instance_url}`,
50
- value: name,
51
- }));
52
- const selected = await search({
53
- message: 'Select a profile:',
54
- source: (input) => {
55
- const filter = (input || '').toLowerCase();
56
- return choices.filter(c => c.name.toLowerCase().includes(filter));
57
- },
58
- });
59
- instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
60
- } else {
61
- throw errUsage(`Instance URL required.
233
+ throw errUsage(`Instance URL required.
62
234
 
63
235
  Examples:
64
236
  jsn auth login https://dev12345.service-now.com
@@ -66,7 +238,6 @@ Examples:
66
238
  jsn auth login --password https://dev328604.service-now.com
67
239
 
68
240
  Find your instance URL in your browser's address bar when logged into ServiceNow.`);
69
- }
70
241
  }
71
242
  }
72
243
 
@@ -129,6 +300,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
129
300
  instance_url: instanceURL,
130
301
  auth_method: argv.password ? 'basic' : 'oauth',
131
302
  username: username || undefined,
303
+ read_only: argv['read-only'] || undefined,
304
+ skip_confirmations: argv['skip-confirmations'] || undefined,
132
305
  };
133
306
 
134
307
  // Re-save OAuth credentials with username now that we have it,
@@ -169,11 +342,11 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
169
342
  })
170
343
  .command({
171
344
  command: 'logout [instance]',
172
- describe: 'Remove stored OAuth credentials',
345
+ describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
173
346
  handler: wrap(async (argv, app) => {
174
347
  let instanceURL;
175
348
  if (argv.instance) {
176
- instanceURL = normalizeInstanceURL(argv.instance);
349
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
177
350
  } else {
178
351
  instanceURL = getEffectiveInstance(app.config);
179
352
  if (!instanceURL) {
@@ -240,6 +413,8 @@ Examples:
240
413
  days_since_last_seen: daysSinceLastSeen,
241
414
  stale: daysSinceLastSeen > 7,
242
415
  default: instance === defaultInstance,
416
+ read_only: profile.read_only || false,
417
+ skip_confirmations: profile.skip_confirmations || false,
243
418
  });
244
419
  }
245
420
 
@@ -273,13 +448,7 @@ Examples:
273
448
  handler: wrap(async (argv, app) => {
274
449
  let instanceURL;
275
450
  if (argv.instance) {
276
- // Check if it's a profile name or URL
277
- const profiles = app.config.profiles || {};
278
- if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
279
- instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
280
- } else {
281
- instanceURL = normalizeInstanceURL(argv.instance);
282
- }
451
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
283
452
  } else {
284
453
  instanceURL = getEffectiveInstance(app.config);
285
454
  if (!instanceURL) {
@@ -300,17 +469,111 @@ Examples:
300
469
  expires_at: refreshed.expires_at,
301
470
  }, { summary: `✓ Token refreshed for ${instanceURL}` });
302
471
  }),
472
+ })
473
+ .command({
474
+ command: 'switch [name]',
475
+ describe: 'Switch active profile (gh: auth switch)',
476
+ builder: (sub) => sub
477
+ .positional('name', {
478
+ describe: 'Profile name to activate',
479
+ type: 'string',
480
+ }),
481
+ handler: wrap(async (argv, app) => {
482
+ let name = argv.name;
483
+ if (!name) {
484
+ const profileNames = Object.keys(app.config.profiles || {});
485
+ if (profileNames.length === 0) {
486
+ throw errUsage('No profiles configured. Run "jsn auth login" first.');
487
+ }
488
+ if (profileNames.length > 1 && app.isInteractive()) {
489
+ const { search } = await import('@inquirer/prompts');
490
+ const choices = profileNames.map(profileName => ({
491
+ name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
492
+ value: profileName,
493
+ }));
494
+ const selected = await search({
495
+ message: 'Select a profile:',
496
+ source: (input) => {
497
+ const filter = (input || '').toLowerCase();
498
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
499
+ },
500
+ });
501
+ name = selected;
502
+ } else {
503
+ name = profileNames[0];
504
+ }
505
+ }
506
+ await setActiveProfile(app.config, name);
507
+ app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
508
+ }),
509
+ })
510
+ .command({
511
+ command: 'remove [name]',
512
+ describe: 'Delete an instance entry and its stored credentials',
513
+ builder: (sub) => sub
514
+ .positional('name', {
515
+ describe: 'Profile name to delete',
516
+ type: 'string',
517
+ }),
518
+ handler: wrap(async (argv, app) => {
519
+ let name = argv.name;
520
+ if (!name) {
521
+ const profileNames = Object.keys(app.config.profiles || {});
522
+ if (profileNames.length === 0) {
523
+ throw errUsage('No profiles configured. Nothing to remove.');
524
+ }
525
+ if (profileNames.length > 1 && app.isInteractive()) {
526
+ const { search } = await import('@inquirer/prompts');
527
+ const choices = profileNames.map(profileName => ({
528
+ name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
529
+ value: profileName,
530
+ }));
531
+ const selected = await search({
532
+ message: 'Remove which profile?',
533
+ source: (input) => {
534
+ const filter = (input || '').toLowerCase();
535
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
536
+ },
537
+ });
538
+ name = selected;
539
+ } else {
540
+ name = profileNames[0];
541
+ }
542
+ }
543
+ if (!app.config.profiles[name]) {
544
+ throw new Error(`Profile not found: ${name}`);
545
+ }
546
+ const instance = app.config.profiles[name].instance_url;
547
+ delete app.config.profiles[name];
548
+ if (app.config.defaultProfile === name) app.config.defaultProfile = '';
549
+ if (app.config.activeProfile === name) app.config.activeProfile = '';
550
+ saveConfig(app.config);
551
+
552
+ // Only clear credentials if no other profile uses this instance URL
553
+ const stillInUse = instance && Object.values(app.config.profiles || {})
554
+ .some(p => p.instance_url === instance);
555
+ if (instance && !stillInUse) {
556
+ app.auth.logout(instance);
557
+ }
558
+
559
+ app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
560
+ }),
303
561
  });
304
562
  },
305
563
  handler: () => {
306
- console.log('Manage OAuth authentication for ServiceNow instances.');
564
+ console.log('Manage authentication for ServiceNow instances.');
307
565
  console.log('');
308
566
  console.log('Available subcommands:');
309
- console.log(' login <url> Login with OAuth (supports --print-url, --code for CI/CD)');
310
- console.log(' status Show auth status for the active profile');
311
- console.log(' refresh Refresh the access token');
567
+ console.log(' login <url|name> Log in / add an instance (picker when run bare)');
568
+ console.log(' status Show auth status for all profiles');
569
+ console.log(' switch <name> Switch the active profile');
570
+ console.log(' refresh Refresh the access token');
571
+ console.log(' logout Clear credentials (keeps the instance entry)');
572
+ console.log(' remove <name> Delete an instance entry and its credentials');
312
573
  console.log('');
313
574
  console.log('Run "jsn auth <command> --help" for details.');
575
+ console.log('');
576
+ console.log('Tip: "jsn auth login" is the only command you need to add an instance.');
314
577
  },
315
578
  };
316
579
  }
@@ -1,4 +1,4 @@
1
- import { getStringField, interactiveList } from '../helpers.js';
1
+ import { getStringField, interactiveList, assertSafeExactMatch } from '../helpers.js';
2
2
 
3
3
  export function catalogCmd(wrap) {
4
4
  return {
@@ -16,7 +16,7 @@ export function catalogCmd(wrap) {
16
16
  .option('short-description', { alias: 'd', type: 'string' })
17
17
  .option('category', { alias: 'c', type: 'string' })
18
18
  .option('variable', { alias: 'v', type: 'array', describe: 'Variable: "name:type:label"' })
19
- .option('variables', { type: 'string', describe: 'JSON for submit_produce: {\"key\":\"value\"}' }),
19
+ .option('variables', { type: 'string', describe: 'JSON for submit_produce: {"key":"value"}' }),
20
20
  handler: wrap(async (argv, app) => {
21
21
  app.requireInstance();
22
22
 
@@ -150,6 +150,7 @@ export function catalogCmd(wrap) {
150
150
  app.requireInstance();
151
151
  let id = argv.id;
152
152
  if (!/^[a-f0-9]{32}$/i.test(id)) {
153
+ assertSafeExactMatch(id);
153
154
  const p = new URLSearchParams();
154
155
  p.set('sysparm_query', `name=${id}`);
155
156
  p.set('sysparm_limit', '1');
@@ -215,8 +216,6 @@ async function showItem(app, sysID) {
215
216
  const wfName = getStringField(item, 'workflow') || '';
216
217
  const wfID = item.workflow?.value || '';
217
218
  const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
218
- const planID = item.delivery_plan?.value || item.execution_plan?.value || '';
219
-
220
219
  app.ok({
221
220
  name: getStringField(item, 'name'),
222
221
  short_description: getStringField(item, 'short_description'),
@@ -1,6 +1,6 @@
1
1
  // Generic dev subcommand builder for table-based CRUD
2
2
 
3
- import { formatRecordForDisplay, getStringField, isHexString, parseDataArg } from '../../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
4
4
  import { getCurrentUser, getCurrentApplication } from '../../context.js';
5
5
  import { paginatedSearch } from '../../paginated-search.js';
6
6
  import { isTTY, FormatAuto } from '../../output.js';
@@ -81,7 +81,6 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
81
81
  const readOnly = opts.readOnly || false;
82
82
  const scopeValidation = opts.scopeValidation || false;
83
83
  const extraQuery = opts.extraQuery || '';
84
- const formatLabel = opts.formatLabel || null;
85
84
  const showSummary = opts.showSummary || ((record, id) => `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${getStringField(record, 'name') || id}`);
86
85
  const showBreadcrumbs = opts.showBreadcrumbs || ((record, id) => {
87
86
  const crumbs = [
@@ -120,7 +119,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
120
119
  totalCount = await app.sdk.aggregateCount(table, extraQuery || '');
121
120
  } catch { /* non-critical */ }
122
121
 
123
- const source = async (term, offset, { signal } = {}) => {
122
+ const source = async (term, offset, { signal: _signal } = {}) => {
124
123
  const params = new URLSearchParams();
125
124
  params.set('sysparm_limit', String(limit));
126
125
  params.set('sysparm_display_value', 'all');
@@ -150,6 +149,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
150
149
 
151
150
  if (selectedName) {
152
151
  const recordName = typeof selectedName === 'object' ? selectedName.value : selectedName;
152
+ assertSafeExactMatch(recordName);
153
153
  const showParams = new URLSearchParams();
154
154
  showParams.set('sysparm_query', `name=${recordName}`);
155
155
  showParams.set('sysparm_display_value', 'all');
@@ -202,6 +202,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
202
202
  handler: wrap(async (argv, app) => {
203
203
  const id = argv.identifier;
204
204
  const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
205
+ assertSafeExactMatch(id);
205
206
  const params = new URLSearchParams();
206
207
  params.set('sysparm_query', `${queryField}=${id}`);
207
208
  params.set('sysparm_limit', '1');
@@ -259,6 +260,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
259
260
  handler: wrap(async (argv, app) => {
260
261
  const id = argv.identifier;
261
262
  const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
263
+ assertSafeExactMatch(id);
262
264
  const findParams = new URLSearchParams();
263
265
  findParams.set('sysparm_query', `${queryField}=${id}`);
264
266
  findParams.set('sysparm_limit', '1');
@@ -289,6 +291,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
289
291
  handler: wrap(async (argv, app) => {
290
292
  const id = argv.identifier;
291
293
  const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
294
+ assertSafeExactMatch(id);
292
295
  const findParams = new URLSearchParams();
293
296
  findParams.set('sysparm_query', `${queryField}=${id}`);
294
297
  findParams.set('sysparm_limit', '1');
@@ -308,17 +311,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
308
311
  }
309
312
  }
310
313
 
311
- if (!argv.force && process.stdout.isTTY && process.stdin.isTTY) {
312
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
313
- const answer = await new Promise((resolve) => {
314
- rl.question(`Delete ${singular} '${recordName}'? (y/N): `, resolve);
315
- });
316
- rl.close();
317
- const response = answer.trim().toLowerCase();
318
- if (response !== 'y' && response !== 'yes') {
319
- throw new Error('Deletion cancelled');
320
- }
321
- }
314
+ await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
322
315
 
323
316
  await app.sdk.delete(table, sysID);
324
317
  app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
@@ -302,6 +302,11 @@ function inferFlowVersion(inspection) {
302
302
  }
303
303
 
304
304
  async function formatFlowStructure(inspection, ctx) {
305
+ const names = await collectCatalogVarNames(inspection, ctx);
306
+ if (names.size > 0) {
307
+ if (!ctx.catalogVarNames) ctx.catalogVarNames = new Map();
308
+ for (const [k, v] of names) ctx.catalogVarNames.set(k, v);
309
+ }
305
310
  const payload = inspection.payload;
306
311
  if (Object.keys(payload).length > 0) {
307
312
  return formatFlowStructureFromPayload(payload, ctx);
@@ -309,6 +314,104 @@ async function formatFlowStructure(inspection, ctx) {
309
314
  return formatFlowStructureFallback(inspection);
310
315
  }
311
316
 
317
+ /**
318
+ * ServiceNow serializes catalog variable selections in action inputs as
319
+ * comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
320
+ * Catalog Task steps). The payload carries the source table on the parameter
321
+ * attributes, but the value itself also names it in each token. Resolve those
322
+ * sys_ids to readable labels (question_text, falling back to name) with one
323
+ * batched Table API query per distinct table.
324
+ */
325
+ async function collectCatalogVarNames(inspection, ctx) {
326
+ const names = new Map();
327
+ if (!ctx?.sdk) return names;
328
+
329
+ const refs = new Map(); // table -> Set<sys_id>
330
+ const TOKEN_RE = /([0-9a-f]{32}):([a-z_]+)/g;
331
+
332
+ function addRefsFromValue(value) {
333
+ if (!value) return;
334
+ for (const m of String(value).matchAll(TOKEN_RE)) {
335
+ const [, id, table] = m;
336
+ if (!refs.has(table)) refs.set(table, new Set());
337
+ refs.get(table).add(id);
338
+ }
339
+ }
340
+
341
+ function walk(items) {
342
+ if (!Array.isArray(items)) return;
343
+ for (const item of items) {
344
+ if (!item || typeof item !== 'object') continue;
345
+ if (Array.isArray(item.inputs)) {
346
+ for (const input of item.inputs) {
347
+ if (!input || typeof input !== 'object') continue;
348
+ addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
349
+ }
350
+ }
351
+ if (Array.isArray(item.flowBlock)) walk(item.flowBlock);
352
+ }
353
+ }
354
+
355
+ const payload = inspection?.payload;
356
+ if (payload && typeof payload === 'object') {
357
+ walk(payload.actionInstances);
358
+ if (Array.isArray(payload.flowLogicInstances)) {
359
+ for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
360
+ }
361
+ }
362
+ // Fallback table sources may also carry inputs (e.g. decoded logic values)
363
+ walk(inspection?.actionInstances);
364
+ if (Array.isArray(inspection?.flowLogicInstances)) {
365
+ for (const logic of inspection.flowLogicInstances) {
366
+ if (logic?._decodedValues?.inputs) {
367
+ for (const input of logic._decodedValues.inputs) {
368
+ if (!input || typeof input !== 'object') continue;
369
+ addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ for (const [table, ids] of refs) {
376
+ const idList = [...ids];
377
+ for (let i = 0; i < idList.length; i += 100) {
378
+ const chunk = idList.slice(i, i + 100);
379
+ try {
380
+ const params = new URLSearchParams();
381
+ params.set('sysparm_query', `sys_idIN${chunk.join(',')}`);
382
+ params.set('sysparm_fields', 'sys_id,name,question_text');
383
+ params.set('sysparm_display_value', 'all');
384
+ params.set('sysparm_limit', String(chunk.length));
385
+ const records = await ctx.sdk.list(table, params);
386
+ for (const r of records) {
387
+ const id = getStringField(r, 'sys_id');
388
+ const label = firstNonEmpty(getStringField(r, 'question_text'), getStringField(r, 'name'));
389
+ if (id && label) names.set(id, label);
390
+ }
391
+ } catch {
392
+ // resolution failed (no access, deleted record) — leave raw sys_ids
393
+ }
394
+ }
395
+ }
396
+
397
+ return names;
398
+ }
399
+
400
+ /**
401
+ * Replace catalog variable "sys_id:table" pairs with readable labels when the
402
+ * resolver map has them. Returns null when the value isn't a catalog-variable
403
+ * list (so callers keep rendering it raw).
404
+ */
405
+ function resolveCatalogVarValue(value, catalogVarNames) {
406
+ if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
407
+ const trimmed = String(value).trim();
408
+ if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
409
+ return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
410
+ const label = catalogVarNames.get(id);
411
+ return label || m;
412
+ });
413
+ }
414
+
312
415
  async function formatFlowStructureFromPayload(payload, ctx) {
313
416
  const childUIDs = new Set();
314
417
 
@@ -497,11 +600,11 @@ async function formatStepLine(stepNum, pad, step, ctx) {
497
600
  case 'subflow':
498
601
  return formatSubFlowStep(stepNum, pad, step.data, ctx);
499
602
  default:
500
- return formatActionStep(stepNum, pad, step.data);
603
+ return formatActionStep(stepNum, pad, step.data, ctx);
501
604
  }
502
605
  }
503
606
 
504
- export function formatActionStep(stepNum, pad, action) {
607
+ export function formatActionStep(stepNum, pad, action, ctx) {
505
608
  const lines = [];
506
609
  let actionName = firstNonEmpty(
507
610
  getNestedString(action, 'actionType', 'fName'),
@@ -549,6 +652,11 @@ export function formatActionStep(stepNum, pad, action) {
549
652
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
550
653
  if (!inputValue) continue;
551
654
 
655
+ // Catalog variable selections arrive as "sys_id:table" pairs; resolve
656
+ // them to readable names when we have a resolver map (built per flow).
657
+ const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
658
+ if (resolvedCatalog) inputValue = resolvedCatalog;
659
+
552
660
  let label = inputName;
553
661
  if (raw.parameter && typeof raw.parameter === 'object') {
554
662
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);