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