@jacebenson/jsn 4.3.7-dev.33 → 4.3.7-dev.34

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
@@ -97,8 +97,27 @@ jsn logs follow --level error --tail 10
97
97
  ### Flow Designer actions
98
98
 
99
99
  `actions` and `action` are the same command. `list`, `show`, and explicit
100
- `delete` remain available. Create and edit use full Process Flow JSON, not
101
- ordinary table-field updates.
100
+ `delete` remain available. In a human terminal, omit the target to open a
101
+ searchable picker. Create and edit guide you through provider schemas and save
102
+ full Process Flow definitions, not ordinary table-field updates.
103
+
104
+ ```bash
105
+ jsn actions create # Guided scope, name, schemas, inputs and script
106
+ jsn actions edit # Pick an action, edit its saved definition
107
+ jsn actions definition # Pick and inspect a complete definition
108
+ jsn actions test --wait # Pick an action, supply saved-schema inputs, confirm
109
+ jsn actions step-types # Pick the exact transaction scope
110
+ ```
111
+
112
+ Script inputs offer a file path or your `$VISUAL`/`$EDITOR`. New script steps can
113
+ declare outputs and map them to action outputs. Variable declarations reuse
114
+ provider schema templates and receive fresh UI identities. Unknown nested
115
+ schema fields are retained. Unsupported input types stop with instructions to
116
+ use `--data-file`; JSN does not silently omit them or invent schema defaults.
117
+ Creation waits for an explicit final confirmation before allocating a parent.
118
+ It saves a draft only and never publishes or runs it automatically.
119
+
120
+ For automation, keep using explicit identifiers and full JSON:
102
121
 
103
122
  ```bash
104
123
  jsn actions step-types --scope <scope_sys_id> --get data > step-types.json
@@ -177,9 +196,43 @@ These internal endpoints and runtime-table permissions can vary by instance.
177
196
 
178
197
  ### Flow execution fields
179
198
 
199
+ ### Flow Designer terminal workflows
200
+
201
+ ```bash
202
+ jsn flows create # Guided record trigger and published action inputs
203
+ jsn flows edit # Pick an inactive Draft, edit condition or existing inputs
204
+ jsn flows show # Pick a flow to inspect
205
+ jsn flows status # Pick a flow to diagnose
206
+ jsn flows test # Pick flow and a record from its actual trigger table, confirm
207
+ jsn flows publish # Pick a flow, explicitly confirm activation
208
+ # Automation must explicitly confirm publication too:
209
+ jsn flows publish <flow_sys_id> --force --json
210
+ ```
211
+
212
+ Wizards and omitted-target pickers require both a prompt-capable terminal and
213
+ `auto` output. `--json`, explicit formats, `--get`, `--data`, `--data-file`,
214
+ `--output-map`, pipes, `CI`, `NONINTERACTIVE` and `JSN_NO_PROMPTS=1` never open a
215
+ wizard. In those modes provide identifiers and required scope/record/data
216
+ explicitly. Tests, publish and deletion require `--force` or the profile's
217
+ existing explicit skip-confirmations setting when prompting is unavailable.
218
+ Read-only profiles still block mutations before authentication or prompting.
219
+ Press Escape in record pickers or Ctrl+C in any wizard to cancel. Refusing a
220
+ final confirmation leaves the instance untouched.
221
+
222
+ Existing action/flow scopes come from saved raw identifiers. New scopes offer
223
+ the active application preference if available, then a searchable scope picker.
224
+ No scope name is guessed. Input pills are passed through unchanged.
225
+
226
+ Flow edits are limited to the existing manifest contract. They do not rename
227
+ flows, add/remove steps, implement Flow Logic, subflows, variables or error
228
+ handling. Flow deletion remains unsupported. Action deletion retains its
229
+ existing explicit parent-record cleanup and absence readback, not a new native
230
+ builder deletion lifecycle. See [command coverage and local proof](docs/flow-designer-interactive.md).
231
+
180
232
  ### Flow creation manifest
181
233
 
182
- `jsn flows create` accepts `--data-file` or inline `--data`. The first slice
234
+ `jsn flows create` accepts `--data-file` or inline `--data`, or guides human
235
+ terminal users without those options. The supported slice
183
236
  supports one `record_create_or_update` trigger and published Action types whose
184
237
  input schemas are available from the instance. JSN resolves action metadata,
185
238
  defaults, and typed inputs from ServiceNow; callers do not provide GraphQL or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.33",
3
+ "version": "4.3.7-dev.34",
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": {
@@ -181,11 +181,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
181
181
  }),
182
182
  })
183
183
  .command({
184
- command: 'show <identifier>',
184
+ command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
185
185
  aliases: ['get'],
186
186
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
187
187
  handler: wrap(async (argv, app) => {
188
- const id = argv.identifier;
188
+ const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
189
+ if (id === undefined) return;
189
190
  // Only restrict sysparm_fields if showFields is explicitly set.
190
191
  // Go version fetches all fields for show unless explicitly restricted.
191
192
  const fields = (showFields && showFields.length > 0)
@@ -1,15 +1,19 @@
1
1
  import { buildDevCmd } from './_generic.js';
2
- import { parseDataArg, confirmDelete } from '../helpers.js';
2
+ import { parseDataArg } from '../helpers.js';
3
3
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
- import { ACTION_TABLE, actionScope, validateScope } from '../processflow-actions.js';
5
+ import { ACTION_TABLE, actionScope } from '../processflow-actions.js';
6
6
  import { errUsage } from '../errors.js';
7
+ import { cancelled, pickDesignerRecord, designerScope, guidedActionCreate, guidedActionEdit, guidedActionTestInputs, interactiveDesigner, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
7
8
 
8
9
  const scopeOption = y => y.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id; inferred from existing action when omitted' });
9
10
  const dataOptions = y => y
10
11
  .option('data-file', { type: 'string', describe: 'Full action definition JSON file, not table fields' })
11
12
  .option('data', { type: 'string', describe: 'Full action definition as JSON' });
12
13
  async function actionID(argv, app) {
14
+ const identifier = await pickDesignerRecord(argv, app, ACTION_TABLE, 'action');
15
+ if (identifier === undefined) return undefined;
16
+ argv = { ...argv, identifier };
13
17
  return unwrapSysId(await resolveRecord(app.sdk, { table: ACTION_TABLE, identifier: argv.identifier, matchField: 'name', resource: 'action' }));
14
18
  }
15
19
 
@@ -17,6 +21,7 @@ export const actionsCmd = wrap => {
17
21
  // Reuse the existing list/show picker and output contract only.
18
22
  const base = buildDevCmd('actions', ACTION_TABLE, ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, {
19
23
  singular: 'action', readOnly: true,
24
+ resolveIdentifier: (argv, app) => cancelled(() => pickDesignerRecord(argv, app, ACTION_TABLE, 'action')),
20
25
  showBreadcrumbs: (_record, id) => [
21
26
  { action: 'definition', cmd: `jsn actions definition ${id}`, description: 'Read complete executable definition' },
22
27
  { action: 'list', cmd: 'jsn actions list', description: 'Back to actions' },
@@ -26,7 +31,7 @@ export const actionsCmd = wrap => {
26
31
  // Retain the common wrap and output controller; domain failures get a
27
32
  // structured error envelope and nonzero exit, never a success-shaped dispatch.
28
33
  const run = handler => wrap(async (argv, app) => {
29
- try { await handler(argv, app); }
34
+ try { await cancelled(() => handler(argv, app)); }
30
35
  catch (error) {
31
36
  if (!error.code?.startsWith('action_')) throw error;
32
37
  if (error.details) error.hint = JSON.stringify(error.details);
@@ -40,37 +45,43 @@ export const actionsCmd = wrap => {
40
45
  builder: y => base.builder(y)
41
46
  .command({
42
47
  command: 'create', describe: 'Create parent and executable Process Flow action; verify persistence',
43
- builder: yy => dataOptions(yy).option('scope', { type: 'string', demandOption: true, describe: 'Exact target scope sys_id, or global' }),
48
+ builder: yy => dataOptions(yy).option('scope', { type: 'string', describe: 'Exact target scope sys_id, or global' }),
44
49
  handler: run(async (argv, app) => {
45
- const definition = parseDataArg(argv);
46
- const result = await app.sdk.createProcessFlowAction(argv.scope, definition);
50
+ const guided = !hasDefinitionData(argv) ? await guidedActionCreate(argv, app) : undefined;
51
+ const definition = guided?.definition ?? parseDataArg(argv);
52
+ const scope = guided?.scope ?? await designerScope(argv, app);
53
+ const result = await app.sdk.createProcessFlowAction(scope, definition);
47
54
  app.ok(result, { summary: `Created and verified action ${result.sys_id}` });
48
55
  }),
49
56
  })
50
57
  .command({
51
- command: 'definition <identifier>', describe: 'Read full JSON definition including executable step_instances',
58
+ command: 'definition [identifier]', describe: 'Read full JSON definition including executable step_instances',
52
59
  builder: scopeOption,
53
60
  handler: run(async (argv, app) => {
54
61
  const id = await actionID(argv, app);
62
+ if (id === undefined) return;
55
63
  const scope = await actionScope(app.sdk, id, argv.scope);
56
64
  app.ok(await app.sdk.getProcessFlowAction(id, scope), { summary: `Read action definition ${id}` });
57
65
  }),
58
66
  })
59
67
  .command({
60
- command: 'update <identifier>', aliases: ['edit'], describe: 'Save a full definition through Process Flow PUT and verify readback',
68
+ command: 'update [identifier]', aliases: ['edit'], describe: 'Save a full definition through Process Flow PUT and verify readback',
61
69
  builder: yy => dataOptions(scopeOption(yy)),
62
70
  handler: run(async (argv, app) => {
63
- const definition = parseDataArg(argv);
71
+ if (!hasDefinitionData(argv) && !interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the full action definition in noninteractive mode');
64
72
  const id = await actionID(argv, app);
73
+ if (id === undefined) return;
74
+ const definition = hasDefinitionData(argv) ? parseDataArg(argv)
75
+ : await guidedActionEdit(argv, app, await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope)));
65
76
  const result = await app.sdk.updateProcessFlowAction(id, argv.scope, definition);
66
77
  app.ok(result, { summary: `Saved and verified action ${id}` });
67
78
  }),
68
79
  })
69
80
  .command({
70
- command: 'test <identifier>', describe: 'Execute saved or supplied definition; optionally wait for actual outputs',
81
+ command: 'test [identifier]', describe: 'Execute saved or supplied definition; optionally wait for actual outputs',
71
82
  builder: yy => dataOptions(scopeOption(yy))
72
83
  .option('force', { type: 'boolean', default: false, describe: 'Confirm execution of action scripts, which may have side effects' })
73
- .option('output-map', { type: 'string', default: '{}', describe: 'JSON action input names to values; sent as outputMap' })
84
+ .option('output-map', { type: 'string', describe: 'JSON action input names to values; sent as outputMap' })
74
85
  .option('run-on-thread', { type: 'boolean', default: true, describe: 'Run test on a worker thread' })
75
86
  .option('tracing-enabled', { type: 'boolean', default: false, describe: 'Enable Flow Designer tracing' })
76
87
  .option('wait', { type: 'boolean', default: false, describe: 'Wait for COMPLETE context and actual runtime outputs' })
@@ -79,9 +90,14 @@ export const actionsCmd = wrap => {
79
90
  let outputMap;
80
91
  try { outputMap = JSON.parse(argv['output-map'] ?? '{}'); }
81
92
  catch { throw errUsage('--output-map must be valid JSON'); }
82
- const definition = argv['data-file'] || argv.data ? parseDataArg(argv) : undefined;
93
+ let definition = hasDefinitionData(argv) ? parseDataArg(argv) : undefined;
83
94
  const id = await actionID(argv, app);
84
- await confirmDelete(app, argv, `Execute action '${argv.identifier}' scripts, which may have side effects`);
95
+ if (id === undefined) return;
96
+ if (argv['output-map'] === undefined && interactiveDesigner(argv, app)) {
97
+ definition = await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope));
98
+ outputMap = await guidedActionTestInputs(argv, app, definition);
99
+ }
100
+ await confirmDesignerMutation(argv, app, `Execute action '${id}' scripts, which may have side effects`);
85
101
  const result = await app.sdk.testProcessFlowAction(id, argv.scope, definition, outputMap, {
86
102
  wait: argv.wait, timeout: argv.timeout, runOnThread: argv['run-on-thread'], tracingEnabled: argv['tracing-enabled'],
87
103
  });
@@ -90,19 +106,20 @@ export const actionsCmd = wrap => {
90
106
  })
91
107
  .command({
92
108
  command: 'step-types', describe: 'Read step schema templates for a target scope',
93
- builder: yy => yy.option('scope', { type: 'string', demandOption: true, describe: 'Exact transaction scope sys_id, or global' }),
109
+ builder: yy => yy.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id, or global' }),
94
110
  handler: run(async (argv, app) => {
95
- await validateScope(app.sdk, argv.scope);
96
- app.ok(await app.sdk.getProcessFlowStepTypes(argv.scope), { summary: 'Process Flow step schemas' });
111
+ const scope = await designerScope(argv, app);
112
+ app.ok(await app.sdk.getProcessFlowStepTypes(scope), { summary: 'Process Flow step schemas' });
97
113
  }),
98
114
  })
99
115
  .command({
100
- command: 'delete <identifier>', describe: 'Delete action parent explicitly',
116
+ command: 'delete [identifier]', describe: 'Delete action parent explicitly',
101
117
  builder: yy => scopeOption(yy).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
102
118
  handler: run(async (argv, app) => {
103
119
  const id = await actionID(argv, app);
120
+ if (id === undefined) return;
104
121
  await actionScope(app.sdk, id, argv.scope);
105
- await confirmDelete(app, argv, `Delete action '${argv.identifier}'`);
122
+ await confirmDesignerMutation(argv, app, `Delete action '${id}'`);
106
123
  await app.sdk.delete(ACTION_TABLE, id);
107
124
  // Verify the exact target is absent. Do not interpret a 403 as deletion.
108
125
  const query = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_limit: '1', sysparm_fields: 'sys_id', sysparm_display_value: 'false' });
@@ -115,7 +132,7 @@ export const actionsCmd = wrap => {
115
132
  }),
116
133
  handler: wrap(async (_argv, app) => {
117
134
  app.ok({ commands: ['list', 'show', 'definition', 'step-types', 'create', 'update', 'test', 'delete'] }, {
118
- summary: 'Use jsn actions <command> --help. Create/update require full JSON definitions, not table fields.',
135
+ summary: 'Use jsn actions <command> --help. Create/update guide terminal users; automation uses full JSON definitions, not table fields.',
119
136
  });
120
137
  }),
121
138
  };
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { formatRecordForDisplay, getStringField, interactiveList, confirmDelete } from '../helpers.js';
2
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
3
3
  import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../flow-context.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
  import { inspectFlow } from '../flow-inspection.js';
@@ -7,6 +7,10 @@ import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '.
7
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
8
  import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
9
  import { testFlow } from '../flow-test.js';
10
+ import { errUsage } from '../errors.js';
11
+ import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
12
+
13
+ const pickFlow = (argv, app, query = '') => pickDesignerRecord(argv, app, argv.action ? 'sys_hub_action_type_definition' : 'sys_hub_flow', argv.action ? 'action' : 'flow', query);
10
14
 
11
15
  function flowInspectionAdapter(app) {
12
16
  return {
@@ -15,7 +19,7 @@ function flowInspectionAdapter(app) {
15
19
  };
16
20
  }
17
21
 
18
- declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish', 'test'] });
22
+ declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'publish', 'test'] });
19
23
 
20
24
  function renderChecks(checks) {
21
25
  return checks.map((c) => {
@@ -25,6 +29,8 @@ function renderChecks(checks) {
25
29
  }
26
30
 
27
31
  export function flowsCmd(wrap) {
32
+ const originalWrap = wrap;
33
+ wrap = handler => originalWrap((argv, app) => cancelled(() => handler(argv, app)));
28
34
  return {
29
35
  command: 'flows [subcommand]',
30
36
  aliases: ['flow'],
@@ -143,7 +149,7 @@ export function flowsCmd(wrap) {
143
149
  }),
144
150
  })
145
151
  .command({
146
- command: 'show <identifier>',
152
+ command: 'show [identifier]',
147
153
  aliases: ['get'],
148
154
  describe: 'Show flow details by name or sys_id',
149
155
  builder: (y) => y
@@ -153,9 +159,11 @@ export function flowsCmd(wrap) {
153
159
  describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
154
160
  }),
155
161
  handler: wrap(async (argv, app) => {
162
+ const identifier = await pickFlow(argv, app);
163
+ if (identifier === undefined) return;
156
164
  const inspection = await inspectFlow({
157
165
  adapter: flowInspectionAdapter(app),
158
- identifier: argv.identifier,
166
+ identifier,
159
167
  instanceURL: app.getEffectiveInstance(),
160
168
  depth: argv.depth,
161
169
  });
@@ -168,20 +176,24 @@ export function flowsCmd(wrap) {
168
176
  }),
169
177
  })
170
178
  .command({
171
- command: 'publish <identifier>',
179
+ command: 'publish [identifier]',
172
180
  aliases: ['activate'],
173
181
  describe: 'Publish (activate) a flow so it can actually run',
174
182
  builder: (y) => y
175
- .option('action', { type: 'boolean', default: false, describe: 'Treat the identifier as a custom action rather than a flow' }),
183
+ .option('action', { type: 'boolean', default: false, describe: 'Treat the identifier as a custom action rather than a flow' })
184
+ .option('force', { type: 'boolean', default: false, describe: 'Confirm publication and activation' }),
176
185
  handler: wrap(async (argv, app) => {
177
186
  app.requireInstance();
187
+ const identifier = await pickFlow(argv, app);
188
+ if (identifier === undefined) return;
178
189
  const sysID = unwrapSysId(await resolveRecord(app.sdk, {
179
190
  table: argv.action ? 'sys_hub_action_type_definition' : 'sys_hub_flow',
180
- identifier: argv.identifier,
191
+ identifier,
181
192
  matchField: 'name',
182
193
  resource: argv.action ? 'Custom action' : 'Flow',
183
194
  }));
184
195
 
196
+ await confirmDesignerMutation(argv, app, `Publish and activate ${argv.action ? 'action' : 'flow'} ${sysID}?`);
185
197
  if (!argv.action) {
186
198
  const lifecycle = await publishFlowLifecycle(app.sdk, app.getEffectiveInstance(), sysID);
187
199
  return app.ok(lifecycle, {
@@ -201,12 +213,14 @@ export function flowsCmd(wrap) {
201
213
  }),
202
214
  })
203
215
  .command({
204
- command: 'status <identifier>',
216
+ command: 'status [identifier]',
205
217
  describe: 'Check whether a flow is actually able to run',
206
218
  handler: wrap(async (argv, app) => {
207
219
  app.requireInstance();
220
+ const identifier = await pickFlow(argv, app);
221
+ if (identifier === undefined) return;
208
222
  const sysID = unwrapSysId(await resolveRecord(app.sdk, {
209
- table: 'sys_hub_flow', identifier: argv.identifier, matchField: 'name', resource: 'Flow',
223
+ table: 'sys_hub_flow', identifier, matchField: 'name', resource: 'Flow',
210
224
  }));
211
225
  const status = await flowStatus(app.sdk, sysID);
212
226
 
@@ -217,15 +231,20 @@ export function flowsCmd(wrap) {
217
231
  }),
218
232
  })
219
233
  .command({
220
- command: 'test <identifier>',
234
+ command: 'test [identifier]',
221
235
  describe: 'Execute a record-triggered flow against one record (may cause side effects)',
222
236
  builder: (y) => y
223
- .option('record', { type: 'string', demandOption: true, describe: 'Trigger record sys_id to test against' })
237
+ .option('record', { type: 'string', describe: 'Trigger record sys_id to test against' })
224
238
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
225
239
  handler: wrap(async (argv, app) => {
226
240
  app.requireInstance();
227
- await confirmDelete(app, argv, `Execute flow test for ${argv.identifier} against record ${argv.record}; actions may cause side effects`);
228
- const result = await testFlow(app.sdk, app.getEffectiveInstance(), argv.identifier, argv.record);
241
+ const identifier = await pickFlow(argv, app);
242
+ if (identifier === undefined) return;
243
+ argv = { ...argv, identifier };
244
+ const record = await guidedFlowTestRecord(argv, app);
245
+ if (record === undefined) return;
246
+ await confirmDesignerMutation(argv, app, `Execute flow test for ${identifier} against record ${record}; actions may cause side effects`);
247
+ const result = await testFlow(app.sdk, app.getEffectiveInstance(), identifier, record);
229
248
  return app.ok(result, {
230
249
  summary: result.status === 'completed'
231
250
  ? `Flow test completed (${result.success ? 'success' : 'failed'})`
@@ -249,16 +268,16 @@ export function flowsCmd(wrap) {
249
268
  })
250
269
  .command({
251
270
  command: 'create',
252
- describe: 'Create a draft flow from a schema-driven JSON manifest',
271
+ describe: 'Guide draft flow creation in a terminal, or use a schema-driven JSON manifest',
253
272
  builder: (y) => y
254
273
  .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
255
274
  .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' }),
256
275
  handler: wrap(async (argv, app) => {
257
276
  app.requireInstance();
258
277
  const dataFile = argv.dataFile ?? argv['data-file'];
259
- if (!dataFile && argv.data === undefined) throw new Error('Provide --data-file or --data for the flow manifest');
260
278
  let manifest;
261
- try {
279
+ if (!hasDefinitionData(argv)) manifest = await guidedFlowCreate(argv, app);
280
+ else try {
262
281
  const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
263
282
  manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
264
283
  } catch (error) {
@@ -274,7 +293,8 @@ export function flowsCmd(wrap) {
274
293
  }),
275
294
  })
276
295
  .command({
277
- command: 'update <identifier>',
296
+ command: 'update [identifier]',
297
+ aliases: ['edit'],
278
298
  describe: 'Safely update supported fields of an existing inactive Draft flow',
279
299
  builder: (y) => y
280
300
  .option('data', { type: 'string', describe: 'Update manifest as inline JSON' })
@@ -283,15 +303,18 @@ export function flowsCmd(wrap) {
283
303
  handler: wrap(async (argv, app) => {
284
304
  app.requireInstance();
285
305
  const dataFile = argv.dataFile ?? argv['data-file'];
286
- if (!dataFile && argv.data === undefined) throw new Error('Provide --data-file or --data for the flow update manifest');
306
+ const identifier = await pickFlow(argv, app, !hasDefinitionData(argv) ? 'active=false^status=draft' : '');
307
+ if (identifier === undefined) return;
308
+ argv = { ...argv, identifier };
287
309
  let manifest;
288
- try {
310
+ if (!hasDefinitionData(argv)) manifest = await guidedFlowEdit(argv, app);
311
+ else try {
289
312
  const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
290
313
  manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
291
314
  } catch (error) {
292
315
  throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
293
316
  }
294
- await confirmDelete(app, argv, `Update draft flow ${argv.identifier}; unspecified components and inputs are preserved`);
317
+ await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
295
318
  const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
296
319
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
297
320
  });
@@ -302,34 +325,20 @@ export function flowsCmd(wrap) {
302
325
  }),
303
326
  })
304
327
  .command({
305
- command: 'delete <identifier>',
328
+ command: 'delete [identifier]',
306
329
  describe: 'Delete a flow (not yet implemented)',
307
- handler: wrap(async (_argv, _app) => {
308
- throw new Error('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
330
+ handler: wrap(async (argv, app) => {
331
+ const identifier = await pickFlow(argv, app);
332
+ if (identifier === undefined) return;
333
+ throw errUsage('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
309
334
  + 'Use the ServiceNow web UI to delete flows.');
310
335
  }),
311
336
  });
312
337
  },
313
- handler: () => {
314
- console.log('Manage Flow Designer flows from the sys_hub_flow table.');
315
- console.log('');
316
- console.log('Available subcommands:');
317
- console.log(' list List flows');
318
- console.log(' executions Show flow executions from sys_flow_context');
319
- console.log(' show <identifier> Show flow details by name or sys_id');
320
- console.log(' status <identifier> Check whether a flow is actually able to run');
321
- console.log(' publish <identifier> Publish (activate) a flow');
322
- console.log(' test <identifier> Execute a record-triggered flow against one record');
323
- console.log(' doctor Check whether this instance can publish flows');
324
- console.log(' create Create a draft flow from a JSON manifest');
325
- console.log(' update <identifier> Safely update supported fields of an existing Draft flow');
326
- console.log(' delete <identifier> Delete a flow (not yet implemented)');
327
- console.log('');
328
- console.log('Run "jsn flows <command> --help" for details.');
329
- console.log('');
330
- console.log('Note: Flow structure comes from the ProcessFlow API (the Flow');
331
- console.log('Designer UI source), so V1, V2, and subflow definitions all');
332
- console.log('render with full action inputs and conditions.');
333
- },
338
+ handler: wrap(async (_argv, app) => {
339
+ app.ok({ commands: ['list', 'executions', 'show', 'status', 'publish', 'test', 'doctor', 'create', 'update', 'delete'] }, {
340
+ summary: 'Use jsn flows <command> --help. Human terminals can omit targets and guide create/edit; automation uses explicit identifiers and manifests.',
341
+ });
342
+ }),
334
343
  };
335
344
  }
package/src/errors.js CHANGED
@@ -204,6 +204,11 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
204
204
  // 'usage' is the legacy literal; CodeUsage ('usage_error') is the
205
205
  // exported constant. Both render as the usage class.
206
206
  if (code === CodeUsage || code === 'usage') {
207
+ // Interactive command families can opt into machine-readable fail-fast errors
208
+ // without changing the legacy stderr contract for other usage errors.
209
+ if (err.jsonEnvelope === true && resolveJsonMode(app, argv) && app) {
210
+ return { stream: 'stdout', text: null, exitCode: 2, appErr: true };
211
+ }
207
212
  return { stream: 'stderr', text: `Error (usage): ${err.message}\n`, exitCode: 2 };
208
213
  }
209
214
 
@@ -91,7 +91,7 @@ function assertGraphQLSuccess(response, operation) {
91
91
  if (response?.result?.errorMessage) throw new Error(`${operation} failed: ${response.result.errorMessage}`);
92
92
  }
93
93
 
94
- function schemaEntries(definition) {
94
+ function schemaEntries(definition, requireTypes = false) {
95
95
  const entries = [];
96
96
  const seen = new Set();
97
97
  walk(definition, object => {
@@ -102,6 +102,7 @@ function schemaEntries(definition) {
102
102
  const name = stringValue(field.name || field.key || field.element || field.parameter);
103
103
  if (!name || seen.has(name)) continue;
104
104
  seen.add(name);
105
+ if (requireTypes && !stringValue(field.type || field.inputType || field.valueType)) throw new Error(`Input ${name} has no provider type; refusing to invent a guided schema`);
105
106
  entries.push({
106
107
  ...field,
107
108
  name,
@@ -193,7 +194,7 @@ export function buildFlowUpdateMutation({ flowId, triggerUpdates = [], actionUpd
193
194
  return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { updates } actions { updates } } } } }`;
194
195
  }
195
196
 
196
- async function readProcessFlow(sdk, instance, flowId, scope) {
197
+ export async function readProcessFlow(sdk, instance, flowId, scope) {
197
198
  if (!scope) throw new Error(`Flow ${flowId} has no transaction scope for ProcessFlow read`);
198
199
  const endpoint = `${instance}${FLOW_ENDPOINT}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
199
200
  const response = await sdk.request(endpoint, { method: 'GET' });
@@ -220,11 +221,11 @@ async function resolveActionType(sdk, actionType) {
220
221
  return { id: actionTypeId(record), record };
221
222
  }
222
223
 
223
- async function readActionDefinition(sdk, instance, actionTypeId, scope) {
224
+ export async function readActionDefinition(sdk, instance, actionTypeId, scope, { requireTypes = false } = {}) {
224
225
  const endpoint = `${instance}/api/now/processflow/action/action_types/${encodeURIComponent(actionTypeId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
225
226
  const response = await sdk.request(endpoint, { method: 'GET' });
226
227
  const definition = response?.result ?? response?.data?.result ?? response?.data ?? response;
227
- const schema = schemaEntries(definition);
228
+ const schema = schemaEntries(definition, requireTypes);
228
229
  if (schema.length === 0) throw new Error(`Action type ${actionTypeId} returned no input schema`);
229
230
  return { definition, schema };
230
231
  }
@@ -257,7 +258,7 @@ function parseSimpleCondition(condition) {
257
258
  return { field: match[1], operator: match[2], term: match[3] };
258
259
  }
259
260
 
260
- async function readTableDescriptor(sdk, instance, scope, table, override) {
261
+ export async function readTableDescriptor(sdk, instance, scope, table, override) {
261
262
  if (override) return override(table);
262
263
  const endpoint = `${instance}${TABLE_DESCRIPTOR_ENDPOINT}?table=${encodeURIComponent(table)}&sysparm_transaction_scope=${encodeURIComponent(scope)}`;
263
264
  const response = await sdk.request(endpoint, { method: 'GET' });
@@ -0,0 +1,327 @@
1
+ // Terminal orchestration only. Persistence and lifecycle remain in the SDK.
2
+ import fs from 'node:fs';
3
+ import { randomUUID, randomBytes } from 'node:crypto';
4
+ import { input, select, confirm, editor } from '@inquirer/prompts';
5
+ import { canPrompt, interactiveList, getStringField } from './helpers.js';
6
+ import { FormatAuto } from './output.js';
7
+ import { errUsage, errConfirmationRequired } from './errors.js';
8
+ import { getCurrentUser, getCurrentApplication } from './context.js';
9
+ import { validateDefinition, validateScope, ACTION_TABLE } from './processflow-actions.js';
10
+ import { validateFlowManifest, validateFlowUpdateManifest, readProcessFlow, readActionDefinition, readTableDescriptor } from './flow-create.js';
11
+ import { deriveRecordTrigger } from './flow-test.js';
12
+ import { getActiveProfile } from './config.js';
13
+
14
+ const CANCEL = Symbol('cancel');
15
+ const clone = value => structuredClone(value);
16
+ export const rawField = value => value && typeof value === 'object' && 'value' in value ? value.value : value;
17
+ const prompts = app => app.flowDesignerPrompts || { input, select, confirm, editor };
18
+ export function hasDefinitionData(argv) {
19
+ return argv.data !== undefined || argv['data-file'] !== undefined || argv.dataFile !== undefined;
20
+ }
21
+ export function interactiveDesigner(argv, app) {
22
+ return !hasDefinitionData(argv) && argv['output-map'] === undefined && !argv.get && app.output?.getFormat() === FormatAuto && canPrompt();
23
+ }
24
+ export async function cancelled(operation) {
25
+ try { return await operation(); }
26
+ catch (error) {
27
+ if (error === CANCEL || ['ExitPromptError', 'AbortPromptError'].includes(error.name)) return undefined;
28
+ // Keep direct handler errors throwable; opt into the shared JSON renderer at the CLI boundary.
29
+ if (error.code === 'usage_error') error.jsonEnvelope = true;
30
+ throw error;
31
+ }
32
+ }
33
+ export async function pickDesignerRecord(argv, app, table, singular, query = '', labelField = 'name') {
34
+ if (argv.identifier !== undefined) {
35
+ if (!String(argv.identifier).trim()) throw errUsage(`${singular} identifier must not be empty`);
36
+ return argv.identifier;
37
+ }
38
+ if (!interactiveDesigner(argv, app)) throw errUsage(`Provide an explicit ${singular} identifier in noninteractive mode`);
39
+ const picked = await interactiveList({ app, table, singular, query, labelField,
40
+ columns: [labelField], promptFn: app.promptFn });
41
+ if (picked === null) throw errUsage(`No readable ${singular} records are available for selection`);
42
+ if (picked === undefined) return undefined;
43
+ const id = rawField(picked.sys_id);
44
+ if (!id) throw errUsage(`Selected ${singular} has no raw sys_id`);
45
+ return id;
46
+ }
47
+ export async function designerScope(argv, app) {
48
+ if (argv.scope !== undefined) return validateScope(app.sdk, argv.scope);
49
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --scope <exact sys_id or global> in noninteractive mode');
50
+ const user = await getCurrentUser(app.sdk);
51
+ const current = user ? await getCurrentApplication(app.sdk, user.sys_id) : null;
52
+ if (current?.appSysId && await prompts(app).confirm({ message: `Use current application scope ${current.appSysId}?`, default: true })) {
53
+ return validateScope(app.sdk, current.appSysId);
54
+ }
55
+ const scope = await pickDesignerRecord(argv, app, 'sys_scope', 'scope');
56
+ if (scope === undefined) throw CANCEL;
57
+ return validateScope(app.sdk, scope);
58
+ }
59
+ export async function confirmDesignerSave(app, message) {
60
+ if (!await prompts(app).confirm({ message, default: false })) throw CANCEL;
61
+ }
62
+ // Explicit JSON/data, pipes and disabled prompts never enter a TTY confirmation.
63
+ export async function confirmDesignerMutation(argv, app, message) {
64
+ if (argv.force) return;
65
+ if (interactiveDesigner(argv, app)) return confirmDesignerSave(app, message);
66
+ if (getActiveProfile(app.config)?.skip_confirmations === true) return;
67
+ throw errConfirmationRequired(message, ['jsn', ...(argv._ || [])].join(' '));
68
+ }
69
+ function required(value) { return String(value).trim() ? true : 'A value is required'; }
70
+ function scalarDefault(value) { return value == null ? '' : typeof value === 'string' ? value : JSON.stringify(value); }
71
+ function schemaObjectValue(field, value) {
72
+ return ['object', 'complex'].includes(field.type) && value && typeof value === 'object'
73
+ && !('schemaless' in value) && field.children?.some(child => Object.hasOwn(value, child.name));
74
+ }
75
+
76
+ // Preserve schema objects. Change only a requested value, never regenerate metadata.
77
+ export async function promptSchemaValue(app, field, existing, prefix = '') {
78
+ if (!field?.name || !field.type) throw errUsage('Provider returned an incomplete named, typed input schema');
79
+ const p = prompts(app);
80
+ const message = `${prefix}${field.label || field.name} [${field.type}]`;
81
+ const supplied = existing ?? field.defaultValue ?? field.default;
82
+ const value = schemaObjectValue(field, supplied) ? supplied : rawField(supplied);
83
+ if (field.type === 'script') {
84
+ const method = await p.select({ message: `${message}: script source`, choices: [
85
+ { name: 'Script file', value: 'file' }, { name: 'Open $VISUAL/$EDITOR', value: 'editor' },
86
+ ] });
87
+ const script = method === 'file'
88
+ ? fs.readFileSync(await p.input({ message: 'Script file path', validate: required }), 'utf8')
89
+ : await p.editor({ message, default: scalarDefault(value), waitForUse: true });
90
+ if ((field.mandatory === true || field.required === true) && !script.trim()) throw errUsage(`Script ${field.name} is required; nothing was saved`);
91
+ return script;
92
+ }
93
+ if (typeof value === 'string' && value.startsWith('{{')) {
94
+ return p.input({ message: `${message} (raw pill)`, default: value, validate: required });
95
+ }
96
+ if (field.children?.length && ['object', 'complex'].includes(field.type)) {
97
+ const result = value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
98
+ for (const child of field.children) result[child.name] = await promptSchemaValue(app, child, result[child.name], `${message}.`);
99
+ return result;
100
+ }
101
+ if (['boolean', 'bool'].includes(field.type)) {
102
+ return p.confirm({ message, default: value === true || value === 'true' });
103
+ }
104
+ if (field.type === 'choice' && Array.isArray(field.choices) && field.choices.length) {
105
+ if (field.choices.some(choice => typeof choice !== 'string' && (!choice || !Object.hasOwn(choice, 'value')))) {
106
+ throw errUsage(`Choice input ${field.name} has incomplete provider choices; nothing was saved`);
107
+ }
108
+ return p.select({ message, default: value, choices: field.choices.map(choice => ({
109
+ name: typeof choice === 'string' ? choice : String(choice.label ?? choice.display_value ?? choice.value),
110
+ value: typeof choice === 'string' ? choice : choice.value,
111
+ })) });
112
+ }
113
+ const textual = ['string', 'reference', 'table_name', 'field_name', 'conditions', 'condition', 'html', 'url', 'email', 'choice', 'document_id'];
114
+ const numeric = ['integer', 'number', 'decimal', 'float'];
115
+ if (![...textual, ...numeric].includes(field.type)) {
116
+ throw errUsage(`Guided input ${field.name} has unsupported type ${field.type}; use --data-file to preserve its complete schema and value`);
117
+ }
118
+ const answer = await p.input({ message: `${message} (raw pills accepted)`, default: scalarDefault(value), validate: answer => {
119
+ if ((field.mandatory === true || field.required === true) && answer === '') return 'A value is required';
120
+ if (numeric.includes(field.type) && answer !== '' && !answer.startsWith('{{') && (!Number.isFinite(Number(answer)) || field.type === 'integer' && !Number.isInteger(Number(answer)))) return 'Enter a valid number or raw pill';
121
+ return true;
122
+ } });
123
+ // Pills are opaque provider expressions, not numbers to be coerced.
124
+ return numeric.includes(field.type) && answer !== '' && !answer.startsWith('{{') ? Number(answer) : answer;
125
+ }
126
+ async function editVariables(app, variables, message, valueKey = 'value') {
127
+ for (const field of variables.filter(field => !field.name.startsWith('__'))) {
128
+ if (await prompts(app).confirm({ message: `${message}: change ${field.name}?`, default: false })) {
129
+ const value = await promptSchemaValue(app, field, field[valueKey], `${message}: `);
130
+ const wrapped = field[valueKey] && typeof field[valueKey] === 'object' && 'value' in field[valueKey] && !schemaObjectValue(field, field[valueKey]);
131
+ field[valueKey] = wrapped ? { ...field[valueKey], value } : value;
132
+ if (valueKey === 'defaultValue') field.mandatory = await prompts(app).confirm({ message: `${field.name}: required?`, default: field.mandatory === true });
133
+ }
134
+ }
135
+ }
136
+ function schemaTemplates(types) {
137
+ const templates = [];
138
+ const visit = fields => {
139
+ if (fields !== undefined && !Array.isArray(fields)) throw errUsage('Provider variable schema must be an array; no parent was allocated');
140
+ for (const field of fields || []) {
141
+ if (!field || typeof field !== 'object') throw errUsage('Provider variable schema requires objects; no parent was allocated');
142
+ if (field.name && field.type && !field.name.startsWith('__')) templates.push(field);
143
+ visit(field.children);
144
+ }
145
+ };
146
+ for (const type of types) { visit(type.inputs); visit(type.outputs); visit(type.extended_inputs); visit(type.extended_outputs); }
147
+ return templates;
148
+ }
149
+ function freshVariable(template) {
150
+ const field = clone(template);
151
+ const renew = variable => {
152
+ variable.id = randomBytes(16).toString('hex');
153
+ if (variable.attributes?.uiUniqueId) variable.attributes.uiUniqueId = randomUUID();
154
+ for (const child of variable.children || []) renew(child);
155
+ };
156
+ renew(field);
157
+ return field;
158
+ }
159
+ async function newVariable(app, templates, message) {
160
+ const candidates = templates.filter(field => ['string', 'integer', 'boolean', 'reference', 'object'].includes(field.type));
161
+ if (!candidates.length) throw errUsage('Step schemas contain no supported variable templates; use --data-file');
162
+ const template = await prompts(app).select({ message: `${message}: select provider schema template`, choices: candidates.map(field => ({ name: `${field.name} [${field.type}]`, value: field })) });
163
+ const field = freshVariable(template);
164
+ field.name = await prompts(app).input({ message: `${message}: variable name`, validate: value => /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) || 'Use a JavaScript variable name' });
165
+ field.label = field.name;
166
+ field.mandatory = await prompts(app).confirm({ message: `${field.name}: required?`, default: false });
167
+ delete field.value;
168
+ delete field.display_value;
169
+ return field;
170
+ }
171
+ export function stepTypeSchemas(response) {
172
+ const types = Array.isArray(response) ? response : response?.step_types ?? response?.stepTypes;
173
+ if (!Array.isArray(types) || !types.length) throw errUsage('Provider returned no supported step_types schema array; no parent was allocated');
174
+ return types;
175
+ }
176
+ export async function guidedActionCreate(argv, app) {
177
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for a full action definition in noninteractive mode');
178
+ const scope = await designerScope(argv, app);
179
+ const types = stepTypeSchemas(await app.sdk.getProcessFlowStepTypes(scope));
180
+ const definition = { scope, name: await prompts(app).input({ message: 'Action name', validate: required }),
181
+ description: await prompts(app).input({ message: 'Description' }), inputs: [], outputs: [], steps: [] };
182
+ const templates = schemaTemplates(types);
183
+ while (await prompts(app).confirm({ message: 'Add an action input?', default: false })) {
184
+ definition.inputs.push(await newVariable(app, templates, 'Action input'));
185
+ }
186
+ do {
187
+ const schema = await prompts(app).select({ message: 'Select executable step schema', choices: types.map(type => ({ name: type.name || type.step_type_name || type.type || type.step_type, value: type })) });
188
+ if (!(schema.type || schema.step_type) || !(schema.id || schema.step_type_id) || !Array.isArray(schema.inputs) || !Array.isArray(schema.outputs)) {
189
+ throw errUsage('Selected provider step schema is incomplete; no parent was allocated');
190
+ }
191
+ const step = { ...clone(schema), cid: randomUUID(), step_type: schema.step_type || schema.type,
192
+ step_type_id: schema.step_type_id || schema.id, order: definition.steps.length + 1,
193
+ inputs: clone(schema.inputs), outputs: clone(schema.outputs),
194
+ extended_inputs: clone(schema.extended_inputs || []), extended_outputs: clone(schema.extended_outputs || []) };
195
+ delete step.id;
196
+ delete step.step_id;
197
+ delete step.action;
198
+ for (const field of step.inputs) field.value = await promptSchemaValue(app, field, field.value);
199
+ if (step.step_type === 'SCRIPT') {
200
+ for (const field of definition.inputs) step.extended_inputs.push({ ...freshVariable(field), extended: true, value: `{{inputs.${field.name}}}` });
201
+ while (await prompts(app).confirm({ message: 'Declare a script step output?', default: false })) {
202
+ step.extended_outputs.push({ ...await newVariable(app, templates, 'Script output'), extended: true });
203
+ }
204
+ }
205
+ definition.steps.push(step);
206
+ } while (await prompts(app).confirm({ message: 'Add another step?', default: false }));
207
+ const outputs = definition.steps.flatMap(step => [...step.outputs, ...step.extended_outputs]
208
+ .filter(field => !field.name.startsWith('__')).map(field => ({ step, field })));
209
+ while (await prompts(app).confirm({ message: 'Map a step output to an action output?', default: false })) {
210
+ if (!outputs.length) throw errUsage('No provider-declared step outputs available');
211
+ const { step, field } = await prompts(app).select({ message: 'Select output', choices: outputs.map(candidate => ({ name: `Step ${candidate.step.order}: ${candidate.field.name}`, value: candidate })) });
212
+ const output = { ...freshVariable(field), extended: false };
213
+ output.name = await prompts(app).input({ message: 'Action output name', default: field.name, validate: required });
214
+ output.value = `{{step[${step.cid}].${field.name}}}`;
215
+ definition.outputs.push(output);
216
+ }
217
+ validateDefinition(definition, undefined, scope);
218
+ await confirmDesignerSave(app, `Create inactive draft action '${definition.name}' with ${definition.steps.length} step(s)? No publish or execution.`);
219
+ return { scope, definition };
220
+ }
221
+ export async function guidedActionEdit(argv, app, definition) {
222
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the full action definition in noninteractive mode');
223
+ validateDefinition(definition, definition.id, definition.scope);
224
+ const edited = clone(definition);
225
+ const fields = await prompts(app).select({ message: 'Edit action', choices: [
226
+ { name: 'Name and description', value: 'metadata' }, { name: 'Action inputs', value: 'inputs' },
227
+ { name: 'Action output mappings', value: 'outputs' }, { name: 'Step inputs, scripts and mappings', value: 'steps' },
228
+ ] });
229
+ if (fields === 'metadata') {
230
+ edited.name = await prompts(app).input({ message: 'Action name', default: edited.name, validate: required });
231
+ if (Object.hasOwn(edited, 'displayName')) edited.displayName = edited.name;
232
+ edited.description = await prompts(app).input({ message: 'Description', default: edited.description || '' });
233
+ } else if (fields === 'steps') {
234
+ const step = await prompts(app).select({ message: 'Select saved step', choices: edited.steps.map(step => ({ name: `${step.order}: ${step.step_type}`, value: step })) });
235
+ await editVariables(app, step.inputs, 'Step input');
236
+ await editVariables(app, step.extended_inputs, 'Step input mapping');
237
+ await editVariables(app, step.extended_outputs, 'Step output');
238
+ } else await editVariables(app, edited[fields], fields, fields === 'inputs' ? 'defaultValue' : 'value');
239
+ validateDefinition(edited, definition.id, definition.scope);
240
+ await confirmDesignerSave(app, `Save action '${edited.name}'? Unspecified schema and lifecycle identities are preserved. No publish or execution.`);
241
+ return edited;
242
+ }
243
+ export async function guidedActionTestInputs(argv, app, definition) {
244
+ const values = {};
245
+ for (const field of definition.inputs) values[field.name] = await promptSchemaValue(app, field, field.defaultValue, 'Test input: ');
246
+ return values;
247
+ }
248
+ async function tableName(argv, app) {
249
+ const id = await pickDesignerRecord(argv, app, 'sys_db_object', 'trigger table');
250
+ if (id === undefined) throw CANCEL;
251
+ const table = await app.sdk.get('sys_db_object', id);
252
+ const name = rawField(table?.name);
253
+ if (!name) throw errUsage('Selected trigger table has no raw name');
254
+ return name;
255
+ }
256
+ export async function guidedFlowCreate(argv, app) {
257
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the flow manifest in noninteractive mode');
258
+ const manifest = { name: await prompts(app).input({ message: 'Flow name', validate: required }),
259
+ description: await prompts(app).input({ message: 'Description' }), scope: await designerScope(argv, app),
260
+ trigger: { type: 'record_create_or_update', table: await tableName(argv, app),
261
+ condition: await prompts(app).input({ message: 'Trigger condition (single supported encoded predicate, or empty)' }) }, actions: [] };
262
+ while (await prompts(app).confirm({ message: 'Add a published action?', default: true })) {
263
+ const id = await pickDesignerRecord(argv, app, ACTION_TABLE, 'published action', 'active=true^published=true');
264
+ if (id === undefined) throw CANCEL;
265
+ const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), id, manifest.scope, { requireTypes: true });
266
+ const inputs = {};
267
+ for (const field of schema) {
268
+ if (!field.id || !field.parameter || typeof field.parameter !== 'object' || Array.isArray(field.parameter)) {
269
+ throw errUsage(`Published action input ${field.name} lacks provider id/parameter metadata; no flow was allocated`);
270
+ }
271
+ inputs[field.name] = await promptSchemaValue(app, field, field.default, 'Action input: ');
272
+ }
273
+ manifest.actions.push({ type: id, inputs });
274
+ }
275
+ validateFlowManifest(manifest);
276
+ await confirmDesignerSave(app, `Create inactive draft flow '${manifest.name}' with ${manifest.actions.length} action(s)? No publish or execution.`);
277
+ return manifest;
278
+ }
279
+ export async function savedFlowDefinition(app, identifier) {
280
+ const { resolveRecord, unwrapSysId } = await import('./resolve-record.js');
281
+ const record = await resolveRecord(app.sdk, { table: 'sys_hub_flow', identifier, matchField: 'name', resource: 'flow' });
282
+ const id = unwrapSysId(record);
283
+ const scope = rawField(record.scope);
284
+ if (!scope) throw errUsage('Saved flow has no raw transaction scope');
285
+ return { record, definition: await readProcessFlow(app.sdk, app.getEffectiveInstance(), id, scope) };
286
+ }
287
+ export async function guidedFlowEdit(argv, app) {
288
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the flow update manifest in noninteractive mode');
289
+ const { record, definition } = await savedFlowDefinition(app, argv.identifier);
290
+ if (rawField(record.active) === true || rawField(record.active) === 'true' || rawField(record.status) !== 'draft') throw errUsage('Guided flow edit requires an inactive Draft');
291
+ const manifest = {};
292
+ const choice = await prompts(app).select({ message: 'Edit supported draft flow fields', choices: [
293
+ { name: 'Record trigger condition', value: 'trigger' }, { name: 'Existing action input values', value: 'actions' },
294
+ ] });
295
+ if (choice === 'trigger') {
296
+ const trigger = deriveRecordTrigger(definition).trigger;
297
+ const condition = (trigger.inputs || []).find(field => field.name === 'condition');
298
+ manifest.trigger = { condition: await prompts(app).input({ message: 'Trigger condition (single supported encoded predicate, or empty)', default: rawField(condition?.value) || '' }) };
299
+ } else {
300
+ const actions = definition.actionInstances || definition.actions || [];
301
+ if (!actions.length) throw errUsage('Saved flow contains no supported action instances');
302
+ const action = await prompts(app).select({ message: 'Select saved action instance', choices: actions.map(action => ({ name: `${action.order}: ${getStringField(action, 'name') || action.actionTypeSysId}`, value: action })) });
303
+ const fields = action.inputs || action.inputValues || [];
304
+ if (!fields.length) throw errUsage('Saved action instance contains no input schema');
305
+ const field = await prompts(app).select({ message: 'Select input to change', choices: fields.map(field => ({ name: `${field.name} [${field.type || field.parameter?.type || 'unknown'}]`, value: field })) });
306
+ const actionType = rawField(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId);
307
+ let inputSchema = { ...field, type: field.type || field.parameter?.type };
308
+ if (!inputSchema.type) {
309
+ const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.scope), { requireTypes: true });
310
+ inputSchema = schema.find(candidate => candidate.name === field.name);
311
+ if (!inputSchema) throw errUsage(`Saved action input ${field.name} is absent from its provider schema`);
312
+ }
313
+ const value = await promptSchemaValue(app, inputSchema, rawField(field.value));
314
+ manifest.actions = [{ order: Number(action.order), type: actionType, inputs: { [field.name]: value } }];
315
+ }
316
+ validateFlowUpdateManifest(manifest);
317
+ return manifest;
318
+ }
319
+ export async function guidedFlowTestRecord(argv, app) {
320
+ if (argv.record !== undefined) return argv.record;
321
+ if (!interactiveDesigner(argv, app)) throw errUsage('Provide --record <sys_id> in noninteractive mode');
322
+ const { record, definition } = await savedFlowDefinition(app, argv.identifier);
323
+ const { table } = deriveRecordTrigger(definition);
324
+ const descriptor = await readTableDescriptor(app.sdk, app.getEffectiveInstance(), rawField(record.scope), table);
325
+ const { identifier: _identifier, ...pickerArgs } = argv;
326
+ return pickDesignerRecord(pickerArgs, app, table, `test record in ${table}`, '', descriptor.displayField);
327
+ }