@jacebenson/jsn 4.0.0 → 4.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "chalk": "^5.4.1",
33
33
  "cli-table3": "^0.6.5",
34
34
  "gray-matter": "^4.0.3",
35
+ "turndown": "^7.2.4",
35
36
  "yargs": "^17.7.2"
36
37
  },
37
38
  "devDependencies": {
@@ -14,7 +14,7 @@ compatibility: |
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
  metadata:
16
16
  author: jacebenson
17
- version: "3.1.1"
17
+ version: "4.0.0"
18
18
  repository: https://github.com/jacebenson/jsn
19
19
  ---
20
20
 
@@ -69,7 +69,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
69
69
  | **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
70
70
  | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
71
71
  | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
72
- | **Config** | `setup`, `auth`, `profiles`, `updatesets`, `scopes` |
72
+ | **Config** | `setup`, `auth`, `updatesets`, `scopes` |
73
73
  | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
74
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
75
75
 
package/src/auth.js CHANGED
@@ -7,19 +7,34 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import crypto from 'node:crypto';
10
- import { execSync } from 'node:child_process';
10
+ import { execFileSync } from 'node:child_process';
11
11
  import { password as passwordPrompt } from '@inquirer/prompts';
12
12
  import { globalConfigDir, normalizeInstanceURL } from './config.js';
13
13
  import { errAuth } from './errors.js';
14
14
 
15
15
  // ─── Credential key helpers ───
16
16
 
17
+ /**
18
+ * Sanitize an instance URL into a filesystem/arg-safe key part.
19
+ *
20
+ * Whitelist approach (issue #143 findings #2/#3): first map the `://`
21
+ * protocol separator to `_` exactly as the Go version's encoding does
22
+ * (so existing keyring keys like `https_dev437538.service-now.com` keep
23
+ * matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
24
+ * stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
25
+ * spaces, and backslashes — which broke out of double-quoted shell args
26
+ * in keyring calls and, on Windows, out of path.join() credential dirs.
27
+ */
28
+ export function sanitizeKeyPart(instance) {
29
+ return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
30
+ }
31
+
17
32
  /**
18
33
  * Build a compound key for credential storage: <username>@<instance>
19
34
  * When username is omitted (legacy), just the normalized instance URL is used.
20
35
  */
21
36
  function credKey(instance, username) {
22
- const normalized = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_');
37
+ const normalized = sanitizeKeyPart(instance);
23
38
  if (!username) return normalized;
24
39
  return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
25
40
  }
@@ -29,7 +44,7 @@ function credKey(instance, username) {
29
44
  function pkceStatePath(instance) {
30
45
  const dir = path.join(globalConfigDir(), 'pkce');
31
46
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
- const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
47
+ const filename = sanitizeKeyPart(instance) + '.json';
33
48
  return path.join(dir, filename);
34
49
  }
35
50
 
@@ -133,8 +148,11 @@ function buildAuthURL(instanceURL, clientID, pkce) {
133
148
 
134
149
  function keyringLookup(key) {
135
150
  try {
136
- const result = execSync(
137
- `secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
151
+ // execFileSync with an arg array — no shell, so `key` can never
152
+ // break out into command injection (issue #143 finding #2).
153
+ const result = execFileSync(
154
+ 'secret-tool',
155
+ ['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
138
156
  { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
139
157
  );
140
158
  const trimmed = result.trim();
@@ -157,9 +175,10 @@ function keyringLookup(key) {
157
175
 
158
176
  function keyringStore(key, creds) {
159
177
  try {
160
- execSync(
161
- `secret-tool store --label="Password for '${key}' on '${KEYRING_SERVICE}'" ` +
162
- `${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
178
+ execFileSync(
179
+ 'secret-tool',
180
+ ['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
181
+ KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
163
182
  { stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
164
183
  );
165
184
  return true;
@@ -170,8 +189,9 @@ function keyringStore(key, creds) {
170
189
 
171
190
  function keyringDelete(key) {
172
191
  try {
173
- execSync(
174
- `secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
192
+ execFileSync(
193
+ 'secret-tool',
194
+ ['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
175
195
  { stdio: 'ignore' }
176
196
  );
177
197
  } catch {
package/src/cli.js CHANGED
@@ -5,10 +5,11 @@ 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, getEffectiveInstance, getActiveProfile, globalConfigDir } from './config.js';
8
+ import { loadConfig, getActiveProfile, globalConfigDir } from './config.js';
9
9
  import { App } from './app.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand } from './mutations.js';
12
+ import { guardExit } from './errors.js';
12
13
 
13
14
  // Command modules
14
15
  import { setupCmd } from './commands/setup.js';
@@ -94,14 +95,6 @@ export function buildCLI() {
94
95
  const cfg = loadConfig();
95
96
  const app = new App(cfg);
96
97
 
97
- // Resolve instance once
98
- let effectiveInstance;
99
- try {
100
- effectiveInstance = getEffectiveInstance(cfg);
101
- } catch {
102
- effectiveInstance = null;
103
- }
104
-
105
98
  return yargs(hideBin(process.argv))
106
99
  .scriptName('jsn')
107
100
  .usage('Usage: jsn <command> [options]')
@@ -161,8 +154,11 @@ export function buildCLI() {
161
154
  if (argv.profile) {
162
155
  const profile = (cfg.profiles || {})[argv.profile];
163
156
  if (!profile) {
164
- process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
165
- process.exit(1);
157
+ guardExit(argv, {
158
+ code: 'unknown_profile',
159
+ message: `Unknown profile "${argv.profile}"`,
160
+ hint: 'Run "jsn auth status" to list profiles.',
161
+ });
166
162
  }
167
163
  const url = profile.instance_url;
168
164
  app._overrideInstance = url;
@@ -193,18 +189,16 @@ export function buildCLI() {
193
189
  // requireInstance throws errUsage; format it cleanly (the yargs
194
190
  // fail handler re-throws middleware errors, which would print a
195
191
  // raw stack trace).
196
- process.stderr.write(`Error: ${err.message}\n`);
197
- process.exit(1);
192
+ guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
198
193
  }
199
194
  const profile = getActiveProfile(cfg);
200
195
  if (profile?.read_only === true) {
201
196
  const name = cfg.activeProfile || cfg.defaultProfile;
202
- process.stderr.write(
203
- `Error: Profile "${name}" is read-only. Mutations are blocked.\n\n` +
204
- 'Switch to a write-enabled profile first:\n' +
205
- ` jsn auth switch <name>\n`
206
- );
207
- process.exit(1);
197
+ guardExit(argv, {
198
+ code: 'read_only',
199
+ message: `Profile "${name}" is read-only. Mutations are blocked.`,
200
+ hint: 'Switch to a write-enabled profile first:\n jsn auth switch <name>',
201
+ });
208
202
  }
209
203
  }
210
204
  },
@@ -1,7 +1,7 @@
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 } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
5
5
  import { search } from '@inquirer/prompts';
6
6
  import { isTTY, FormatAuto } from '../output.js';
7
7
 
@@ -86,6 +86,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
86
86
  }
87
87
 
88
88
  if (selectedNumber) {
89
+ assertSafeExactMatch(selectedNumber);
89
90
  const showParams = new URLSearchParams();
90
91
  showParams.set('sysparm_query', `number=${selectedNumber}`);
91
92
  showParams.set('sysparm_display_value', 'all');
@@ -161,6 +162,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
161
162
  describe: `Show a specific ${displayName}`,
162
163
  handler: wrap(async (argv, app) => {
163
164
  const number = argv.number;
165
+ assertSafeExactMatch(number);
164
166
  const params = new URLSearchParams();
165
167
  params.set('sysparm_query', `number=${number}`);
166
168
  params.set('sysparm_display_value', 'all');
@@ -216,6 +218,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
216
218
  .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
217
219
  handler: wrap(async (argv, app) => {
218
220
  const number = argv.number;
221
+ assertSafeExactMatch(number);
219
222
  const recordData = JSON.parse(argv.data);
220
223
  const findParams = new URLSearchParams();
221
224
  findParams.set('sysparm_query', `number=${number}`);
@@ -237,8 +240,11 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
237
240
  .command({
238
241
  command: 'delete <number>',
239
242
  describe: `Delete a ${displayName}`,
243
+ builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
240
244
  handler: wrap(async (argv, app) => {
241
245
  const number = argv.number;
246
+ await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
247
+ assertSafeExactMatch(number);
242
248
  const findParams = new URLSearchParams();
243
249
  findParams.set('sysparm_query', `number=${number}`);
244
250
  findParams.set('sysparm_limit', '1');
@@ -57,6 +57,9 @@ export async function loginWizard(app, argv = {}) {
57
57
  if (argv['read-only']) {
58
58
  profile.read_only = true;
59
59
  }
60
+ if (argv['skip-confirmations']) {
61
+ profile.skip_confirmations = true;
62
+ }
60
63
 
61
64
  const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
62
65
  const useBasic = authMethod.toLowerCase().startsWith('b');
@@ -192,6 +195,11 @@ export function authCmd(wrap) {
192
195
  describe: 'Mark the created profile as read-only (blocks mutation commands)',
193
196
  type: 'boolean',
194
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,
195
203
  }),
196
204
  handler: wrap(async (argv, app) => {
197
205
  let instanceURL;
@@ -293,6 +301,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
293
301
  auth_method: argv.password ? 'basic' : 'oauth',
294
302
  username: username || undefined,
295
303
  read_only: argv['read-only'] || undefined,
304
+ skip_confirmations: argv['skip-confirmations'] || undefined,
296
305
  };
297
306
 
298
307
  // Re-save OAuth credentials with username now that we have it,
@@ -405,6 +414,7 @@ Examples:
405
414
  stale: daysSinceLastSeen > 7,
406
415
  default: instance === defaultInstance,
407
416
  read_only: profile.read_only || false,
417
+ skip_confirmations: profile.skip_confirmations || false,
408
418
  });
409
419
  }
410
420
 
@@ -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,16 @@ 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
+ }
310
+ const labelCache = buildLabelCache(inspection.payload);
311
+ if (labelCache.size > 0) {
312
+ if (!ctx.labelCache) ctx.labelCache = new Map();
313
+ for (const [k, v] of labelCache) ctx.labelCache.set(k, v);
314
+ }
305
315
  const payload = inspection.payload;
306
316
  if (Object.keys(payload).length > 0) {
307
317
  return formatFlowStructureFromPayload(payload, ctx);
@@ -309,6 +319,146 @@ async function formatFlowStructure(inspection, ctx) {
309
319
  return formatFlowStructureFallback(inspection);
310
320
  }
311
321
 
322
+ /**
323
+ * The ProcessFlow payload carries a label cache (also serialized as
324
+ * labelCacheAsJsonString) mapping data-pill names like "<step-uid>.<var>"
325
+ * to the human labels the Flow Designer UI shows, e.g.
326
+ * "1 - Get Catalog Variables➛session_title". Build a Map so guid-prefixed
327
+ * pills can be rendered readably.
328
+ */
329
+ function buildLabelCache(payload) {
330
+ const cache = new Map();
331
+ if (!payload || typeof payload !== 'object') return cache;
332
+ let entries = payload.label_cache;
333
+ if (!Array.isArray(entries)) {
334
+ try {
335
+ entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
336
+ } catch {
337
+ entries = [];
338
+ }
339
+ }
340
+ for (const e of entries) {
341
+ if (!e || typeof e !== 'object') continue;
342
+ const name = getStringField(e, 'name');
343
+ const label = getStringField(e, 'label');
344
+ if (name && label) cache.set(name, label);
345
+ }
346
+ return cache;
347
+ }
348
+
349
+ const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
350
+
351
+ /**
352
+ * Replace opaque guid-prefixed pills ({{<32-hex-guid>.<var>}}) with their
353
+ * human label from the payload's label cache. Readable step refs like
354
+ * {{Created_1.table_name}} are NOT matched and stay raw.
355
+ */
356
+ function resolveGuidPills(value, labelCache) {
357
+ if (!labelCache || labelCache.size === 0 || !value) return value;
358
+ return String(value).replace(GUID_PILL_RE, (m, guid, name) => {
359
+ const label = labelCache.get(`${guid}.${name}`);
360
+ return label ? `{{${label}}}` : m;
361
+ });
362
+ }
363
+
364
+ /**
365
+ * ServiceNow serializes catalog variable selections in action inputs as
366
+ * comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
367
+ * Catalog Task steps). The payload carries the source table on the parameter
368
+ * attributes, but the value itself also names it in each token. Resolve those
369
+ * sys_ids to readable labels (question_text, falling back to name) with one
370
+ * batched Table API query per distinct table.
371
+ */
372
+ async function collectCatalogVarNames(inspection, ctx) {
373
+ const names = new Map();
374
+ if (!ctx?.sdk) return names;
375
+
376
+ const refs = new Map(); // table -> Set<sys_id>
377
+ const TOKEN_RE = /([0-9a-f]{32}):([a-z_]+)/g;
378
+
379
+ function addRefsFromValue(value) {
380
+ if (!value) return;
381
+ for (const m of String(value).matchAll(TOKEN_RE)) {
382
+ const [, id, table] = m;
383
+ if (!refs.has(table)) refs.set(table, new Set());
384
+ refs.get(table).add(id);
385
+ }
386
+ }
387
+
388
+ function walk(items) {
389
+ if (!Array.isArray(items)) return;
390
+ for (const item of items) {
391
+ if (!item || typeof item !== 'object') continue;
392
+ if (Array.isArray(item.inputs)) {
393
+ for (const input of item.inputs) {
394
+ if (!input || typeof input !== 'object') continue;
395
+ addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
396
+ }
397
+ }
398
+ if (Array.isArray(item.flowBlock)) walk(item.flowBlock);
399
+ }
400
+ }
401
+
402
+ const payload = inspection?.payload;
403
+ if (payload && typeof payload === 'object') {
404
+ walk(payload.actionInstances);
405
+ if (Array.isArray(payload.flowLogicInstances)) {
406
+ for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
407
+ }
408
+ }
409
+ // Fallback table sources may also carry inputs (e.g. decoded logic values)
410
+ walk(inspection?.actionInstances);
411
+ if (Array.isArray(inspection?.flowLogicInstances)) {
412
+ for (const logic of inspection.flowLogicInstances) {
413
+ if (logic?._decodedValues?.inputs) {
414
+ for (const input of logic._decodedValues.inputs) {
415
+ if (!input || typeof input !== 'object') continue;
416
+ addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ for (const [table, ids] of refs) {
423
+ const idList = [...ids];
424
+ for (let i = 0; i < idList.length; i += 100) {
425
+ const chunk = idList.slice(i, i + 100);
426
+ try {
427
+ const params = new URLSearchParams();
428
+ params.set('sysparm_query', `sys_idIN${chunk.join(',')}`);
429
+ params.set('sysparm_fields', 'sys_id,name,question_text');
430
+ params.set('sysparm_display_value', 'all');
431
+ params.set('sysparm_limit', String(chunk.length));
432
+ const records = await ctx.sdk.list(table, params);
433
+ for (const r of records) {
434
+ const id = getStringField(r, 'sys_id');
435
+ const label = firstNonEmpty(getStringField(r, 'question_text'), getStringField(r, 'name'));
436
+ if (id && label) names.set(id, label);
437
+ }
438
+ } catch {
439
+ // resolution failed (no access, deleted record) — leave raw sys_ids
440
+ }
441
+ }
442
+ }
443
+
444
+ return names;
445
+ }
446
+
447
+ /**
448
+ * Replace catalog variable "sys_id:table" pairs with readable labels when the
449
+ * resolver map has them. Returns null when the value isn't a catalog-variable
450
+ * list (so callers keep rendering it raw).
451
+ */
452
+ function resolveCatalogVarValue(value, catalogVarNames) {
453
+ if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
454
+ const trimmed = String(value).trim();
455
+ if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
456
+ return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
457
+ const label = catalogVarNames.get(id);
458
+ return label || m;
459
+ });
460
+ }
461
+
312
462
  async function formatFlowStructureFromPayload(payload, ctx) {
313
463
  const childUIDs = new Set();
314
464
 
@@ -497,11 +647,11 @@ async function formatStepLine(stepNum, pad, step, ctx) {
497
647
  case 'subflow':
498
648
  return formatSubFlowStep(stepNum, pad, step.data, ctx);
499
649
  default:
500
- return formatActionStep(stepNum, pad, step.data);
650
+ return formatActionStep(stepNum, pad, step.data, ctx);
501
651
  }
502
652
  }
503
653
 
504
- export function formatActionStep(stepNum, pad, action) {
654
+ export function formatActionStep(stepNum, pad, action, ctx) {
505
655
  const lines = [];
506
656
  let actionName = firstNonEmpty(
507
657
  getNestedString(action, 'actionType', 'fName'),
@@ -549,6 +699,15 @@ export function formatActionStep(stepNum, pad, action) {
549
699
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
550
700
  if (!inputValue) continue;
551
701
 
702
+ // Catalog variable selections arrive as "sys_id:table" pairs; resolve
703
+ // them to readable names when we have a resolver map (built per flow).
704
+ const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
705
+ if (resolvedCatalog) inputValue = resolvedCatalog;
706
+
707
+ // Opaque guid-prefixed pills ({{<32-hex>.<var>}}) get their human
708
+ // label from the payload's label cache. Readable step refs stay raw.
709
+ inputValue = resolveGuidPills(inputValue, ctx?.labelCache);
710
+
552
711
  let label = inputName;
553
712
  if (raw.parameter && typeof raw.parameter === 'object') {
554
713
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
@@ -22,15 +22,6 @@ function getIntValue(record, key) {
22
22
  return 0;
23
23
  }
24
24
 
25
- function getBoolValue(record, key) {
26
- if (!record || typeof record !== 'object') return false;
27
- const val = record[key];
28
- if (typeof val === 'boolean') return val;
29
- if (typeof val === 'string') return val === 'true';
30
- if (typeof val === 'object' && val.value != null) return String(val.value) === 'true';
31
- return false;
32
- }
33
-
34
25
  async function showForm(app, table, viewName) {
35
26
  // 1) Look up the view
36
27
  const viewParams = new URLSearchParams();
@@ -88,8 +79,6 @@ async function showForm(app, table, viewName) {
88
79
 
89
80
  sectionsOut.sort((a, b) => a.position - b.position);
90
81
 
91
- const totalElements = sectionsOut.reduce((sum, s) => sum + s.elements.length, 0);
92
-
93
82
  // Build formatted output
94
83
  const lines = [];
95
84
  lines.push(`Form: ${table} → ${viewName}`);
@@ -20,7 +20,7 @@ export const importCmd = (wrap) => buildDevCmd('import', 'sys_import_set', ['imp
20
20
  const rows = await app.sdk.list('sys_import_set_row', rp);
21
21
  console.log(` Import rows: ${rows.length}`);
22
22
  console.log(` → jsn records list --table sys_import_set_row --query "sys_import_set=${sysID}" --limit 200`);
23
- } catch (err) {
23
+ } catch {
24
24
  console.log(` Import rows: (unavailable)`);
25
25
  }
26
26
  },
@@ -17,7 +17,7 @@ export function listsCmd(wrap) {
17
17
  handler: wrap(async (argv, app) => {
18
18
  app.requireInstance();
19
19
  let table = argv.table;
20
- let viewSysID = null;
20
+ let viewSysID;
21
21
 
22
22
  // If no table, pick one interactively
23
23
  if (!table) {
@@ -76,7 +76,7 @@ export function listsCmd(wrap) {
76
76
  const table = argv.table;
77
77
 
78
78
  // Resolve view sys_id
79
- let viewSysID = '';
79
+ let viewSysID;
80
80
  const vp = new URLSearchParams();
81
81
  vp.set('sysparm_limit', '1'); vp.set('sysparm_fields', 'sys_id');
82
82
  vp.set('sysparm_query', `name=${argv.view}^name=${table}`);
@@ -0,0 +1,18 @@
1
+ {
2
+ "sources": [
3
+ {
4
+ "name": "maik-skoddow-knowledge-sources",
5
+ "author": "Maik Skoddow",
6
+ "author_url": "https://www.servicenow.com/community/user/viewprofilepage/user-id/116698",
7
+ "url": "https://www.servicenow.com/community/training-and-certifications/knowledge-sources-to-go/ta-p/2310130",
8
+ "expand": true
9
+ },
10
+ {
11
+ "name": "mark-roethof-experiences",
12
+ "author": "Mark Roethof",
13
+ "author_url": "https://www.servicenow.com/community/user/viewprofilepage/user-id/201738",
14
+ "url": "https://www.servicenow.com/community/developer-blog/456-articles-blogs-videos-podcasts-share-projects-experiences/ba-p/2292127",
15
+ "expand": true
16
+ }
17
+ ]
18
+ }