@jacebenson/jsn 3.4.0 → 3.4.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": "3.4.0",
3
+ "version": "3.4.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": {
package/src/cli.js CHANGED
@@ -225,7 +225,11 @@ export function buildCLI() {
225
225
  }
226
226
 
227
227
  if (shouldCheckSkill) {
228
- checkSkill().catch(() => {});
228
+ try {
229
+ checkSkill();
230
+ } catch {
231
+ // Non-fatal — don't crash the CLI if skill check fails
232
+ }
229
233
 
230
234
  try {
231
235
  fs.mkdirSync(globalConfigDir(), { recursive: true });
@@ -14,7 +14,12 @@ export function flowsCmd(wrap) {
14
14
  builder: (y) => y
15
15
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
16
16
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
17
- .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
17
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' })
18
+ .option('depth', {
19
+ type: 'number',
20
+ default: 2,
21
+ describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
22
+ }),
18
23
  handler: wrap(async (argv, app) => {
19
24
  app.requireInstance();
20
25
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'active', 'description', 'sys_created_by', 'sys_updated_on'];
@@ -28,7 +33,13 @@ export function flowsCmd(wrap) {
28
33
  if (picked === undefined) return; // user cancelled
29
34
  if (picked) {
30
35
  const inspection = await app.sdk.inspectFlow(getStringField(picked, 'sys_id'));
31
- const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
36
+ const ctx = {
37
+ sdk: app.sdk,
38
+ instanceURL: app.getEffectiveInstance(),
39
+ depth: Math.max(1, Math.floor(argv.depth ?? 2)),
40
+ visited: new Set([inspection.flow.sysID]),
41
+ };
42
+ const formatted = await formatFlowInspection(inspection, ctx);
32
43
  return app.ok({ ...inspection, _formatted: formatted }, {
33
44
  summary: `Flow: ${inspection.flow.name}`,
34
45
  breadcrumbs: [{ action: 'list', cmd: 'jsn flows list', description: 'Back to all flows' }],
@@ -56,9 +67,21 @@ export function flowsCmd(wrap) {
56
67
  command: 'show <identifier>',
57
68
  aliases: ['get'],
58
69
  describe: 'Show flow details by name or sys_id',
70
+ builder: (y) => y
71
+ .option('depth', {
72
+ type: 'number',
73
+ default: 2,
74
+ describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
75
+ }),
59
76
  handler: wrap(async (argv, app) => {
60
77
  const inspection = await app.sdk.inspectFlow(argv.identifier);
61
- const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
78
+ const ctx = {
79
+ sdk: app.sdk,
80
+ instanceURL: app.getEffectiveInstance(),
81
+ depth: Math.max(1, Math.floor(argv.depth ?? 2)),
82
+ visited: new Set([inspection.flow.sysID]),
83
+ };
84
+ const formatted = await formatFlowInspection(inspection, ctx);
62
85
  const data = { ...inspection, _formatted: formatted };
63
86
  app.ok(data, {
64
87
  summary: `Flow: ${inspection.flow.name}`,
@@ -109,15 +132,16 @@ export function flowsCmd(wrap) {
109
132
  console.log('');
110
133
  console.log('Run "jsn flows <command> --help" for details.');
111
134
  console.log('');
112
- console.log('Note: Flow inspection detail varies. V2 flows (with payload)');
113
- console.log('show full action inputs and conditions. Subflows and V1 flows');
114
- console.log('show step names only.');
135
+ console.log('Note: Flow structure comes from the ProcessFlow API (the Flow');
136
+ console.log('Designer UI source), so V1, V2, and subflow definitions all');
137
+ console.log('render with full action inputs and conditions.');
115
138
  },
116
139
  };
117
140
  }
118
141
 
119
- function formatFlowInspection(inspection, instanceURL) {
142
+ async function formatFlowInspection(inspection, ctx) {
120
143
  const lines = [];
144
+ const instanceURL = ctx?.instanceURL || '';
121
145
  const flow = inspection.flow;
122
146
 
123
147
  lines.push('');
@@ -185,12 +209,14 @@ function formatFlowInspection(inspection, instanceURL) {
185
209
  lines.push('');
186
210
  lines.push('⚡ FLOW STRUCTURE');
187
211
  lines.push('─'.repeat(50));
188
- const structureLines = formatFlowStructure(inspection);
212
+ const structureLines = await formatFlowStructure(inspection, ctx);
189
213
  lines.push(...structureLines);
190
- // Note limitation for V1/subflows without payload detail
191
- if (!inspection.payload || Object.keys(inspection.payload).length === 0) {
214
+ // Note limitation only when we couldn't reconstruct detail from the payload OR the gzip fallback
215
+ const hasPayload = inspection.payload && Object.keys(inspection.payload).length > 0;
216
+ const hasDecodedLogic = (inspection.flowLogicInstances || []).some(l => l._decodedValues);
217
+ if (!hasPayload && !hasDecodedLogic) {
192
218
  lines.push('');
193
- lines.push(' (step detail limited — this flow lacks a V2 payload)');
219
+ lines.push(' (step detail limited — no flow definition data available)');
194
220
  }
195
221
 
196
222
  lines.push('');
@@ -254,15 +280,15 @@ function inferFlowVersion(inspection) {
254
280
  return 'Unset';
255
281
  }
256
282
 
257
- function formatFlowStructure(inspection) {
283
+ async function formatFlowStructure(inspection, ctx) {
258
284
  const payload = inspection.payload;
259
285
  if (Object.keys(payload).length > 0) {
260
- return formatFlowStructureFromPayload(payload);
286
+ return formatFlowStructureFromPayload(payload, ctx);
261
287
  }
262
288
  return formatFlowStructureFallback(inspection);
263
289
  }
264
290
 
265
- function formatFlowStructureFromPayload(payload) {
291
+ async function formatFlowStructureFromPayload(payload, ctx) {
266
292
  const childUIDs = new Set();
267
293
 
268
294
  function markChildren(items) {
@@ -312,10 +338,10 @@ function formatFlowStructureFromPayload(payload) {
312
338
  const lines = [];
313
339
  let stepNum = 1;
314
340
 
315
- function walk(steps, indent) {
341
+ async function walk(steps, indent) {
316
342
  for (const step of steps) {
317
343
  const pad = ' '.repeat(indent);
318
- lines.push(...formatStepLine(stepNum, pad, step));
344
+ lines.push(...await formatStepLine(stepNum, pad, step, ctx));
319
345
  stepNum++;
320
346
 
321
347
  if (step.stepType !== 'logic') continue;
@@ -332,11 +358,11 @@ function formatFlowStructureFromPayload(payload) {
332
358
  });
333
359
  }
334
360
  children.sort((a, b) => a.order - b.order);
335
- walk(children, indent + 1);
361
+ await walk(children, indent + 1);
336
362
  }
337
363
  }
338
364
 
339
- walk(roots, 0);
365
+ await walk(roots, 0);
340
366
  return lines;
341
367
  }
342
368
 
@@ -351,7 +377,7 @@ function formatFlowStructureFallback(inspection) {
351
377
  getStringField(action, 'display_text'),
352
378
  'Action',
353
379
  );
354
- steps.push({ order: parseOrderField(action), text: name });
380
+ steps.push({ order: parseOrderField(action), text: name, comment: getStringField(action, 'comment') });
355
381
  }
356
382
  }
357
383
 
@@ -363,7 +389,12 @@ function formatFlowStructureFallback(inspection) {
363
389
  getStringField(logic, 'display_text'),
364
390
  'Logic',
365
391
  );
366
- steps.push({ order: parseOrderField(logic), text: name });
392
+ steps.push({
393
+ order: parseOrderField(logic),
394
+ text: name,
395
+ comment: getStringField(logic, 'comment'),
396
+ decoded: logic._decodedValues || null,
397
+ });
367
398
  }
368
399
  }
369
400
 
@@ -385,15 +416,65 @@ function formatFlowStructureFallback(inspection) {
385
416
  return [' (no steps found)'];
386
417
  }
387
418
 
388
- return steps.map((step, i) => `${i + 1}. ${step.text}`);
419
+ const lines = [];
420
+ steps.forEach((step, i) => {
421
+ const num = `${i + 1}.`;
422
+ // Decoded gzip values carry the same input detail as a V2 payload
423
+ if (step.decoded && (step.text === 'If' || step.text === 'Else If')) {
424
+ let condition = '';
425
+ let conditionLabel = '';
426
+ if (Array.isArray(step.decoded.inputs)) {
427
+ for (const raw of step.decoded.inputs) {
428
+ if (!raw || typeof raw !== 'object') continue;
429
+ const inputName = getStringField(raw, 'name');
430
+ if (inputName === 'condition') {
431
+ condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
432
+ }
433
+ if (inputName === 'condition_name') {
434
+ conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
435
+ }
436
+ }
437
+ }
438
+ let displayText = step.text;
439
+ if (conditionLabel) {
440
+ displayText = `${step.text}: ${conditionLabel}`;
441
+ } else if (condition && condition.length < 60) {
442
+ displayText = `${step.text}: ${condition}`;
443
+ }
444
+ lines.push(` ${num} ${displayText}`);
445
+ if (condition && condition.length >= 60 && !conditionLabel) {
446
+ lines.push(` Condition: ${condition}`);
447
+ }
448
+ } else if (step.decoded && step.text === 'Set Flow Variables') {
449
+ lines.push(` ${num} ${step.text}`);
450
+ const vars = step.decoded.variables || step.decoded.flowVariables;
451
+ if (Array.isArray(vars) && vars.length > 0) {
452
+ lines.push(` Variables Set:`);
453
+ for (const raw of vars) {
454
+ if (!raw || typeof raw !== 'object') continue;
455
+ const varName = getStringField(raw, 'name');
456
+ const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
457
+ if (!varName) continue;
458
+ lines.push(varValue ? ` • ${varName} = ${varValue}` : ` • ${varName}`);
459
+ }
460
+ }
461
+ } else {
462
+ lines.push(` ${num} ${step.text}`);
463
+ }
464
+ if (step.comment) {
465
+ lines.push(` Annotation: ${step.comment}`);
466
+ }
467
+ });
468
+
469
+ return lines;
389
470
  }
390
471
 
391
- function formatStepLine(stepNum, pad, step) {
472
+ async function formatStepLine(stepNum, pad, step, ctx) {
392
473
  switch (step.stepType) {
393
474
  case 'logic':
394
475
  return formatLogicStep(stepNum, pad, step.data);
395
476
  case 'subflow':
396
- return formatSubFlowStep(stepNum, pad, step.data);
477
+ return formatSubFlowStep(stepNum, pad, step.data, ctx);
397
478
  default:
398
479
  return formatActionStep(stepNum, pad, step.data);
399
480
  }
@@ -425,8 +506,9 @@ function formatActionStep(stepNum, pad, action) {
425
506
  }
426
507
  }
427
508
 
509
+ const showsTableSuffix = tableName && (actionName === 'Update Record' || actionName === 'Create or Update Record');
428
510
  let actionDisplay = actionName;
429
- if (tableName && actionName === 'Update Record') {
511
+ if (showsTableSuffix) {
430
512
  actionDisplay = actionName + ' - ' + tableName;
431
513
  }
432
514
 
@@ -441,7 +523,7 @@ function formatActionStep(stepNum, pad, action) {
441
523
  for (const raw of action.inputs) {
442
524
  if (!raw || typeof raw !== 'object') continue;
443
525
  const inputName = getStringField(raw, 'name');
444
- if (inputName === 'table_name') continue;
526
+ if (inputName === 'table_name' && showsTableSuffix) continue;
445
527
 
446
528
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
447
529
  if (!inputValue) continue;
@@ -461,7 +543,7 @@ function formatActionStep(stepNum, pad, action) {
461
543
  return lines;
462
544
  }
463
545
 
464
- function formatSubFlowStep(stepNum, pad, subFlow) {
546
+ export async function formatSubFlowStep(stepNum, pad, subFlow, ctx) {
465
547
  const lines = [];
466
548
  const subFlowName = firstNonEmpty(
467
549
  getNestedString(subFlow, 'subFlowType', 'fName'),
@@ -479,7 +561,43 @@ function formatSubFlowStep(stepNum, pad, subFlow) {
479
561
  lines.push(`${pad}${stepNum}. ↪ ${subFlowName}`);
480
562
  }
481
563
 
482
- lines.push(`${pad} jsn flows "${subFlowName}"`);
564
+ // Recurse into the subflow when depth allows and we haven't already seen it.
565
+ // Note: `subflowSysId` on the instance is a snapshot id (sys_hub_flow_snapshot),
566
+ // NOT the flow id. The embedded `subFlow.parentFlow` is the real flow sys_id.
567
+ const subflowSysId = firstNonEmpty(
568
+ getNestedString(subFlow, 'subFlow', 'parentFlow'),
569
+ getStringField(subFlow, 'subflowSysId'),
570
+ getNestedString(subFlow, 'subFlow', 'sys_id'),
571
+ getStringField(subFlow, 'sys_id'),
572
+ );
573
+ const depth = ctx?.depth ?? 1;
574
+ const canRecurse = ctx?.sdk && subflowSysId && depth > 1 && !ctx.visited.has(subflowSysId);
575
+
576
+ if (!canRecurse) {
577
+ if (!subflowSysId) {
578
+ lines.push(`${pad} (subflow definition not found)`);
579
+ } else if (depth <= 1) {
580
+ lines.push(`${pad} jsn flows show "${subFlowName}"`);
581
+ }
582
+ return lines;
583
+ }
584
+
585
+ ctx.visited.add(subflowSysId);
586
+ try {
587
+ const subInspection = await ctx.sdk.inspectFlow(subflowSysId);
588
+ const subCtx = { ...ctx, depth: depth - 1 };
589
+ const subLines = await formatFlowStructure(subInspection, subCtx);
590
+ if (subLines.length === 0) {
591
+ lines.push(`${pad} (no steps found)`);
592
+ } else {
593
+ const innerPad = pad + ' ';
594
+ for (const l of subLines) {
595
+ lines.push(innerPad + l.replace(/^ {2}/, ''));
596
+ }
597
+ }
598
+ } catch (e) {
599
+ lines.push(`${pad} (could not load subflow: ${e.message})`);
600
+ }
483
601
  return lines;
484
602
  }
485
603
 
package/src/sdk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // ServiceNow REST API client
2
2
 
3
+ import { gunzipSync } from 'node:zlib';
3
4
  import { errAuth, errAPI, errNetwork } from './errors.js';
4
5
  import { getStringField } from './helpers.js';
5
6
 
@@ -246,28 +247,51 @@ export class SDKClient {
246
247
  flowOutputs: [],
247
248
  };
248
249
 
249
- // 2) Fetch latest flow version
250
- const versionQuery = new URLSearchParams();
251
- versionQuery.set('sysparm_display_value', 'all');
252
- versionQuery.set('sysparm_limit', '1');
253
- versionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYDESCsys_updated_on`);
254
- versionQuery.set('sysparm_fields', 'sys_id,flow,version,payload,sys_updated_on');
255
-
256
- const versionRecords = await this.list('sys_hub_flow_version', versionQuery);
257
- if (versionRecords && versionRecords.length > 0) {
258
- inspection.version = versionRecords[0];
259
- if (!inspection.flow.version) {
260
- inspection.flow.version = getStringField(versionRecords[0], 'version');
250
+ // 2) Prefer the ProcessFlow API — the Flow Designer UI's own source.
251
+ // Returns complete structure for both V2 and legacy V1 flows (the
252
+ // version payload field is empty for V1 flows).
253
+ let payloadLoaded = false;
254
+ try {
255
+ const pfResponse = await this.request(`${this.baseURL}/api/now/processflow/flow/${flowSysID}`, { method: 'GET' });
256
+ const pfData = pfResponse?.result?.data;
257
+ if (pfData && typeof pfData === 'object' && !pfResponse?.result?.errorMessage) {
258
+ inspection.payload = pfData;
259
+ if (!inspection.flow.version) {
260
+ const pfVersion = getStringField(pfData, 'version');
261
+ if (pfVersion) inspection.flow.version = pfVersion;
262
+ }
263
+ hydrateFlowBlocks(inspection.payload);
264
+ extractPayloadData(inspection, inspection.payload);
265
+ payloadLoaded = true;
261
266
  }
267
+ } catch {
268
+ // ProcessFlow API may not exist on older instances — fall through
269
+ }
270
+
271
+ // 2b) Fallback: latest flow version payload
272
+ if (!payloadLoaded) {
273
+ const versionQuery = new URLSearchParams();
274
+ versionQuery.set('sysparm_display_value', 'all');
275
+ versionQuery.set('sysparm_limit', '1');
276
+ versionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYDESCsys_updated_on`);
277
+ versionQuery.set('sysparm_fields', 'sys_id,flow,version,payload,sys_updated_on');
278
+
279
+ const versionRecords = await this.list('sys_hub_flow_version', versionQuery);
280
+ if (versionRecords && versionRecords.length > 0) {
281
+ inspection.version = versionRecords[0];
282
+ if (!inspection.flow.version) {
283
+ inspection.flow.version = getStringField(versionRecords[0], 'version');
284
+ }
262
285
 
263
- const payload = getStringField(versionRecords[0], 'payload');
264
- if (payload) {
265
- try {
266
- const payloadData = JSON.parse(payload);
267
- inspection.payload = payloadData;
268
- extractPayloadData(inspection, payloadData);
269
- } catch {
270
- // ignore parse error
286
+ const payload = getStringField(versionRecords[0], 'payload');
287
+ if (payload) {
288
+ try {
289
+ const payloadData = JSON.parse(payload);
290
+ inspection.payload = payloadData;
291
+ extractPayloadData(inspection, payloadData);
292
+ } catch {
293
+ // ignore parse error
294
+ }
271
295
  }
272
296
  }
273
297
  }
@@ -295,15 +319,24 @@ export class SDKClient {
295
319
  }
296
320
 
297
321
  if (!inspection.flowLogicInstances || inspection.flowLogicInstances.length === 0) {
298
- const logicTables = ['sys_hub_flow_logic', 'sys_hub_flow_logic_instance_v2'];
299
- for (const table of logicTables) {
322
+ const logicTables = [
323
+ { table: 'sys_hub_flow_logic', payloadField: 'inputs' },
324
+ { table: 'sys_hub_flow_logic_instance_v2', payloadField: 'values' },
325
+ ];
326
+ for (const { table, payloadField } of logicTables) {
300
327
  const logicQuery = new URLSearchParams();
301
328
  logicQuery.set('sysparm_display_value', 'all');
302
329
  logicQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYorder`);
303
- logicQuery.set('sysparm_fields', 'sys_id,order,name,display_text,comment,parent_ui_id,logic_definition');
330
+ logicQuery.set('sysparm_fields', `sys_id,order,name,display_text,comment,parent_ui_id,logic_definition,${payloadField}`);
304
331
  logicQuery.set('sysparm_limit', '200');
305
332
  const records = await this.list(table, logicQuery);
306
- if (records) inspection.flowLogicInstances.push(...records);
333
+ if (records) {
334
+ for (const rec of records) {
335
+ const decoded = decodeGzipJson(rec[payloadField]);
336
+ if (decoded) rec._decodedValues = decoded;
337
+ }
338
+ inspection.flowLogicInstances.push(...records);
339
+ }
307
340
  }
308
341
  inspection.flowLogicInstances.sort((a, b) => parseOrderField(a) - parseOrderField(b));
309
342
  }
@@ -634,8 +667,72 @@ function toMapSlice(v) {
634
667
  return v.filter(item => item && typeof item === 'object');
635
668
  }
636
669
 
670
+ /**
671
+ * The ProcessFlow API returns flow structure as flat arrays where children
672
+ * reference their parent logic step via a `parent` / `parent_ui_id` field
673
+ * (a uiUniqueIdentifier value). The payload-based renderer expects nested
674
+ * `flowBlock` arrays on each logic instance. Rebuild that nesting here so
675
+ * both data sources render identically.
676
+ */
677
+ export function hydrateFlowBlocks(payload) {
678
+ const blockKeys = ['actionInstances', 'flowLogicInstances', 'subFlowInstances'];
679
+ const items = [];
680
+ for (const key of blockKeys) {
681
+ if (Array.isArray(payload[key])) {
682
+ for (const item of payload[key]) {
683
+ if (item && typeof item === 'object') items.push(item);
684
+ }
685
+ }
686
+ }
687
+ if (items.length === 0) return;
688
+
689
+ // If the payload already has nested flowBlock arrays (version-payload
690
+ // shape), leave it alone.
691
+ const logicItems = Array.isArray(payload.flowLogicInstances) ? payload.flowLogicInstances : [];
692
+ if (logicItems.some(l => l && typeof l === 'object' && Array.isArray(l.flowBlock))) return;
693
+
694
+ // Build parent → children map keyed by uiUniqueIdentifier
695
+ const childrenByParent = new Map();
696
+ for (const item of items) {
697
+ const parentId = getStringField(item, 'parent_ui_id') || getStringField(item, 'parent');
698
+ if (!parentId) continue;
699
+ if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []);
700
+ childrenByParent.get(parentId).push(item);
701
+ }
702
+
703
+ // Attach sorted flowBlock to each logic instance
704
+ for (const logic of logicItems) {
705
+ if (!logic || typeof logic !== 'object') continue;
706
+ const uid = getStringField(logic, 'uiUniqueIdentifier');
707
+ const children = uid ? childrenByParent.get(uid) : null;
708
+ if (Array.isArray(children) && children.length > 0) {
709
+ children.sort((a, b) => parseOrderField(a) - parseOrderField(b));
710
+ logic.flowBlock = children;
711
+ }
712
+ }
713
+ }
714
+
637
715
  function parseOrderField(record) {
638
716
  const order = getStringField(record, 'order');
639
717
  const n = parseInt(order, 10);
640
718
  return isNaN(n) ? 0 : n;
641
719
  }
720
+
721
+ /**
722
+ * Decode a ServiceNow flow-logic "values" field: base64-gzip JSON.
723
+ * Returns the parsed object, or null when the field is empty / not gzip / unparseable.
724
+ */
725
+ export function decodeGzipJson(value) {
726
+ if (!value) return null;
727
+ const s = (typeof value === 'object' && value !== null)
728
+ ? (value.value ?? value.display_value ?? '')
729
+ : String(value);
730
+ const trimmed = s.trim();
731
+ if (!trimmed.startsWith('H4sI')) return null; // not gzip base64
732
+ try {
733
+ const decoded = gunzipSync(Buffer.from(trimmed, 'base64')).toString('utf-8');
734
+ return JSON.parse(decoded);
735
+ } catch {
736
+ return null;
737
+ }
738
+ }