@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 +56 -3
- package/package.json +1 -1
- package/src/commands/_generic.js +3 -2
- package/src/commands/actions.js +36 -19
- package/src/commands/flows.js +53 -44
- package/src/errors.js +5 -0
- package/src/flow-create.js +6 -5
- package/src/flow-designer-interactive.js +327 -0
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.
|
|
101
|
-
|
|
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
|
|
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
package/src/commands/_generic.js
CHANGED
|
@@ -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)
|
package/src/commands/actions.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { buildDevCmd } from './_generic.js';
|
|
2
|
-
import { parseDataArg
|
|
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
|
|
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',
|
|
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
|
|
46
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
93
|
+
let definition = hasDefinitionData(argv) ? parseDataArg(argv) : undefined;
|
|
83
94
|
const id = await actionID(argv, app);
|
|
84
|
-
|
|
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',
|
|
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
|
|
96
|
-
app.ok(await app.sdk.getProcessFlowStepTypes(
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|
package/src/commands/flows.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import { formatRecordForDisplay, getStringField, interactiveList
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
228
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
328
|
+
command: 'delete [identifier]',
|
|
306
329
|
describe: 'Delete a flow (not yet implemented)',
|
|
307
|
-
handler: wrap(async (
|
|
308
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
package/src/flow-create.js
CHANGED
|
@@ -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
|
+
}
|