@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.
@@ -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
- process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
261
- process.stdout.write('Available subcommands:\n');
262
- process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
263
- process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
264
- process.stdout.write(' search Full-text + semantic search across the local docs index\n');
265
- process.stdout.write(' show Display a full documentation page by id or path\n');
266
- process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
267
- process.stdout.write(' community Sync ServiceNow Community articles (hubs + linked posts) into the index\n');
268
- process.stdout.write('\n');
269
- process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
270
- process.stdout.write('\n');
271
- const dbPath = getDocsDbPath();
272
- process.stdout.write(`Docs DB: ${dbPath} (run "jsn docs status" for details, or query directly with sqlite3)\n`);
273
- process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
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
  }
@@ -1,9 +1,30 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
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
- declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish'] });
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 <identifier>',
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: argv.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 <identifier>',
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: argv.identifier,
217
+ identifier,
178
218
  matchField: 'name',
179
219
  resource: argv.action ? 'Custom action' : 'Flow',
180
220
  }));
181
221
 
182
- const result = argv.action
183
- ? await publishFlows(app.sdk, [], [sysID])
184
- : await publishFlows(app.sdk, [sysID], []);
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 <identifier>',
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: argv.identifier, matchField: 'name', resource: 'Flow',
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 new flow (not yet implemented)',
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: 'JSON data for the flow' }),
230
- handler: wrap(async (_argv, _app) => {
231
- throw new Error('Flow creation requires the Flow Designer GraphQL API - not yet implemented.\n'
232
- + 'Use the ServiceNow web UI to create flows, then use "jsn flows list" to view them.');
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 <identifier>',
237
- describe: 'Update a flow (not yet implemented)',
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: 'JSON data to update' }),
240
- handler: wrap(async (_argv, _app) => {
241
- throw new Error('Flow updates require the Flow Designer GraphQL API - not yet implemented.\n'
242
- + 'Use the ServiceNow web UI to update flows, then use "jsn flows list" to view them.');
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 <identifier>',
355
+ command: 'delete [identifier]',
247
356
  describe: 'Delete a flow (not yet implemented)',
248
- handler: wrap(async (_argv, _app) => {
249
- throw new Error('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
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
- console.log('Manage Flow Designer flows from the sys_hub_flow table.');
256
- console.log('');
257
- console.log('Available subcommands:');
258
- console.log(' list List flows');
259
- console.log(' executions Show flow executions from sys_flow_context');
260
- console.log(' show <identifier> Show flow details by name or sys_id');
261
- console.log(' status <identifier> Check whether a flow is actually able to run');
262
- console.log(' publish <identifier> Publish (activate) a flow');
263
- console.log(' doctor Check whether this instance can publish flows');
264
- console.log(' create Create a new flow (not yet implemented)');
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
+ }