@jacebenson/jsn 4.2.1 → 4.2.3

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.1",
3
+ "version": "4.2.3",
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/app.js CHANGED
@@ -131,10 +131,10 @@ export class App {
131
131
  process.stderr.write('# Use `jsn scopes` to change scope, `jsn updatesets set` to change updateset\n');
132
132
  process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
133
133
 
134
- const profileStr = hyperlink(`${String(this.context.profileName).padEnd(9)}${roBadge}`, instanceLink);
134
+ const profileStr = `${hyperlink(String(this.context.profileName).padEnd(9), instanceLink)}${roBadge}`;
135
135
  const userStr = hyperlink(String(displayUserName).padEnd(9), userLink);
136
136
  const scopeStr = hyperlink(String(scopeFormatted).padEnd(17), scopeLink);
137
- const updateSetStr = hyperlink(updateSet, updateSetLink);
137
+ const updateSetStr = hyperlink(String(updateSet), updateSetLink);
138
138
 
139
139
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
140
140
 
package/src/auth.js CHANGED
@@ -366,6 +366,9 @@ export class AuthManager {
366
366
  if (getBasicAuthFromEnv(instance)) return true;
367
367
  const creds = loadCredentials(instance, this._activeUsername());
368
368
  if (!creds) return false;
369
+ if (creds.auth_method === 'basic') {
370
+ return Boolean(creds.username && creds.password);
371
+ }
369
372
  if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
370
373
  return !!creds.access_token;
371
374
  }
@@ -384,11 +387,11 @@ export class AuthManager {
384
387
  return this.getCredentialsFor(instance);
385
388
  }
386
389
 
387
- getCredentialsFor(instance) {
390
+ getCredentialsFor(instance, username = this._activeUsername()) {
388
391
  // Check basic auth from env vars first
389
392
  const basicCreds = getBasicAuthFromEnv(instance);
390
393
  if (basicCreds) return basicCreds;
391
- const creds = loadCredentials(instance, this._activeUsername());
394
+ const creds = loadCredentials(instance, username);
392
395
  if (!creds) {
393
396
  throw errAuth(`Not authenticated for ${instance}`);
394
397
  }
@@ -483,17 +486,19 @@ export class AuthManager {
483
486
  * Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
484
487
  * Stores the basic auth credentials so they persist across sessions.
485
488
  */
486
- async loginWithPassword(instanceURL) {
489
+ async loginWithPassword(instanceURL, username = this._activeUsername()) {
487
490
  instanceURL = normalizeInstanceURL(instanceURL);
488
- const creds = getBasicAuthFromEnv(instanceURL);
489
- if (!creds) {
491
+ const envCreds = getBasicAuthFromEnv(instanceURL);
492
+ const storedCreds = loadCredentials(instanceURL, username);
493
+ const creds = envCreds || storedCreds;
494
+ if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
490
495
  throw errAuth(
491
- `No basic auth credentials found in environment.\n\n` +
496
+ `No basic auth credentials found in environment or stored profile.\n\n` +
492
497
  `Set the following environment variables:\n` +
493
498
  ` SN_USERNAME=admin (or SN_${envVarName(instanceURL)}_USERNAME)\n` +
494
499
  ` SN_PASSWORD=<password> (or SN_${envVarName(instanceURL)}_PASSWORD)\n\n` +
495
500
  `Then run:\n` +
496
- ` jsn auth login --password ${instanceURL}`
501
+ ` jsn auth login --basic ${instanceURL}`
497
502
  );
498
503
  }
499
504
  saveCredentials(instanceURL, creds, creds.username);
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,18 @@ 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
+
85
97
  /**
86
98
  * Pick a profile by name — interactive search picker when ambiguous,
87
99
  * auto-selects when there's exactly one, throws when none configured.
@@ -111,9 +123,10 @@ export async function pickProfile(app, message) {
111
123
  * always asks otherwise.
112
124
  */
113
125
  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 || '';
126
+ // Only explicit inputs may pre-fill the wizard. The active/default profile
127
+ // belongs to the existing connection and must not silently become the new
128
+ // instance when setup is adding a profile.
129
+ return process.env.SERVICENOW_INSTANCE_URL || app._overrideInstance || argv.instance || '';
117
130
  }
118
131
 
119
132
  /**
@@ -426,7 +439,7 @@ export async function removeProfile(app, name) {
426
439
  export function authCmd(wrap) {
427
440
  return {
428
441
  command: 'auth [subcommand]',
429
- describe: 'Manage authentication (OAuth or basic auth via env vars)',
442
+ describe: 'Manage authentication (OAuth or Basic Auth)',
430
443
  builder: (yargs) => {
431
444
  return yargs
432
445
  .command({
@@ -437,8 +450,9 @@ export function authCmd(wrap) {
437
450
  describe: 'Authorization code from browser (bypasses interactive prompt)',
438
451
  type: 'string',
439
452
  })
440
- .option('password', {
441
- describe: 'Authenticate with basic auth via env vars (SN_USERNAME/SN_PASSWORD)',
453
+ .option('basic', {
454
+ alias: 'password',
455
+ describe: 'Authenticate with Basic Auth via env vars (SN_USERNAME/SN_PASSWORD)',
442
456
  type: 'boolean',
443
457
  })
444
458
  .option('print-url', {
@@ -497,15 +511,20 @@ export function authCmd(wrap) {
497
511
  Examples:
498
512
  jsn auth login https://dev12345.service-now.com
499
513
  jsn auth login dev373698
500
- jsn auth login --password https://dev328604.service-now.com
514
+ jsn auth login --basic https://dev328604.service-now.com
501
515
 
502
516
  Find your instance URL in your browser's address bar when logged into ServiceNow.`);
503
517
  }
504
518
  }
505
519
 
506
- // --password: authenticate with basic auth from env vars
507
- if (argv.password) {
508
- await app.auth.loginWithPassword(instanceURL);
520
+ const targetProfile = Object.values(app.config.profiles || {})
521
+ .find(profile => profile.instance_url === instanceURL);
522
+ const targetUsername = targetProfile?.username || '';
523
+ const useBasic = shouldUseBasicAuth(argv, app.config, instanceURL);
524
+ // --basic (with --password as a compatibility alias): authenticate
525
+ // with basic auth from env vars and persist it for this profile.
526
+ if (useBasic) {
527
+ await app.auth.loginWithPassword(instanceURL, targetUsername || undefined);
509
528
  }
510
529
  // --print-url with --wait-file: print URL and wait for code file
511
530
  else if (argv.printUrl && argv.waitFile) {
@@ -528,9 +547,12 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
528
547
  // Verify auth by fetching current user
529
548
  let username = '';
530
549
  try {
531
- if (!app.sdk) {
550
+ if (!app.sdk || targetUsername) {
532
551
  const { SDKClient } = await import('../sdk.js');
533
- app.sdk = new SDKClient(instanceURL, app.auth);
552
+ const authProvider = targetUsername
553
+ ? { getCredentials: () => app.auth.getCredentialsFor(instanceURL, targetUsername) }
554
+ : app.auth;
555
+ app.sdk = new SDKClient(instanceURL, authProvider);
534
556
  }
535
557
  const user = await app.sdk.getCurrentUser();
536
558
  username = user?.user_name || user?.name || '';
@@ -560,7 +582,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
560
582
  app.config.profiles[profileName] = {
561
583
  ...(app.config.profiles[profileName] || {}),
562
584
  instance_url: instanceURL,
563
- auth_method: argv.password ? 'basic' : 'oauth',
585
+ auth_method: useBasic ? 'basic' : 'oauth',
564
586
  username: username || undefined,
565
587
  read_only: argv['read-only'] || undefined,
566
588
  skip_confirmations: argv['skip-confirmations'] || undefined,
@@ -571,7 +593,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
571
593
  // so they're keyed by <user>@<instance> for per-user isolation.
572
594
  // The legacy bare-instance key is cleaned up behind the
573
595
  // AuthManager seam (credential-store internals don't leak here).
574
- if (!argv.password && username) {
596
+ if (!useBasic && username) {
575
597
  app.auth.migrateLegacyCredential(instanceURL, username);
576
598
  }
577
599
 
@@ -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
  };