@jacebenson/jsn 4.3.7 → 4.3.8-dev.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +266 -1
  2. package/package.json +1 -1
  3. package/skills/servicenow/SKILL.md +16 -0
  4. package/src/app.js +8 -1
  5. package/src/auth.js +14 -12
  6. package/src/cli-adapter.js +162 -11
  7. package/src/cli.js +121 -13
  8. package/src/commands/_generic.js +3 -2
  9. package/src/commands/_simple.js +1 -1
  10. package/src/commands/actions.js +156 -0
  11. package/src/commands/auth.js +21 -11
  12. package/src/commands/cmdb.js +18 -0
  13. package/src/commands/diff.js +205 -0
  14. package/src/commands/docs/docs.js +18 -16
  15. package/src/commands/flows.js +149 -47
  16. package/src/commands/graphql.js +95 -0
  17. package/src/commands/groupmembers.js +19 -4
  18. package/src/commands/grouproles.js +15 -4
  19. package/src/commands/midservers.js +708 -0
  20. package/src/commands/perf.js +36 -9
  21. package/src/commands/platform.js +202 -0
  22. package/src/commands/records.js +19 -15
  23. package/src/commands/skill.js +5 -2
  24. package/src/commands/transactions.js +78 -0
  25. package/src/commands/updatesets.js +77 -11
  26. package/src/errors.js +71 -6
  27. package/src/flow-create.js +884 -0
  28. package/src/flow-designer-interactive.js +417 -0
  29. package/src/flow-inspection.js +15 -5
  30. package/src/flow-publish.js +32 -0
  31. package/src/flow-test.js +122 -0
  32. package/src/flow-triggers.js +448 -0
  33. package/src/flow-versioning.js +53 -0
  34. package/src/help.js +93 -28
  35. package/src/helpers.js +124 -9
  36. package/src/midserver-direct.js +312 -0
  37. package/src/midserver-install.js +170 -0
  38. package/src/midserver-zip.js +107 -0
  39. package/src/output.js +7 -0
  40. package/src/processflow-actions.js +366 -0
  41. package/src/sdk.js +8 -0
@@ -1,5 +1,6 @@
1
1
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
2
- import { errUsage } from '../errors.js';
2
+ import { errUsage, errUsageHint } from '../errors.js';
3
+ import { canUsePicker } from '../helpers.js';
3
4
  import { declareCapabilities } from '../capabilities.js';
4
5
  import process from 'node:process';
5
6
 
@@ -128,14 +129,16 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
128
129
  }
129
130
 
130
131
  /**
131
- * Pick a profile by name — interactive search picker when ambiguous,
132
- * auto-selects when there's exactly one, throws when none configured.
132
+ * Pick a profile by name — interactive search picker when ambiguous and a
133
+ * human is available, auto-selects when there's exactly one, throws when
134
+ * none configured. A non-interactive caller (agent, pipe, --json/--get)
135
+ * never opens a picker; it falls back to the single/default profile.
133
136
  */
134
137
  export async function pickProfile(app, message) {
135
138
  const { search } = await import('@inquirer/prompts');
136
139
  const profileNames = Object.keys(app.config.profiles || {});
137
140
  if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
138
- if (profileNames.length === 1 || !app.isInteractive()) return profileNames[0];
141
+ if (profileNames.length === 1 || !canUsePicker(app, {})) return profileNames[0];
139
142
  const choices = profileNames.map(name => ({
140
143
  name: `${name} — ${app.config.profiles[name].instance_url}`,
141
144
  value: name,
@@ -281,10 +284,9 @@ export async function loginWizard(app, argv = {}) {
281
284
  app.config.activeProfile = profileName;
282
285
  app.config.defaultProfile = profileName;
283
286
 
284
- // Present all connection-level options during setup. Gated on interactivity
285
- // so a piped/scripted call never blocks on prompts.
286
- const { canPrompt } = await import('../helpers.js');
287
- if (canPrompt()) {
287
+ // Present all connection-level options during setup. Gated on
288
+ // interactivity so a piped/scripted call never blocks on prompts.
289
+ if (canUsePicker(app, argv)) {
288
290
  // @inquirer/prompts owns stdin while these confirmations run. Close the
289
291
  // older readline interface before handing stdin to Inquirer. This matters
290
292
  // on Windows Git Bash: leaving both readers attached can make the later
@@ -604,7 +606,7 @@ export function authCmd(wrap) {
604
606
  instanceURL = normalizeInstanceURL(namedProfile.instance_url);
605
607
  } else if (argv.instance) {
606
608
  instanceURL = resolveInstanceArg(argv.instance, app.config);
607
- } else if (app.isInteractive()) {
609
+ } else if (canUsePicker(app, argv)) {
608
610
  // gh-faithful: interactive terminals always get a picker —
609
611
  // existing profiles plus "+ Add a new instance". Zero profiles
610
612
  // goes straight to the setup wizard.
@@ -987,7 +989,9 @@ Examples:
987
989
  if (profileNames.length === 0) {
988
990
  throw errUsage('No profiles configured. Run "jsn auth login" first.');
989
991
  }
990
- if (profileNames.length > 1 && app.isInteractive()) {
992
+ if (profileNames.length === 1) {
993
+ name = profileNames[0];
994
+ } else if (canUsePicker(app, argv)) {
991
995
  const { search } = await import('@inquirer/prompts');
992
996
  const choices = profileNames.map(profileName => ({
993
997
  name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
@@ -1002,7 +1006,13 @@ Examples:
1002
1006
  });
1003
1007
  name = selected;
1004
1008
  } else {
1005
- name = profileNames[0];
1009
+ // Never silently switch the active profile for a
1010
+ // non-interactive caller: picking one for them is a
1011
+ // config write they did not ask for.
1012
+ throw errUsageHint(
1013
+ `Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`,
1014
+ 'Pass the profile name: jsn auth switch <name>'
1015
+ );
1006
1016
  }
1007
1017
  }
1008
1018
  await setActiveProfile(app.config, name);
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  assertSafeExactMatch,
10
10
  buildQuerySuffix,
11
+ formatCommandList,
11
12
  formatRecordForDisplay,
12
13
  getStringField,
13
14
  interactiveList,
@@ -351,6 +352,23 @@ export function cmdbCmd(wrap) {
351
352
  return {
352
353
  command: 'cmdb [subcommand]',
353
354
  describe: 'Query the Configuration Management Database (CMDB) — CIs and relationship traversal (read-only)',
355
+ // Parent discovery. Without a handler `jsn cmdb` exited 0 having printed
356
+ // nothing: a script reads that as success and a human as a hang. The child
357
+ // list comes from the registry (argv.subcommands), so it cannot drift from
358
+ // what is registered; the styled view is a side channel that JSON ignores.
359
+ handler: wrap(async (argv, app) => {
360
+ const subcommands = argv.subcommands || [];
361
+ const summary = 'Query the Configuration Management Database (CMDB): CI lookup and relationship traversal. Read-only.';
362
+ app.ok({
363
+ command: 'cmdb',
364
+ subcommands,
365
+ _formatted: formatCommandList({
366
+ title: summary,
367
+ commands: subcommands,
368
+ footer: 'Run "jsn cmdb <command> --help" for details.',
369
+ }),
370
+ }, { summary });
371
+ }),
354
372
  builder: (yargs) => {
355
373
  return yargs
356
374
  .command({
@@ -0,0 +1,205 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { errUsage, errUsageHint } from '../errors.js';
3
+ import { resolveSession } from '../session.js';
4
+ import { resolveRecord } from '../resolve-record.js';
5
+
6
+ declareCapabilities('diff', {});
7
+
8
+ const DEFAULT_IGNORED_FIELDS = [
9
+ 'sys_created_by',
10
+ 'sys_created_on',
11
+ 'sys_mod_count',
12
+ 'sys_updated_by',
13
+ 'sys_updated_on',
14
+ ];
15
+
16
+ function profileTarget(app, name, label) {
17
+ const session = resolveSession({ profile: name }, app.config);
18
+ if (session.unknownProfile || !session.profileName) {
19
+ throw errUsageHint(`Unknown ${label} profile "${name}"`, 'Run "jsn auth status" to list configured profiles.');
20
+ }
21
+ if (!session.instance || !session.profile) {
22
+ throw errUsage(`Profile "${name}" has no configured instance URL`);
23
+ }
24
+ return {
25
+ name: session.profileName,
26
+ instance: session.instance,
27
+ profile: session.profile,
28
+ username: session.username || '',
29
+ domain: session.domain || '',
30
+ };
31
+ }
32
+
33
+ function sdkForProfile(app, target) {
34
+ return app.getSDKForProfile(target.instance, {
35
+ profile: target.profile,
36
+ profileName: target.name,
37
+ username: target.username,
38
+ authMethod: target.profile.auth_method,
39
+ domain: target.domain,
40
+ });
41
+ }
42
+
43
+ async function fetchRecord(app, target, table, identifier) {
44
+ try {
45
+ const record = await resolveRecord(sdkForProfile(app, target), {
46
+ table,
47
+ identifier,
48
+ matchField: 'sys_id',
49
+ resource: 'Record',
50
+ });
51
+ return { status: 'found', record };
52
+ } catch (error) {
53
+ if (error?.code === 'not_found') return { status: 'missing', record: null };
54
+ if (error?.code === 'forbidden') return { status: 'forbidden', record: null, error: error.code };
55
+ if (error?.code === 'auth_error') return { status: 'auth_error', record: null, error: error.code };
56
+ return { status: 'error', record: null, error: error?.code || 'error' };
57
+ }
58
+ }
59
+
60
+ function stableValue(value) {
61
+ if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return value.value;
62
+ if (Array.isArray(value)) return value.map(stableValue);
63
+ if (value && typeof value === 'object') {
64
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function equalValue(a, b) {
70
+ return JSON.stringify(stableValue(a)) === JSON.stringify(stableValue(b));
71
+ }
72
+
73
+ function displayValue(value) {
74
+ if (value && typeof value === 'object' && value.display_value != null) return String(value.display_value);
75
+ if (value == null) return 'null';
76
+ if (typeof value === 'object') return JSON.stringify(value);
77
+ return String(value);
78
+ }
79
+
80
+ function fieldDifference(field, status, a, b) {
81
+ return {
82
+ field,
83
+ status,
84
+ ...(a !== undefined ? { a } : {}),
85
+ ...(b !== undefined ? { b } : {}),
86
+ };
87
+ }
88
+
89
+ export function compareRecords(recordA, recordB, ignoredFields = DEFAULT_IGNORED_FIELDS) {
90
+ const ignored = new Set(ignoredFields);
91
+ const fields = [...new Set([
92
+ ...Object.keys(recordA || {}),
93
+ ...Object.keys(recordB || {}),
94
+ ])].filter(field => !ignored.has(field)).sort();
95
+ const differences = [];
96
+ for (const field of fields) {
97
+ const hasA = Object.hasOwn(recordA || {}, field);
98
+ const hasB = Object.hasOwn(recordB || {}, field);
99
+ if (!hasA && hasB) differences.push(fieldDifference(field, 'added', undefined, recordB[field]));
100
+ else if (hasA && !hasB) differences.push(fieldDifference(field, 'missing', recordA[field], undefined));
101
+ else if (!equalValue(recordA[field], recordB[field])) differences.push(fieldDifference(field, 'changed', recordA[field], recordB[field]));
102
+ }
103
+ return differences;
104
+ }
105
+
106
+ export function formatRecordDiff(data) {
107
+ const lines = [
108
+ `Record diff: ${data.table} ${data.identifiers.a}`,
109
+ `A: ${data.profiles.a.name} (${data.existence.a})`,
110
+ `B: ${data.profiles.b.name} (${data.existence.b})`,
111
+ `Status: ${data.status}`,
112
+ ];
113
+ if (data.status === 'error' || data.status === 'forbidden' || data.status === 'auth_error' || data.status === 'missing' || data.status === 'both_missing') return `${lines.join('\n')}\n`;
114
+ lines.push(`Ignored: ${data.ignored_fields.join(', ') || '(none)'}`);
115
+ if (data.differences.length === 0) lines.push('No field differences');
116
+ for (const difference of data.differences) {
117
+ const left = difference.a === undefined ? '(missing)' : displayValue(difference.a);
118
+ const right = difference.b === undefined ? '(missing)' : displayValue(difference.b);
119
+ lines.push(`${difference.status.toUpperCase()} ${difference.field}: ${left} -> ${right}`);
120
+ }
121
+ return `${lines.join('\n')}\n`;
122
+ }
123
+
124
+ function ignoredFields(argv) {
125
+ if (argv['no-ignore']) return [];
126
+ return [...new Set([...DEFAULT_IGNORED_FIELDS, ...(argv.ignore || [])])].sort();
127
+ }
128
+
129
+ export function diffCmd(wrap) {
130
+ return {
131
+ command: 'diff [subcommand]',
132
+ describe: 'Compare records across configured profiles (read-only)',
133
+ builder: (y) => y.command({
134
+ command: 'record',
135
+ describe: 'Compare one record across two profiles',
136
+ builder: (b) => b
137
+ .option('table', { type: 'string', demandOption: true, describe: 'ServiceNow table' })
138
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id or exact identifier' })
139
+ .option('profile-a', { type: 'string', demandOption: true, describe: 'Profile for side A' })
140
+ .option('profile-b', { type: 'string', demandOption: true, describe: 'Profile for side B' })
141
+ .option('ignore', { type: 'string', array: true, default: [], describe: 'Additional field to ignore (repeatable)' })
142
+ .option('no-ignore', { type: 'boolean', default: false, describe: 'Do not ignore system noise fields' }),
143
+ handler: wrap(async (argv, app) => {
144
+ const table = String(argv.table || '').trim();
145
+ const identifier = String(argv['sys-id'] || '').trim();
146
+ if (!table || !identifier) throw errUsage('diff record requires --table and --sys-id');
147
+ if (argv['profile-a'] === argv['profile-b']) throw errUsage('Profile A and profile B must be different');
148
+ const targetA = profileTarget(app, argv['profile-a'], 'profile-a');
149
+ const targetB = profileTarget(app, argv['profile-b'], 'profile-b');
150
+ const [sideA, sideB] = await Promise.all([
151
+ fetchRecord(app, targetA, table, identifier),
152
+ fetchRecord(app, targetB, table, identifier),
153
+ ]);
154
+ const ignored = ignoredFields(argv);
155
+ let status = 'error';
156
+ let differences = [];
157
+ if (sideA.status === 'found' && sideB.status === 'found') {
158
+ differences = compareRecords(sideA.record, sideB.record, ignored);
159
+ status = differences.length ? 'different' : 'equal';
160
+ } else if (sideA.status === 'missing' && sideB.status === 'missing') {
161
+ status = 'both_missing';
162
+ } else if (sideA.status === 'missing' || sideB.status === 'missing') {
163
+ status = 'missing';
164
+ } else if (sideA.status === 'forbidden' || sideB.status === 'forbidden') {
165
+ status = 'forbidden';
166
+ } else if (sideA.status === 'auth_error' || sideB.status === 'auth_error') {
167
+ status = 'auth_error';
168
+ }
169
+ const data = {
170
+ table,
171
+ identifiers: { a: identifier, b: identifier },
172
+ profiles: {
173
+ a: { name: targetA.name, instance: targetA.instance },
174
+ b: { name: targetB.name, instance: targetB.instance },
175
+ },
176
+ existence: { a: sideA.status, b: sideB.status },
177
+ errors: { a: sideA.error || null, b: sideB.error || null },
178
+ ignored_fields: ignored,
179
+ status,
180
+ differences,
181
+ _formatted: formatRecordDiff({
182
+ table,
183
+ identifiers: { a: identifier },
184
+ profiles: dataProfiles(targetA, targetB),
185
+ existence: { a: sideA.status, b: sideB.status },
186
+ ignored_fields: ignored,
187
+ status,
188
+ differences,
189
+ }),
190
+ };
191
+ app.ok(data, { summary: `Record diff ${status}: ${table} ${identifier}` });
192
+ }),
193
+ }),
194
+ handler: wrap(async (_argv, app) => {
195
+ app.ok({ command: 'diff', subcommands: ['record'] }, { summary: 'Use `jsn diff record --help` for details.' });
196
+ }),
197
+ };
198
+ }
199
+
200
+ function dataProfiles(targetA, targetB) {
201
+ return {
202
+ a: { name: targetA.name, instance: targetA.instance },
203
+ b: { name: targetB.name, instance: targetB.instance },
204
+ };
205
+ }
@@ -15,6 +15,7 @@ import { searchDocs, getDocByIdOrPath } from './search.js';
15
15
  import { serveDocs } from './serve.js';
16
16
  import { syncCommunity, listCommunityDocs } from './community.js';
17
17
  import { declareCapabilities } from '../../capabilities.js';
18
+ import { formatCommandList } from '../../helpers.js';
18
19
 
19
20
  declareCapabilities('docs', { noInstance: true, skipDailyChecks: true });
20
21
 
@@ -256,21 +257,22 @@ export function docsCmd(wrap) {
256
257
  .demandCommand(1, 'Specify an action: sync or list'),
257
258
  });
258
259
  },
259
- handler: () => {
260
- process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
261
- process.stdout.write('Available subcommands:\n');
262
- process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
263
- process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
264
- process.stdout.write(' search Full-text + semantic search across the local docs index\n');
265
- process.stdout.write(' show Display a full documentation page by id or path\n');
266
- process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
267
- process.stdout.write(' community Sync ServiceNow Community articles (hubs + linked posts) into the index\n');
268
- process.stdout.write('\n');
269
- process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
270
- process.stdout.write('\n');
271
- const dbPath = getDocsDbPath();
272
- process.stdout.write(`Docs DB: ${dbPath} (run "jsn docs status" for details, or query directly with sqlite3)\n`);
273
- process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
274
- },
260
+ handler: wrap(async (argv, app) => {
261
+ const subcommands = argv.subcommands || [];
262
+ const summary = 'jsn docs — ServiceNow documentation search (sync, status, search, show, serve).';
263
+ app.ok({
264
+ command: 'docs',
265
+ subcommands,
266
+ _formatted: formatCommandList({
267
+ title: summary,
268
+ commands: subcommands,
269
+ footer: [
270
+ 'Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.',
271
+ `Docs DB: ${getDocsDbPath()} (run "jsn docs status" for details, or query directly with sqlite3)`,
272
+ 'Run "jsn docs <command> --help" for details.',
273
+ ].join('\n'),
274
+ }),
275
+ }, { summary });
276
+ }),
275
277
  };
276
278
  }
@@ -1,9 +1,30 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
1
+ import fs from 'node:fs';
2
+ import { formatRecordForDisplay, formatCommandList, getStringField, interactiveList } from '../helpers.js';
2
3
  import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../flow-context.js';
3
4
  import { declareCapabilities } from '../capabilities.js';
4
5
  import { inspectFlow } from '../flow-inspection.js';
5
- import { publishFlows, publishDoctor, flowStatus } from '../flow-publish.js';
6
+ import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
6
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
+ import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
+ import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES } from '../flow-triggers.js';
10
+ import { testFlow } from '../flow-test.js';
11
+ import { errUsage } from '../errors.js';
12
+ import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
13
+
14
+ const pickFlow = (argv, app, query = '') => pickDesignerRecord(argv, app, argv.action ? 'sys_hub_action_type_definition' : 'sys_hub_flow', argv.action ? 'action' : 'flow', query);
15
+
16
+ const FLOW_COMMANDS = [
17
+ { name: 'list', description: 'List flows' },
18
+ { name: 'executions', description: 'Show flow executions from sys_flow_context' },
19
+ { name: 'show [identifier]', description: 'Show a flow: structure, inputs, steps and conditions' },
20
+ { name: 'status [identifier]', description: 'Check whether a flow is actually able to run' },
21
+ { name: 'publish [identifier]', description: 'Publish (activate) a flow, or a custom action with --action' },
22
+ { name: 'test [identifier]', description: 'Execute a record-triggered flow against one record' },
23
+ { name: 'create', description: 'Create a draft flow: guided in a terminal, JSON manifest in scripts' },
24
+ { name: 'update [identifier]', description: 'Update a draft flow (alias: edit)' },
25
+ { name: 'doctor', description: 'Check whether this instance can publish flows at all' },
26
+ { name: 'delete [identifier]', description: 'Delete a flow (not implemented: use the Flow Designer UI)' },
27
+ ];
7
28
 
8
29
  function flowInspectionAdapter(app) {
9
30
  return {
@@ -12,7 +33,19 @@ function flowInspectionAdapter(app) {
12
33
  };
13
34
  }
14
35
 
15
- declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish'] });
36
+ // The picker is driven by the instance's own trigger definitions, but this CLI
37
+ // can only create the families it has verified. State both plainly so a created
38
+ // flow never surprises the operator with a trigger that was not really set.
39
+ function triggerGuidance(trigger) {
40
+ return {
41
+ supported_families: [...SUPPORTED_TRIGGER_TYPES].sort(),
42
+ excluded_families: [...EXCLUDED_TRIGGER_TYPES.entries()].map(([type, reason]) => ({ type, reason })).sort((a, b) => a.type.localeCompare(b.type)),
43
+ created_trigger: trigger ? { id: trigger.id, type: trigger.type, name: trigger.name, triggerDefinitionId: trigger.triggerDefinitionId } : null,
44
+ note: 'The trigger picker lists only families this CLI can create and verify. Families the picker omits are reported in excluded_families.',
45
+ };
46
+ }
47
+
48
+ declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'publish', 'test'] });
16
49
 
17
50
  function renderChecks(checks) {
18
51
  return checks.map((c) => {
@@ -22,6 +55,8 @@ function renderChecks(checks) {
22
55
  }
23
56
 
24
57
  export function flowsCmd(wrap) {
58
+ const originalWrap = wrap;
59
+ wrap = handler => originalWrap((argv, app) => cancelled(() => handler(argv, app)));
25
60
  return {
26
61
  command: 'flows [subcommand]',
27
62
  aliases: ['flow'],
@@ -140,7 +175,7 @@ export function flowsCmd(wrap) {
140
175
  }),
141
176
  })
142
177
  .command({
143
- command: 'show <identifier>',
178
+ command: 'show [identifier]',
144
179
  aliases: ['get'],
145
180
  describe: 'Show flow details by name or sys_id',
146
181
  builder: (y) => y
@@ -150,9 +185,11 @@ export function flowsCmd(wrap) {
150
185
  describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
151
186
  }),
152
187
  handler: wrap(async (argv, app) => {
188
+ const identifier = await pickFlow(argv, app);
189
+ if (identifier === undefined) return;
153
190
  const inspection = await inspectFlow({
154
191
  adapter: flowInspectionAdapter(app),
155
- identifier: argv.identifier,
192
+ identifier,
156
193
  instanceURL: app.getEffectiveInstance(),
157
194
  depth: argv.depth,
158
195
  });
@@ -165,23 +202,32 @@ export function flowsCmd(wrap) {
165
202
  }),
166
203
  })
167
204
  .command({
168
- command: 'publish <identifier>',
205
+ command: 'publish [identifier]',
169
206
  aliases: ['activate'],
170
207
  describe: 'Publish (activate) a flow so it can actually run',
171
208
  builder: (y) => y
172
- .option('action', { type: 'boolean', default: false, describe: 'Treat the identifier as a custom action rather than a flow' }),
209
+ .option('action', { type: 'boolean', default: false, describe: 'Treat the identifier as a custom action rather than a flow' })
210
+ .option('force', { type: 'boolean', default: false, describe: 'Confirm publication and activation' }),
173
211
  handler: wrap(async (argv, app) => {
174
212
  app.requireInstance();
213
+ const identifier = await pickFlow(argv, app);
214
+ if (identifier === undefined) return;
175
215
  const sysID = unwrapSysId(await resolveRecord(app.sdk, {
176
216
  table: argv.action ? 'sys_hub_action_type_definition' : 'sys_hub_flow',
177
- identifier: argv.identifier,
217
+ identifier,
178
218
  matchField: 'name',
179
219
  resource: argv.action ? 'Custom action' : 'Flow',
180
220
  }));
181
221
 
182
- const result = argv.action
183
- ? await publishFlows(app.sdk, [], [sysID])
184
- : await publishFlows(app.sdk, [sysID], []);
222
+ await confirmDesignerMutation(argv, app, `Publish and activate ${argv.action ? 'action' : 'flow'} ${sysID}?`);
223
+ if (!argv.action) {
224
+ const lifecycle = await publishFlowLifecycle(app.sdk, app.getEffectiveInstance(), sysID);
225
+ return app.ok(lifecycle, {
226
+ summary: `Published ${sysID}; active=${lifecycle.active}; version=${lifecycle.version?.sys_id ?? lifecycle.version?.id ?? 'read back'}`,
227
+ breadcrumbs: [{ action: 'status', cmd: `jsn flows status ${sysID}`, description: 'Verify the flow will run' }],
228
+ });
229
+ }
230
+ const result = await publishFlows(app.sdk, [], [sysID]);
185
231
 
186
232
  const { summary, results, via } = result;
187
233
  const lines = results.map(r => ` ${r.status === 'success' ? '✅' : '❌'} ${r.flow_name ?? r.sys_id}: ${r.message ?? r.status}${r.errorMessage ? ` — ${r.errorMessage}` : ''}`);
@@ -193,12 +239,14 @@ export function flowsCmd(wrap) {
193
239
  }),
194
240
  })
195
241
  .command({
196
- command: 'status <identifier>',
242
+ command: 'status [identifier]',
197
243
  describe: 'Check whether a flow is actually able to run',
198
244
  handler: wrap(async (argv, app) => {
199
245
  app.requireInstance();
246
+ const identifier = await pickFlow(argv, app);
247
+ if (identifier === undefined) return;
200
248
  const sysID = unwrapSysId(await resolveRecord(app.sdk, {
201
- table: 'sys_hub_flow', identifier: argv.identifier, matchField: 'name', resource: 'Flow',
249
+ table: 'sys_hub_flow', identifier, matchField: 'name', resource: 'Flow',
202
250
  }));
203
251
  const status = await flowStatus(app.sdk, sysID);
204
252
 
@@ -208,6 +256,28 @@ export function flowsCmd(wrap) {
208
256
  });
209
257
  }),
210
258
  })
259
+ .command({
260
+ command: 'test [identifier]',
261
+ describe: 'Execute a record-triggered flow against one record (may cause side effects)',
262
+ builder: (y) => y
263
+ .option('record', { type: 'string', describe: 'Trigger record sys_id to test against' })
264
+ .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
265
+ handler: wrap(async (argv, app) => {
266
+ app.requireInstance();
267
+ const identifier = await pickFlow(argv, app);
268
+ if (identifier === undefined) return;
269
+ argv = { ...argv, identifier };
270
+ const record = await guidedFlowTestRecord(argv, app);
271
+ if (record === undefined) return;
272
+ await confirmDesignerMutation(argv, app, `Execute flow test for ${identifier} against record ${record}; actions may cause side effects`);
273
+ const result = await testFlow(app.sdk, app.getEffectiveInstance(), identifier, record);
274
+ return app.ok(result, {
275
+ summary: result.status === 'completed'
276
+ ? `Flow test completed (${result.success ? 'success' : 'failed'})`
277
+ : 'Flow test accepted for execution',
278
+ });
279
+ }),
280
+ })
211
281
  .command({
212
282
  command: 'doctor',
213
283
  describe: 'Check whether this instance can publish flows at all',
@@ -224,52 +294,84 @@ export function flowsCmd(wrap) {
224
294
  })
225
295
  .command({
226
296
  command: 'create',
227
- describe: 'Create a new flow (not yet implemented)',
297
+ describe: 'Create a draft flow: pick a trigger category then trigger in a terminal, or use a JSON manifest in scripts',
228
298
  builder: (y) => y
229
- .option('data', { type: 'string', describe: 'JSON data for the flow' }),
230
- handler: wrap(async (_argv, _app) => {
231
- throw new Error('Flow creation requires the Flow Designer GraphQL API - not yet implemented.\n'
232
- + 'Use the ServiceNow web UI to create flows, then use "jsn flows list" to view them.');
299
+ .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
300
+ .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
301
+ .option('scope', { type: 'string', describe: 'Transaction scope sys_id or global for the guided picker' }),
302
+ handler: wrap(async (argv, app) => {
303
+ app.requireInstance();
304
+ const dataFile = argv.dataFile ?? argv['data-file'];
305
+ let manifest;
306
+ if (!hasDefinitionData(argv)) manifest = await guidedFlowCreate(argv, app);
307
+ else try {
308
+ const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
309
+ manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
310
+ } catch (error) {
311
+ throw new Error(`Invalid flow manifest JSON: ${error.message}`, { cause: error });
312
+ }
313
+ const result = await createFlowFromManifest(app.sdk, app.getEffectiveInstance(), manifest, {
314
+ readUpdateSet: () => readCurrentUpdateSet(app.sdk),
315
+ });
316
+ app.ok({ ...result, trigger_guidance: triggerGuidance(result.trigger) }, {
317
+ summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) with trigger ${result.trigger?.type ?? 'unknown'} (${result.trigger?.id ?? 'no trigger id'}) without activation`,
318
+ breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the draft flow' }],
319
+ });
233
320
  }),
234
321
  })
235
322
  .command({
236
- command: 'update <identifier>',
237
- describe: 'Update a flow (not yet implemented)',
323
+ command: 'update [identifier]',
324
+ aliases: ['edit'],
325
+ describe: 'Safely update supported fields of an existing inactive Draft flow',
238
326
  builder: (y) => y
239
- .option('data', { type: 'string', describe: 'JSON data to update' }),
240
- handler: wrap(async (_argv, _app) => {
241
- throw new Error('Flow updates require the Flow Designer GraphQL API - not yet implemented.\n'
242
- + 'Use the ServiceNow web UI to update flows, then use "jsn flows list" to view them.');
327
+ .option('data', { type: 'string', describe: 'Update manifest as inline JSON' })
328
+ .option('data-file', { type: 'string', describe: 'Read the update manifest from a JSON file' })
329
+ .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
330
+ handler: wrap(async (argv, app) => {
331
+ app.requireInstance();
332
+ const dataFile = argv.dataFile ?? argv['data-file'];
333
+ const identifier = await pickFlow(argv, app, !hasDefinitionData(argv) ? 'active=false^status=draft' : '');
334
+ if (identifier === undefined) return;
335
+ argv = { ...argv, identifier };
336
+ let manifest;
337
+ if (!hasDefinitionData(argv)) manifest = await guidedFlowEdit(argv, app);
338
+ else try {
339
+ const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
340
+ manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
341
+ } catch (error) {
342
+ throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
343
+ }
344
+ await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
345
+ const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
346
+ readUpdateSet: () => readCurrentUpdateSet(app.sdk),
347
+ });
348
+ return app.ok(result, {
349
+ summary: `Updated draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
350
+ breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
351
+ });
243
352
  }),
244
353
  })
245
354
  .command({
246
- command: 'delete <identifier>',
355
+ command: 'delete [identifier]',
247
356
  describe: 'Delete a flow (not yet implemented)',
248
- handler: wrap(async (_argv, _app) => {
249
- throw new Error('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
357
+ handler: wrap(async (argv, app) => {
358
+ const identifier = await pickFlow(argv, app);
359
+ if (identifier === undefined) return;
360
+ throw errUsage('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
250
361
  + 'Use the ServiceNow web UI to delete flows.');
251
362
  }),
252
363
  });
253
364
  },
254
- handler: () => {
255
- console.log('Manage Flow Designer flows from the sys_hub_flow table.');
256
- console.log('');
257
- console.log('Available subcommands:');
258
- console.log(' list List flows');
259
- console.log(' executions Show flow executions from sys_flow_context');
260
- console.log(' show <identifier> Show flow details by name or sys_id');
261
- console.log(' status <identifier> Check whether a flow is actually able to run');
262
- console.log(' publish <identifier> Publish (activate) a flow');
263
- console.log(' doctor Check whether this instance can publish flows');
264
- console.log(' create Create a new flow (not yet implemented)');
265
- console.log(' update <identifier> Update a flow (not yet implemented)');
266
- console.log(' delete <identifier> Delete a flow (not yet implemented)');
267
- console.log('');
268
- console.log('Run "jsn flows <command> --help" for details.');
269
- console.log('');
270
- console.log('Note: Flow structure comes from the ProcessFlow API (the Flow');
271
- console.log('Designer UI source), so V1, V2, and subflow definitions all');
272
- console.log('render with full action inputs and conditions.');
273
- },
365
+ handler: wrap(async (_argv, app) => {
366
+ const summary = 'Manage Flow Designer flows from the sys_hub_flow table. Flow structure comes from the ProcessFlow API, so V1, V2 and subflow definitions all render in full.';
367
+ app.ok({
368
+ commands: FLOW_COMMANDS.map(command => command.name),
369
+ _formatted: formatCommandList({
370
+ title: summary,
371
+ commands: FLOW_COMMANDS,
372
+ footer: 'Run "jsn flows <command> --help" for details. In a terminal you can omit the target and pick one; scripts use explicit identifiers and manifests.',
373
+ }),
374
+ }, { summary });
375
+ }),
274
376
  };
275
377
  }