@jacebenson/jsn 4.3.7 → 4.3.8
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 +246 -1
- package/package.json +1 -1
- 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/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
|
@@ -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
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
|
|
4
|
+
// GraphQL documents may contain mutations. Gate the whole command so normal
|
|
5
|
+
// instance/auth and read-only protections apply without attempting AST parsing.
|
|
6
|
+
declareCapabilities('graphql', { mutationSubcommands: [''] });
|
|
7
|
+
|
|
8
|
+
function readStdin() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let data = '';
|
|
11
|
+
process.stdin.setEncoding('utf-8');
|
|
12
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
13
|
+
process.stdin.on('end', () => resolve(data));
|
|
14
|
+
process.stdin.on('error', reject);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolveGraphQLInput(argv) {
|
|
19
|
+
const sources = [
|
|
20
|
+
argv.document !== undefined,
|
|
21
|
+
argv.query !== undefined,
|
|
22
|
+
argv.queryFile !== undefined,
|
|
23
|
+
argv.stdin === true,
|
|
24
|
+
].filter(Boolean).length;
|
|
25
|
+
if (sources !== 1) {
|
|
26
|
+
throw new Error('Provide exactly one GraphQL document via positional document, --query, --query-file, or --stdin');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let query;
|
|
30
|
+
if (argv.document !== undefined) query = argv.document;
|
|
31
|
+
else if (argv.query !== undefined) query = argv.query;
|
|
32
|
+
else if (argv.queryFile !== undefined) query = fs.readFileSync(argv.queryFile, 'utf-8');
|
|
33
|
+
else query = await readStdin();
|
|
34
|
+
|
|
35
|
+
query = String(query || '').trim();
|
|
36
|
+
if (!query) throw new Error('GraphQL document must not be empty');
|
|
37
|
+
return query;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseGraphQLVariables(value) {
|
|
41
|
+
if (value === undefined) return {};
|
|
42
|
+
let variables;
|
|
43
|
+
try {
|
|
44
|
+
variables = JSON.parse(value);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error('--variables must be valid JSON');
|
|
47
|
+
}
|
|
48
|
+
if (!variables || typeof variables !== 'object' || Array.isArray(variables)) {
|
|
49
|
+
throw new Error('--variables must be a JSON object');
|
|
50
|
+
}
|
|
51
|
+
return variables;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function graphqlCmd(wrap) {
|
|
55
|
+
return {
|
|
56
|
+
command: 'graphql [document]',
|
|
57
|
+
describe: 'Execute a GraphQL document against ServiceNow Now GraphQL',
|
|
58
|
+
builder: (yargs) => yargs
|
|
59
|
+
.positional('document', {
|
|
60
|
+
describe: 'GraphQL document string',
|
|
61
|
+
type: 'string',
|
|
62
|
+
})
|
|
63
|
+
.option('query', {
|
|
64
|
+
type: 'string',
|
|
65
|
+
describe: 'GraphQL document string (alternative to positional)',
|
|
66
|
+
})
|
|
67
|
+
.option('query-file', {
|
|
68
|
+
type: 'string',
|
|
69
|
+
describe: 'Read the GraphQL document from a file',
|
|
70
|
+
})
|
|
71
|
+
.option('variables', {
|
|
72
|
+
type: 'string',
|
|
73
|
+
describe: 'GraphQL variables as a JSON object',
|
|
74
|
+
})
|
|
75
|
+
.option('stdin', {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
describe: 'Read the GraphQL document from stdin',
|
|
78
|
+
}),
|
|
79
|
+
handler: wrap(async (argv, app) => {
|
|
80
|
+
const query = await resolveGraphQLInput(argv);
|
|
81
|
+
const variables = parseGraphQLVariables(argv.variables);
|
|
82
|
+
const endpoint = `${app.getEffectiveInstance()}/api/now/graphql`;
|
|
83
|
+
const result = await app.sdk.request(endpoint, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: JSON.stringify({ query, variables }),
|
|
86
|
+
});
|
|
87
|
+
const errorCount = Array.isArray(result?.errors) ? result.errors.length : 0;
|
|
88
|
+
app.ok(result, {
|
|
89
|
+
summary: errorCount
|
|
90
|
+
? `GraphQL returned ${errorCount} error(s)`
|
|
91
|
+
: 'GraphQL request succeeded',
|
|
92
|
+
});
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
}
|