@jacebenson/jsn 4.3.7-dev.33 → 4.3.7-dev.35
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 +69 -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/commands/midservers.js +105 -23
- package/src/errors.js +5 -0
- package/src/flow-create.js +6 -5
- package/src/flow-designer-interactive.js +327 -0
- package/src/midserver-direct.js +252 -0
- package/src/midserver-zip.js +107 -0
|
@@ -5,7 +5,8 @@ import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList,
|
|
|
5
5
|
import { FormatAuto } from '../output.js';
|
|
6
6
|
import { declareCapabilities } from '../capabilities.js';
|
|
7
7
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
8
|
-
import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, validateMidCredentials } from '../midserver-install.js';
|
|
8
|
+
import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, normalizeMidName, validateMidCredentials } from '../midserver-install.js';
|
|
9
|
+
import { detectDirectPlatform, installDirectMidServer, readDirectInstallation, removeDirectInstallation, runDirectInstallation } from '../midserver-direct.js';
|
|
9
10
|
|
|
10
11
|
const AGENT_TABLE = 'ecc_agent';
|
|
11
12
|
const QUEUE_TABLE = 'ecc_queue';
|
|
@@ -13,7 +14,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
|
|
|
13
14
|
const DEFAULT_TIMEOUT = 120000;
|
|
14
15
|
const DEFAULT_INTERVAL = 2000;
|
|
15
16
|
|
|
16
|
-
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute'] });
|
|
17
|
+
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
|
|
17
18
|
|
|
18
19
|
function agentName(record) {
|
|
19
20
|
const name = getStringField(record, 'name');
|
|
@@ -231,7 +232,7 @@ export function assertMidServerReady(record) {
|
|
|
231
232
|
const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
|
|
232
233
|
const validated = queueValue(record.validated).toLowerCase();
|
|
233
234
|
if (validated !== 'true') {
|
|
234
|
-
const error = new Error(`MID Server '${name}' is not validated yet (validation: ${validated || 'unknown'}).
|
|
235
|
+
const error = new Error(`MID Server '${name}' is not validated yet (validation: ${validated || 'unknown'}). Use the manual Validate action in ServiceNow before executing a command.`);
|
|
235
236
|
error.code = 'midserver_not_validated';
|
|
236
237
|
throw error;
|
|
237
238
|
}
|
|
@@ -273,7 +274,7 @@ export async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
|
273
274
|
sysparm_query: `name=${fullName}`,
|
|
274
275
|
sysparm_limit: '1',
|
|
275
276
|
sysparm_display_value: 'all',
|
|
276
|
-
sysparm_fields: 'sys_id,name,status,host_name,version,last_heartbeat',
|
|
277
|
+
sysparm_fields: 'sys_id,name,status,validated,host_name,version,last_heartbeat',
|
|
277
278
|
});
|
|
278
279
|
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
279
280
|
if (records[0] && getStringField(records[0], 'status').toLowerCase() === 'up') return records[0];
|
|
@@ -282,11 +283,16 @@ export async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
|
282
283
|
throw new Error(`MID Server '${fullName}' did not report Up within ${timeoutMs}ms`);
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
function removeLocalMidServer(instanceURL, name) {
|
|
286
|
-
const
|
|
286
|
+
function removeLocalMidServer(instanceURL, name, directory) {
|
|
287
|
+
const direct = readDirectInstallation(instanceURL, name, directory);
|
|
288
|
+
if (direct) { removeDirectInstallation(direct); return true; }
|
|
289
|
+
const installDir = directory || midInstallDirectory(instanceURL, name);
|
|
287
290
|
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
288
291
|
const envPath = path.join(installDir, '.env');
|
|
289
|
-
if (!fs.existsSync(composePath))
|
|
292
|
+
if (!fs.existsSync(composePath)) {
|
|
293
|
+
if (fs.existsSync(path.join(installDir, 'agent'))) throw new Error('Native MID directory exists without trustworthy ownership metadata; refusing removal');
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
290
296
|
const composeArgs = ['compose', ...(fs.existsSync(envPath) ? ['--env-file', envPath] : []), '-f', composePath, 'down'];
|
|
291
297
|
try {
|
|
292
298
|
defaultRun('docker', composeArgs, { cwd: installDir });
|
|
@@ -303,7 +309,26 @@ function removeLocalMidServer(instanceURL, name) {
|
|
|
303
309
|
}
|
|
304
310
|
|
|
305
311
|
function showMidServerLogs(app, identifier, argv) {
|
|
306
|
-
const installDir = midInstallDirectory(app.getEffectiveInstance(), identifier);
|
|
312
|
+
const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
|
|
313
|
+
const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
|
|
314
|
+
if (direct) {
|
|
315
|
+
if (!Number.isInteger(argv.tail) || argv.tail < 1 || argv.tail > 10000) throw new Error('Use --tail between 1 and 10000');
|
|
316
|
+
if (argv.follow) throw new Error('Direct log follow is not supported; read logs again or use the native host log viewer');
|
|
317
|
+
const logfile = path.join(installDir, 'agent', 'logs', 'agent0.log');
|
|
318
|
+
if (!fs.existsSync(logfile)) throw new Error('No MID agent log yet; the installation may only be prepared');
|
|
319
|
+
const stat = fs.lstatSync(logfile);
|
|
320
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Untrusted MID log path');
|
|
321
|
+
const fd = fs.openSync(logfile, 'r');
|
|
322
|
+
let text;
|
|
323
|
+
try {
|
|
324
|
+
const length = Math.min(stat.size, 1024 * 1024);
|
|
325
|
+
const buffer = Buffer.alloc(length);
|
|
326
|
+
fs.readSync(fd, buffer, 0, length, stat.size - length);
|
|
327
|
+
text = buffer.toString('utf8').split('\n').slice(-argv.tail).join('\n');
|
|
328
|
+
} finally { fs.closeSync(fd); }
|
|
329
|
+
app.ok({ installation: 'direct', logfile, text, _formatted: `${text}\n` }, { summary: 'Local MID agent log (last 1 MiB maximum)' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
307
332
|
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
308
333
|
const envPath = path.join(installDir, '.env');
|
|
309
334
|
if (!fs.existsSync(composePath)) throw new Error(`No local Compose installation found for MID Server '${identifier}'`);
|
|
@@ -349,24 +374,48 @@ export function midserversCmd(wrap) {
|
|
|
349
374
|
})
|
|
350
375
|
.command({
|
|
351
376
|
command: 'logs <identifier>',
|
|
352
|
-
describe: 'Show local Docker Compose
|
|
377
|
+
describe: 'Show local native or Docker Compose MID logs',
|
|
353
378
|
builder: (b) => b
|
|
354
379
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
355
|
-
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
380
|
+
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
|
|
381
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
356
382
|
handler: wrap(async (argv, app) => {
|
|
357
383
|
showMidServerLogs(app, argv.identifier, argv);
|
|
358
384
|
}),
|
|
359
385
|
})
|
|
360
386
|
.command({
|
|
361
387
|
command: '<identifier> logs',
|
|
362
|
-
describe: 'Show local Docker Compose
|
|
388
|
+
describe: 'Show local native or Docker Compose MID logs',
|
|
363
389
|
builder: (b) => b
|
|
364
390
|
.option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
|
|
365
|
-
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
|
|
391
|
+
.option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
|
|
392
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
366
393
|
handler: wrap(async (argv, app) => {
|
|
367
394
|
showMidServerLogs(app, argv.identifier, argv);
|
|
368
395
|
}),
|
|
369
396
|
})
|
|
397
|
+
.command({
|
|
398
|
+
command: 'start <identifier>',
|
|
399
|
+
describe: 'Start an owned direct MID (Windows: foreground console, no service)',
|
|
400
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
|
|
401
|
+
handler: wrap(async (argv, app) => {
|
|
402
|
+
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
403
|
+
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
404
|
+
if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
|
|
405
|
+
const result = runDirectInstallation(metadata, 'start');
|
|
406
|
+
app.ok(result, { summary: result.platform === 'windows' ? 'Windows MID console exited; no service was registered' : 'MID start command completed; registration and manual validation must be checked in ServiceNow' });
|
|
407
|
+
}),
|
|
408
|
+
})
|
|
409
|
+
.command({
|
|
410
|
+
command: 'stop <identifier>',
|
|
411
|
+
describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
|
|
412
|
+
builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
|
|
413
|
+
handler: wrap(async (argv, app) => {
|
|
414
|
+
const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
|
|
415
|
+
if (!metadata) throw new Error('No owned direct MID installation found');
|
|
416
|
+
app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
|
|
417
|
+
}),
|
|
418
|
+
})
|
|
370
419
|
.command({
|
|
371
420
|
command: 'add',
|
|
372
421
|
describe: 'Install and verify a MID Server',
|
|
@@ -374,7 +423,7 @@ export function midserversCmd(wrap) {
|
|
|
374
423
|
.option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
|
|
375
424
|
.option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
|
|
376
425
|
.option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
|
|
377
|
-
.option('directory', { type: 'string', describe: '
|
|
426
|
+
.option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
|
|
378
427
|
.option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
|
|
379
428
|
.option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
|
|
380
429
|
handler: wrap(async (argv, app) => {
|
|
@@ -395,20 +444,51 @@ export function midserversCmd(wrap) {
|
|
|
395
444
|
choices: dockerAvailable
|
|
396
445
|
? [
|
|
397
446
|
{ name: 'Docker (recommended)', value: 'docker' },
|
|
398
|
-
{ name: 'Direct install (
|
|
447
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
399
448
|
]
|
|
400
449
|
: dockerStatus.installed && dockerStatus.compose
|
|
401
450
|
? [
|
|
402
451
|
{ name: 'Run with sudo', value: 'sudo' },
|
|
403
452
|
{ name: 'Prepare files now, run Docker later', value: 'prepare' },
|
|
404
|
-
{ name: 'Direct install (
|
|
453
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
405
454
|
]
|
|
406
455
|
: [
|
|
407
456
|
{ name: 'Prepare files now, run Docker later', value: 'prepare' },
|
|
408
|
-
{ name: 'Direct install (
|
|
457
|
+
{ name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
|
|
409
458
|
],
|
|
410
459
|
});
|
|
411
|
-
if (installMethod === 'direct')
|
|
460
|
+
if (installMethod === 'direct') {
|
|
461
|
+
const native = detectDirectPlatform();
|
|
462
|
+
const decision = await select({
|
|
463
|
+
message: native === 'linux' ? 'No system service or elevation will be used. Choose runtime:' : 'Windows console runtime only; native service registration is not supported. Choose:',
|
|
464
|
+
choices: [
|
|
465
|
+
...(native === 'linux' ? [{ name: 'Start user-owned background MID', value: 'start' }] : []),
|
|
466
|
+
{ name: 'Prepare private files only (do not start)', value: 'prepare' },
|
|
467
|
+
{ name: 'Cancel without downloading', value: 'cancel' },
|
|
468
|
+
],
|
|
469
|
+
});
|
|
470
|
+
if (decision === 'cancel') return;
|
|
471
|
+
const shortName = normalizeMidName(name);
|
|
472
|
+
const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${shortName}`, sysparm_fields: 'sys_id,name', sysparm_limit: '1' }));
|
|
473
|
+
if (existing.length) throw new Error(`MID '${shortName}' already exists in ServiceNow; choose a new name. Existing runtime and record were not changed.`);
|
|
474
|
+
const installation = await installDirectMidServer({
|
|
475
|
+
instanceURL: app.getEffectiveInstance(), ...credentials, name, directory: argv.directory, runNow: decision === 'start',
|
|
476
|
+
});
|
|
477
|
+
if (installation.state === 'prepared') {
|
|
478
|
+
app.ok(installation, { summary: `Prepared native MID '${installation.name}' in ${installation.installDir}; not started or registered. Start with jsn midservers start ${installation.name}${argv.directory ? ' --directory ' + JSON.stringify(installation.installDir) : ''}. Windows start runs in the foreground console; no service is installed.` });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
let record;
|
|
482
|
+
try { record = await waitForMidServerUp(app, installation.name, Math.max(1, argv['wait-timeout']), Math.max(500, argv['wait-interval'])); }
|
|
483
|
+
catch {
|
|
484
|
+
app.ok({ ...installation, registration: 'pending', validated: false }, { summary: `Native MID start command completed; registration is pending. Runtime retained for diagnostics at ${installation.installDir}; no forced teardown.` });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const validated = queueValue(record.validated).toLowerCase() === 'true';
|
|
488
|
+
const url = new URL(`/ecc_agent.do?sys_id=${encodeURIComponent(unwrapSysId(record))}`, app.getEffectiveInstance()).href;
|
|
489
|
+
app.ok({ ...installation, registration: 'up', validated, record, url }, { summary: validated ? `MID '${installation.name}' is Up and manually validated` : `MID '${installation.name}' is Up but pending manual validation. Use Validate in ServiceNow: ${url}` });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
412
492
|
|
|
413
493
|
const installation = await installDockerMidServer({
|
|
414
494
|
instanceURL: app.getEffectiveInstance(),
|
|
@@ -448,7 +528,8 @@ export function midserversCmd(wrap) {
|
|
|
448
528
|
command: 'remove [identifier]',
|
|
449
529
|
aliases: ['delete'],
|
|
450
530
|
describe: 'Remove a MID Server record (interactive when no identifier is given)',
|
|
451
|
-
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
|
|
531
|
+
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
|
|
532
|
+
.option('directory', { type: 'string', describe: 'Local installation directory override' }),
|
|
452
533
|
handler: wrap(async (argv, app) => {
|
|
453
534
|
let record;
|
|
454
535
|
if (argv.identifier) {
|
|
@@ -456,8 +537,8 @@ export function midserversCmd(wrap) {
|
|
|
456
537
|
record = await resolveMidServer(app.sdk, argv.identifier);
|
|
457
538
|
} catch (error) {
|
|
458
539
|
if (error.code !== 'not_found') throw error;
|
|
459
|
-
const localDir = midInstallDirectory(app.getEffectiveInstance(), argv.identifier);
|
|
460
|
-
if (!fs.existsSync(path.join(localDir, 'docker-compose.yaml'))) throw error;
|
|
540
|
+
const localDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), argv.identifier);
|
|
541
|
+
if (!readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory) && !fs.existsSync(path.join(localDir, 'docker-compose.yaml'))) throw error;
|
|
461
542
|
record = null;
|
|
462
543
|
}
|
|
463
544
|
} else {
|
|
@@ -483,9 +564,10 @@ export function midserversCmd(wrap) {
|
|
|
483
564
|
const sysId = record ? unwrapSysId(record) : null;
|
|
484
565
|
const name = getStringField(record, 'name') || argv.identifier || sysId;
|
|
485
566
|
await confirmDelete(app, argv, `Remove MID Server '${name}'`);
|
|
486
|
-
const
|
|
567
|
+
const direct = readDirectInstallation(app.getEffectiveInstance(), name, argv.directory);
|
|
568
|
+
const localRemoved = removeLocalMidServer(app.getEffectiveInstance(), name, argv.directory);
|
|
487
569
|
if (record) await app.sdk.delete(AGENT_TABLE, sysId);
|
|
488
|
-
app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its local
|
|
570
|
+
app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
|
|
489
571
|
}),
|
|
490
572
|
})
|
|
491
573
|
.command({
|
|
@@ -563,7 +645,7 @@ export function midserversCmd(wrap) {
|
|
|
563
645
|
console.log(' issues [name_or_sys_id] Show unresolved issues (--all includes resolved)');
|
|
564
646
|
console.log(' events [name_or_sys_id] Show lifecycle history');
|
|
565
647
|
console.log(' show <name_or_sys_id> Show a MID Server');
|
|
566
|
-
console.log(' add --name <name>
|
|
648
|
+
console.log(' add --name <name> Install a MID Server (Docker or direct ZIP)');
|
|
567
649
|
console.log(' remove <name_or_sys_id> Remove a MID Server record');
|
|
568
650
|
console.log(' execute <name_or_sys_id> Execute a shell command');
|
|
569
651
|
console.log('');
|
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
|
+
}
|