@jacebenson/jsn 4.2.2 → 4.2.4

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/README.md CHANGED
@@ -53,6 +53,12 @@ jsn incidents create --description "Server down" --priority 1
53
53
  # Admin tasks
54
54
  jsn flows list # Interactive picker with pagination
55
55
  jsn flows show "Assign Task" # Full flow detail
56
+ jsn flows executions # All states, newest first
57
+ jsn flows executions --active # Only waiting/running/queued
58
+ jsn flows executions --since "2026-08-25 00:00:00" --until "2026-08-26 00:00:00"
59
+ jsn flows executions --summary # Server totals + sampled duration metrics by flow
60
+ jsn flows executions --limit 100 # Inspect more rows from the matching population
61
+ jsn flows executions --record <sys_id> # Executions for one source record
56
62
  jsn rules list --query "collection=incident"
57
63
  jsn updatesets set "My Feature"
58
64
 
@@ -78,6 +84,20 @@ jsn snippets run open-inc
78
84
  jsn logs follow --level error --tail 10
79
85
  ```
80
86
 
87
+ ### Flow execution fields
88
+
89
+ `jsn flows executions` reads `sys_flow_context` and returns both the raw row and a normalized `execution` object. JSN discovers the runtime columns from `sys_dictionary` first, then uses these mappings:
90
+
91
+ - `started`: `started`, `start_time`, `started_at`, then `sys_created_on`
92
+ - `ended`: `ended`, `end_time`, `ended_at`, then `completed_on`
93
+ - `duration_seconds`: stored `duration`, `run_time`, or `execution_duration`, otherwise `ended - started`
94
+ - `waiting_age_seconds`: current time minus `started` while the status is waiting, queued, paused, or pending
95
+ - `execution_age_seconds`: current time minus `started` while the status is running, executing, or processing
96
+ - `status`: `state`, then `status`, then `execution_state`
97
+ - `error`: `error`, `error_message`, `exception`, then `message`
98
+
99
+ The timestamp fields are instance-dependent. `sys_created_on` is a fallback for when the context row was created, not a claim that it is the true runtime start time.
100
+
81
101
  ## Commands
82
102
 
83
103
  Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
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": {
package/src/auth.js CHANGED
@@ -271,6 +271,25 @@ function askHidden(promptText) {
271
271
  return passwordPrompt({ message: promptText });
272
272
  }
273
273
 
274
+ /**
275
+ * Extract the browser session token and cookies from pasted DevTools headers
276
+ * or a "Copy as cURL" command. Only these two values are retained.
277
+ */
278
+ export function parseBrowserSessionInput(input) {
279
+ const text = String(input || '');
280
+ const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
281
+ const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
282
+ const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
283
+ const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
284
+ const token = tokenMatch?.[1]?.trim() || '';
285
+ const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
286
+ .trim()
287
+ .replace(/['"`]\s*(?:\\\s*)?$/, '');
288
+ if (!token) throw errAuth('No X-UserToken header found');
289
+ if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
290
+ return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
291
+ }
292
+
274
293
  export class AuthManager {
275
294
  /**
276
295
  * @param {object} identity — the resolved-identity provider. Production
@@ -366,6 +385,12 @@ export class AuthManager {
366
385
  if (getBasicAuthFromEnv(instance)) return true;
367
386
  const creds = loadCredentials(instance, this._activeUsername());
368
387
  if (!creds) return false;
388
+ if (creds.auth_method === 'basic') {
389
+ return Boolean(creds.username && creds.password);
390
+ }
391
+ if (creds.auth_method === 'gck') {
392
+ return Boolean(creds.access_token && creds.cookies);
393
+ }
369
394
  if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
370
395
  return !!creds.access_token;
371
396
  }
@@ -384,11 +409,11 @@ export class AuthManager {
384
409
  return this.getCredentialsFor(instance);
385
410
  }
386
411
 
387
- getCredentialsFor(instance) {
412
+ getCredentialsFor(instance, username = this._activeUsername()) {
388
413
  // Check basic auth from env vars first
389
414
  const basicCreds = getBasicAuthFromEnv(instance);
390
415
  if (basicCreds) return basicCreds;
391
- const creds = loadCredentials(instance, this._activeUsername());
416
+ const creds = loadCredentials(instance, username);
392
417
  if (!creds) {
393
418
  throw errAuth(`Not authenticated for ${instance}`);
394
419
  }
@@ -483,17 +508,19 @@ export class AuthManager {
483
508
  * Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
484
509
  * Stores the basic auth credentials so they persist across sessions.
485
510
  */
486
- async loginWithPassword(instanceURL) {
511
+ async loginWithPassword(instanceURL, username = this._activeUsername()) {
487
512
  instanceURL = normalizeInstanceURL(instanceURL);
488
- const creds = getBasicAuthFromEnv(instanceURL);
489
- if (!creds) {
513
+ const envCreds = getBasicAuthFromEnv(instanceURL);
514
+ const storedCreds = loadCredentials(instanceURL, username);
515
+ const creds = envCreds || storedCreds;
516
+ if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
490
517
  throw errAuth(
491
- `No basic auth credentials found in environment.\n\n` +
518
+ `No basic auth credentials found in environment or stored profile.\n\n` +
492
519
  `Set the following environment variables:\n` +
493
520
  ` SN_USERNAME=admin (or SN_${envVarName(instanceURL)}_USERNAME)\n` +
494
521
  ` SN_PASSWORD=<password> (or SN_${envVarName(instanceURL)}_PASSWORD)\n\n` +
495
522
  `Then run:\n` +
496
- ` jsn auth login --password ${instanceURL}`
523
+ ` jsn auth login --basic ${instanceURL}`
497
524
  );
498
525
  }
499
526
  saveCredentials(instanceURL, creds, creds.username);
@@ -505,6 +532,13 @@ export class AuthManager {
505
532
  * Complete login using an authorization code obtained from a prior buildAuthURL() call.
506
533
  * The PKCE state must have been saved by an earlier buildAuthURL() call.
507
534
  */
535
+ async loginWithGck(instanceURL, input, username = this._activeUsername()) {
536
+ instanceURL = normalizeInstanceURL(instanceURL);
537
+ const creds = parseBrowserSessionInput(input);
538
+ saveCredentials(instanceURL, { ...creds, username: username || undefined }, username || undefined);
539
+ return { ...creds, username: username || undefined };
540
+ }
541
+
508
542
  async loginWithCode(instanceURL, code) {
509
543
  instanceURL = normalizeInstanceURL(instanceURL);
510
544
  const clientID = getOAuthClientID();
package/src/cli.js CHANGED
@@ -29,6 +29,9 @@ import { groupsCmd } from './commands/groups.js';
29
29
  import { groupMembersCmd } from './commands/groupmembers.js';
30
30
  import { groupRolesCmd } from './commands/grouproles.js';
31
31
  import { ticketsCmd } from './commands/tickets.js';
32
+ import { transactionsCmd } from './commands/transactions.js';
33
+ import { instanceCmd } from './commands/instance.js';
34
+ import { platformCmd } from './commands/platform.js';
32
35
  import { codesearchCmd } from './commands/codesearch.js';
33
36
  import { versionCmd } from './commands/version.js';
34
37
  import { devCmd } from './commands/dev.js';
@@ -354,6 +357,9 @@ export function buildCLI() {
354
357
  // DATA
355
358
  .command(cmdbCmd(wrap))
356
359
  .command(recordsCmd(wrap))
360
+ .command(transactionsCmd(wrap))
361
+ .command(instanceCmd(wrap))
362
+ .command(platformCmd(wrap))
357
363
  .command(snippetsCmd(wrap))
358
364
  .command(ticketsCmd(wrap))
359
365
  .command(codesearchCmd(wrap))
@@ -82,6 +82,25 @@ export function resolveInstanceArg(arg, cfg) {
82
82
  return normalizeInstanceURL(`${trimmed}.service-now.com`);
83
83
  }
84
84
 
85
+ /**
86
+ * Select the login method. Explicit Basic Auth wins; otherwise reuse the
87
+ * method recorded on an existing profile. New instances remain OAuth by
88
+ * default.
89
+ */
90
+ export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '') {
91
+ if (argv.basic || argv.password) return true;
92
+ return Object.values(cfg.profiles || {}).some(profile =>
93
+ profile.instance_url === instanceURL && profile.auth_method === 'basic'
94
+ );
95
+ }
96
+
97
+ export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '') {
98
+ if (argv.gck || argv.headers) return true;
99
+ return Object.values(cfg.profiles || {}).some(profile =>
100
+ profile.instance_url === instanceURL && profile.auth_method === 'gck'
101
+ );
102
+ }
103
+
85
104
  /**
86
105
  * Pick a profile by name — interactive search picker when ambiguous,
87
106
  * auto-selects when there's exactly one, throws when none configured.
@@ -111,9 +130,50 @@ export async function pickProfile(app, message) {
111
130
  * always asks otherwise.
112
131
  */
113
132
  export function resolveWizardInstance(app, argv = {}) {
114
- // app.getEffectiveInstance() is the session-backed read (production App);
115
- // the _overrideInstance fallback keeps hand-rolled test fakes honest.
116
- return process.env.SERVICENOW_INSTANCE_URL || app.getEffectiveInstance?.() || app._overrideInstance || argv.instance || '';
133
+ // Only explicit inputs may pre-fill the wizard. The active/default profile
134
+ // belongs to the existing connection and must not silently become the new
135
+ // instance when setup is adding a profile.
136
+ return process.env.SERVICENOW_INSTANCE_URL || app._overrideInstance || argv.instance || '';
137
+ }
138
+
139
+ export async function readBrowserSessionInput() {
140
+ const stdin = process.stdin;
141
+ console.log('Paste the complete cURL command, then press Ctrl+D:');
142
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
143
+ const chunks = [];
144
+ for await (const chunk of stdin) chunks.push(chunk);
145
+ return Buffer.concat(chunks).toString();
146
+ }
147
+
148
+ return new Promise((resolve, reject) => {
149
+ let pasted = '';
150
+ const finish = () => {
151
+ stdin.setRawMode(false);
152
+ stdin.pause();
153
+ stdin.removeListener('data', onData);
154
+ stdin.removeListener('error', onError);
155
+ process.stdout.write('\n');
156
+ };
157
+ const onError = (err) => { finish(); reject(err); };
158
+ const onData = (chunk) => {
159
+ const text = chunk.toString();
160
+ const eof = text.indexOf('\x04');
161
+ if (eof !== -1) {
162
+ pasted += text.slice(0, eof);
163
+ finish();
164
+ resolve(pasted);
165
+ } else if (text.includes('\x03')) {
166
+ finish();
167
+ reject(new Error('Browser session input cancelled'));
168
+ } else {
169
+ pasted += text;
170
+ }
171
+ };
172
+ stdin.setRawMode(true);
173
+ stdin.resume();
174
+ stdin.on('data', onData);
175
+ stdin.on('error', onError);
176
+ });
117
177
  }
118
178
 
119
179
  /**
@@ -126,7 +186,7 @@ export function resolveWizardInstance(app, argv = {}) {
126
186
  */
127
187
  export async function loginWizard(app, argv = {}) {
128
188
  const readline = (await import('node:readline')).default;
129
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
189
+ let rl = readline.createInterface({ input: process.stdin, output: process.stdout });
130
190
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
131
191
 
132
192
  console.log('Welcome to JSN - ServiceNow CLI');
@@ -161,8 +221,9 @@ export async function loginWizard(app, argv = {}) {
161
221
  profile.skip_confirmations = true;
162
222
  }
163
223
 
164
- const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
165
- const useBasic = authMethod.toLowerCase().startsWith('b');
224
+ const authMethod = await ask('Authentication method (OAuth/Basic/Browser session) [OAuth]: ');
225
+ const useGck = authMethod.toLowerCase().startsWith('g') || authMethod.toLowerCase().startsWith('browser');
226
+ const useBasic = !useGck && authMethod.toLowerCase().startsWith('b');
166
227
 
167
228
  let loggedIn = false;
168
229
  if (useBasic) {
@@ -179,6 +240,12 @@ export async function loginWizard(app, argv = {}) {
179
240
  }
180
241
  profile.username = username;
181
242
  profile.auth_method = 'basic';
243
+ } else if (useGck) {
244
+ rl.close();
245
+ const input = await readBrowserSessionInput();
246
+ await app.auth.loginWithGck(instance, input);
247
+ profile.auth_method = 'gck';
248
+ loggedIn = true;
182
249
  } else {
183
250
  profile.auth_method = 'oauth';
184
251
  }
@@ -192,6 +259,11 @@ export async function loginWizard(app, argv = {}) {
192
259
  // so a piped/scripted call never blocks on prompts.
193
260
  const { canPrompt } = await import('../helpers.js');
194
261
  if (canPrompt()) {
262
+ // @inquirer/prompts owns stdin while these confirmations run. Close the
263
+ // older readline interface before handing stdin to Inquirer. This matters
264
+ // on Windows Git Bash: leaving both readers attached can make the later
265
+ // OAuth question see EOF and let Node exit instead of waiting for input.
266
+ rl.close();
195
267
  const { confirm } = await import('@inquirer/prompts');
196
268
  profile.read_only = await confirm({ message: 'Read-only profile (blocks mutation commands)?', default: profile.read_only || false });
197
269
  profile.skip_confirmations = await confirm({ message: 'Skip confirmations (deletes run without prompting)?', default: profile.skip_confirmations || false });
@@ -201,7 +273,10 @@ export async function loginWizard(app, argv = {}) {
201
273
 
202
274
  await saveConfig(app.config);
203
275
 
204
- if (!useBasic) {
276
+ if (!useBasic && !useGck) {
277
+ // Recreate readline after Inquirer has released stdin. Do not reuse the
278
+ // interface that was open before the confirmation prompts.
279
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
205
280
  const loginNow = await ask('Login now? [Y/n]: ');
206
281
  rl.close();
207
282
  if (!loginNow || loginNow.toLowerCase() !== 'n') {
@@ -426,7 +501,7 @@ export async function removeProfile(app, name) {
426
501
  export function authCmd(wrap) {
427
502
  return {
428
503
  command: 'auth [subcommand]',
429
- describe: 'Manage authentication (OAuth or basic auth via env vars)',
504
+ describe: 'Manage authentication (OAuth or Basic Auth)',
430
505
  builder: (yargs) => {
431
506
  return yargs
432
507
  .command({
@@ -437,8 +512,9 @@ export function authCmd(wrap) {
437
512
  describe: 'Authorization code from browser (bypasses interactive prompt)',
438
513
  type: 'string',
439
514
  })
440
- .option('password', {
441
- describe: 'Authenticate with basic auth via env vars (SN_USERNAME/SN_PASSWORD)',
515
+ .option('basic', {
516
+ alias: 'password',
517
+ describe: 'Authenticate with Basic Auth via env vars (SN_USERNAME/SN_PASSWORD)',
442
518
  type: 'boolean',
443
519
  })
444
520
  .option('print-url', {
@@ -497,15 +573,20 @@ export function authCmd(wrap) {
497
573
  Examples:
498
574
  jsn auth login https://dev12345.service-now.com
499
575
  jsn auth login dev373698
500
- jsn auth login --password https://dev328604.service-now.com
576
+ jsn auth login --basic https://dev328604.service-now.com
501
577
 
502
578
  Find your instance URL in your browser's address bar when logged into ServiceNow.`);
503
579
  }
504
580
  }
505
581
 
506
- // --password: authenticate with basic auth from env vars
507
- if (argv.password) {
508
- await app.auth.loginWithPassword(instanceURL);
582
+ const targetProfile = Object.values(app.config.profiles || {})
583
+ .find(profile => profile.instance_url === instanceURL);
584
+ const targetUsername = targetProfile?.username || '';
585
+ const useBasic = shouldUseBasicAuth(argv, app.config, instanceURL);
586
+ // --basic (with --password as a compatibility alias): authenticate
587
+ // with basic auth from env vars and persist it for this profile.
588
+ if (useBasic) {
589
+ await app.auth.loginWithPassword(instanceURL, targetUsername || undefined);
509
590
  }
510
591
  // --print-url with --wait-file: print URL and wait for code file
511
592
  else if (argv.printUrl && argv.waitFile) {
@@ -528,9 +609,12 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
528
609
  // Verify auth by fetching current user
529
610
  let username = '';
530
611
  try {
531
- if (!app.sdk) {
612
+ if (!app.sdk || targetUsername) {
532
613
  const { SDKClient } = await import('../sdk.js');
533
- app.sdk = new SDKClient(instanceURL, app.auth);
614
+ const authProvider = targetUsername
615
+ ? { getCredentials: () => app.auth.getCredentialsFor(instanceURL, targetUsername) }
616
+ : app.auth;
617
+ app.sdk = new SDKClient(instanceURL, authProvider);
534
618
  }
535
619
  const user = await app.sdk.getCurrentUser();
536
620
  username = user?.user_name || user?.name || '';
@@ -560,7 +644,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
560
644
  app.config.profiles[profileName] = {
561
645
  ...(app.config.profiles[profileName] || {}),
562
646
  instance_url: instanceURL,
563
- auth_method: argv.password ? 'basic' : 'oauth',
647
+ auth_method: useBasic ? 'basic' : 'oauth',
564
648
  username: username || undefined,
565
649
  read_only: argv['read-only'] || undefined,
566
650
  skip_confirmations: argv['skip-confirmations'] || undefined,
@@ -571,7 +655,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
571
655
  // so they're keyed by <user>@<instance> for per-user isolation.
572
656
  // The legacy bare-instance key is cleaned up behind the
573
657
  // AuthManager seam (credential-store internals don't leak here).
574
- if (!argv.password && username) {
658
+ if (!useBasic && username) {
575
659
  app.auth.migrateLegacyCredential(instanceURL, username);
576
660
  }
577
661
 
@@ -1,4 +1,5 @@
1
1
  import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
+ import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../../flow-context.js';
2
3
  import { declareCapabilities } from '../../capabilities.js';
3
4
 
4
5
  declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete'], devAlias: true });
@@ -66,6 +67,62 @@ export function flowsCmd(wrap) {
66
67
  }, { summary: `${records.length} flow(s)` });
67
68
  }),
68
69
  })
70
+ .command({
71
+ command: 'executions',
72
+ aliases: ['runs', 'contexts'],
73
+ describe: 'Show Flow Designer executions from sys_flow_context',
74
+ builder: (y) => y
75
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "state=WAITING" or "source_record=<sys_id>")' })
76
+ .option('record', { type: 'string', describe: 'Only executions for this source record sys_id' })
77
+ .option('since', { type: 'string', describe: 'Only contexts created on or after this timestamp' })
78
+ .option('until', { type: 'string', describe: 'Only contexts created on or before this timestamp' })
79
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max executions' })
80
+ .option('summary', { type: 'boolean', default: false, describe: 'Return local counts and duration metrics by flow' })
81
+ .option('active', { type: 'boolean', default: false, describe: 'Only waiting, running, and queued executions' })
82
+ .option('all', { type: 'boolean', default: false, describe: 'Compatibility flag; all states are now shown by default' }),
83
+ handler: wrap(async (argv, app) => {
84
+ app.requireInstance();
85
+ const fields = await discoverFlowContextFields(app.sdk);
86
+ const query = buildFlowContextQuery({
87
+ record: argv.record,
88
+ since: argv.since,
89
+ until: argv.until,
90
+ query: argv.query || (argv.active ? 'stateINWAITING,RUNNING,QUEUED' : ''),
91
+ });
92
+ const params = new URLSearchParams();
93
+ params.set('sysparm_query', query);
94
+ params.set('sysparm_limit', String(argv.limit));
95
+ params.set('sysparm_fields', [...fields].join(','));
96
+ params.set('sysparm_display_value', 'all');
97
+ const records = await app.sdk.list('sys_flow_context', params);
98
+ const executions = records.map(record => normalizeFlowContext(record));
99
+ const sampledSummary = summarizeFlowContexts(executions);
100
+ let executionSummary = sampledSummary;
101
+ if (argv.summary) {
102
+ const [totalCount, stats] = await Promise.all([
103
+ app.sdk.aggregateCount('sys_flow_context', query).catch(() => null),
104
+ app.sdk.aggregate('sys_flow_context', { query, groupBy: ['name', 'state'], count: true }).catch(() => null),
105
+ ]);
106
+ executionSummary = mergeFlowContextStats(stats?.groups || stats?.stats || [], sampledSummary, totalCount);
107
+ }
108
+ const data = {
109
+ table: 'sys_flow_context',
110
+ count: argv.summary ? executionSummary.total_count ?? executions.length : executions.length,
111
+ sample_count: executions.length,
112
+ query,
113
+ fields: [...fields],
114
+ field_mapping: aggregateFlowContextMappings(executions),
115
+ missing_fields: [...new Set(executions.flatMap(execution => execution.missing_fields))],
116
+ summary: executionSummary,
117
+ executions: argv.summary ? undefined : executions,
118
+ _formatted: argv.summary ? formatFlowContextSummary(executionSummary) : undefined,
119
+ context: { instance_url: app.getEffectiveInstance(), timezone: 'UTC' },
120
+ };
121
+ app.ok(data, { summary: argv.summary
122
+ ? `${executionSummary.total_count ?? executions.length} matching flow execution(s), ${executions.length} sampled`
123
+ : `${executions.length} flow execution(s)` });
124
+ }),
125
+ })
69
126
  .command({
70
127
  command: 'show <identifier>',
71
128
  aliases: ['get'],
@@ -128,6 +185,7 @@ export function flowsCmd(wrap) {
128
185
  console.log('');
129
186
  console.log('Available subcommands:');
130
187
  console.log(' list List flows');
188
+ console.log(' executions Show flow executions from sys_flow_context');
131
189
  console.log(' show <identifier> Show flow details by name or sys_id');
132
190
  console.log(' create Create a new flow (not yet implemented)');
133
191
  console.log(' update <identifier> Update a flow (not yet implemented)');
@@ -650,10 +708,66 @@ async function formatStepLine(stepNum, pad, step, ctx) {
650
708
  case 'subflow':
651
709
  return formatSubFlowStep(stepNum, pad, step.data, ctx);
652
710
  default:
653
- return formatActionStep(stepNum, pad, step.data, ctx);
711
+ return formatActionStepWithChildren(stepNum, pad, step.data, ctx);
712
+ }
713
+ }
714
+
715
+ async function formatActionStepWithChildren(stepNum, pad, action, ctx) {
716
+ const lines = formatActionStep(stepNum, pad, action, ctx);
717
+ const depth = ctx?.depth ?? 1;
718
+ if (!ctx?.sdk?.inspectCustomAction || depth <= 1) return lines;
719
+
720
+ const actionName = getActionName(action);
721
+ if (!actionName) return lines;
722
+
723
+ if (!ctx.customActionCache) ctx.customActionCache = new Map();
724
+ if (!ctx.customActionCache.has(actionName)) {
725
+ ctx.customActionCache.set(actionName, ctx.sdk.inspectCustomAction(actionName).catch(() => null));
726
+ }
727
+ const details = await ctx.customActionCache.get(actionName);
728
+ const steps = details?.steps;
729
+ if (!Array.isArray(steps) || steps.length === 0 || !hasUsefulActionSteps(actionName, steps)) return lines;
730
+
731
+ lines.push(`${pad} ↳ Internal action steps`);
732
+ const ordered = [...steps].sort((a, b) => parseOrderField(a) - parseOrderField(b));
733
+ ordered.forEach((step, index) => {
734
+ const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'), 'Action step');
735
+ const type = getNestedString(step, 'step_type', 'display_value') || getStringField(step, 'step_type');
736
+ const suffix = type ? ` - ${type}` : '';
737
+ lines.push(`${pad} Step ${index + 1}: ${label}${suffix}`);
738
+ appendStepValues(lines, pad + ' ', step, ctx);
739
+ });
740
+ return lines;
741
+ }
742
+
743
+ function hasUsefulActionSteps(actionName, steps) {
744
+ if (steps.length !== 1) return true;
745
+ const step = steps[0];
746
+ const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'))
747
+ .replace(/\s+step$/i, '').trim().toLowerCase();
748
+ return label !== actionName.trim().toLowerCase();
749
+ }
750
+
751
+ function appendStepValues(lines, pad, step, ctx) {
752
+ for (const field of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) {
753
+ const value = getStringField(step, field);
754
+ if (!value) continue;
755
+ const resolved = resolveGuidPills(value, ctx?.labelCache);
756
+ for (const part of resolved.split('^')) {
757
+ if (part) lines.push(`${pad}${field}: ${part}`);
758
+ }
654
759
  }
655
760
  }
656
761
 
762
+ function getActionName(action) {
763
+ return firstNonEmpty(
764
+ getNestedString(action, 'actionType', 'fName'),
765
+ getStringField(action, 'actionName'),
766
+ getStringField(action, 'actionInternalName'),
767
+ getStringField(action, 'name'),
768
+ );
769
+ }
770
+
657
771
  export function formatActionStep(stepNum, pad, action, ctx) {
658
772
  const lines = [];
659
773
  let actionName = firstNonEmpty(
@@ -700,7 +814,6 @@ export function formatActionStep(stepNum, pad, action, ctx) {
700
814
  if (inputName === 'table_name' && showsTableSuffix) continue;
701
815
 
702
816
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
703
- if (!inputValue) continue;
704
817
 
705
818
  // Catalog variable selections arrive as "sys_id:table" pairs; resolve
706
819
  // them to readable names when we have a resolver map (built per flow).
@@ -716,6 +829,11 @@ export function formatActionStep(stepNum, pad, action, ctx) {
716
829
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
717
830
  }
718
831
 
832
+ if (!inputValue) {
833
+ lines.push(`${pad} ${label}: (not set)`);
834
+ continue;
835
+ }
836
+
719
837
  // ServiceNow writes multi-field payloads (encoded-query style) as
720
838
  // "field=value^field2=value2". Split those onto one line per field;
721
839
  // plain long strings stay on a single line, untruncated.
@@ -26,6 +26,11 @@ export function restCmd(wrap) {
26
26
  type: 'string',
27
27
  describe: 'Table name shorthand for api/now/table/{table}',
28
28
  })
29
+ .option('raw', {
30
+ type: 'boolean',
31
+ default: false,
32
+ describe: 'Return non-JSON response bodies as text (for XML/HTML diagnostic endpoints)',
33
+ })
29
34
  .option('query', {
30
35
  type: 'string',
31
36
  describe: 'Query parameters string',
@@ -63,7 +68,9 @@ export function restCmd(wrap) {
63
68
  requestOptions.body = argv.data;
64
69
  }
65
70
 
66
- const result = await app.sdk.request(url, requestOptions);
71
+ const result = argv.raw
72
+ ? await app.sdk.rawRequest(url, requestOptions)
73
+ : await app.sdk.request(url, requestOptions);
67
74
  app.ok(result, { summary: `${method} ${endpoint}` });
68
75
  }),
69
76
  };