@jacebenson/jsn 4.3.8 → 4.3.9-dev.62

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.
@@ -9,8 +9,10 @@ import { cancelled, pickDesignerRecord, designerScope, guidedActionCreate, guide
9
9
  const ACTION_COMMANDS = [
10
10
  { name: 'list', description: 'List actions' },
11
11
  { name: 'show [identifier]', description: 'Show an action by name or sys_id' },
12
+ { name: 'help [identifier]', description: 'Show action input requirements and live usage examples' },
12
13
  { name: 'definition [identifier]', description: 'Read the full JSON definition including executable step_instances' },
13
14
  { name: 'step-types', description: 'Read step schema templates for a target scope' },
15
+ { name: 'action-type-schema', description: 'Read provider metadata for an exact action type ID and scope' },
14
16
  { name: 'create', description: 'Create an action: guided in a terminal, full JSON definition in scripts' },
15
17
  { name: 'update [identifier]', description: 'Save a full definition through Process Flow (alias: edit)' },
16
18
  { name: 'test [identifier]', description: 'Execute an action and optionally wait for actual outputs' },
@@ -28,6 +30,53 @@ async function actionID(argv, app) {
28
30
  return unwrapSysId(await resolveRecord(app.sdk, { table: ACTION_TABLE, identifier: argv.identifier, matchField: 'name', resource: 'action' }));
29
31
  }
30
32
 
33
+ const refID = value => typeof value === 'string' ? value : value?.value ?? value?.sys_id ?? value?.sysId ?? value?.id;
34
+ const asBoolean = value => value === true || value === 1 || (typeof value === 'string' && value.toLowerCase() === 'true');
35
+ const EXAMPLE_TYPES = new Set(['approval_rules', 'conditions', 'schedule_date_time']);
36
+ function collectActionExamples(node, snapshotId, allowedInputs, examples, flowName) {
37
+ if (!node || typeof node !== 'object') return;
38
+ if (Array.isArray(node)) { for (const item of node) collectActionExamples(item, snapshotId, allowedInputs, examples, flowName); return; }
39
+ const typeId = refID(node.actionTypeSysId || node.action_type_sys_id || node.actionType || node.action_type);
40
+ if (typeId === snapshotId && Array.isArray(node.inputs)) {
41
+ const values = Object.fromEntries(node.inputs.filter(input => input?.name && allowedInputs.has(input.name) && input.value !== undefined && input.value !== null)
42
+ .map(input => [input.name, refID(input.value) ?? input.value]));
43
+ if (Object.keys(values).length) examples.push({ flow: flowName, inputs: values });
44
+ }
45
+ for (const [key, value] of Object.entries(node)) if (key !== 'inputs') collectActionExamples(value, snapshotId, allowedInputs, examples, flowName);
46
+ }
47
+ async function findActionExamples(sdk, definition, inputDefinitions) {
48
+ const snapshotId = refID(definition.latest_snapshot);
49
+ if (!/^[a-f0-9]{32}$/i.test(snapshotId || '')) return { examples: [], status: 'unavailable' };
50
+ const allowedInputs = new Set(inputDefinitions.filter(input => EXAMPLE_TYPES.has(input.type)
51
+ && !/(password|secret|token|credential|authorization|cookie|api[_-]?key)/i.test(input.name)).map(input => input.name));
52
+ if (!allowedInputs.size) return { examples: [], status: 'not_applicable' };
53
+ const query = new URLSearchParams({
54
+ sysparm_query: `action_type=${snapshotId}^ORDERBYsys_id`,
55
+ sysparm_fields: 'sys_id,flow,action_type',
56
+ sysparm_limit: '20',
57
+ sysparm_display_value: 'false',
58
+ });
59
+ const rows = await sdk.listStrict('sys_hub_action_instance', query);
60
+ if (!rows.length) return { examples: [], status: 'none' };
61
+ const flowIds = [...new Set(rows.map(row => refID(row.flow)).filter(value => /^[a-f0-9]{32}$/i.test(value || '')))];
62
+ if (!flowIds.length) return { examples: [], status: 'unavailable' };
63
+ const examples = [];
64
+ let unreadable = false;
65
+ for (const flowId of flowIds.slice(0, 10)) {
66
+ try {
67
+ const result = await sdk.inspectFlow(flowId);
68
+ const payloadActions = result?.payload?.actionInstances;
69
+ if (!Array.isArray(payloadActions) || !payloadActions.some(item => item && typeof item === 'object' && !Array.isArray(item))) {
70
+ unreadable = true;
71
+ continue;
72
+ }
73
+ collectActionExamples(result.payload, snapshotId, allowedInputs, examples, result?.flow?.name || flowId);
74
+ } catch { unreadable = true; }
75
+ if (examples.length >= 3) break;
76
+ }
77
+ return { examples: examples.slice(0, 3), status: examples.length ? 'found' : unreadable ? 'unavailable' : 'none' };
78
+ }
79
+
31
80
  export const actionsCmd = wrap => {
32
81
  // Reuse the existing list/show picker and output contract only.
33
82
  const base = buildDevCmd('actions', ACTION_TABLE, ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, {
@@ -55,7 +104,7 @@ export const actionsCmd = wrap => {
55
104
  describe: 'Flow Designer actions: create, edit full definitions, and test with outputs',
56
105
  builder: y => base.builder(y)
57
106
  .command({
58
- command: 'create', describe: 'Create parent and executable Process Flow action; verify persistence',
107
+ command: 'create', describe: 'Create a Process Flow action. Use `jsn actions step-types --scope <sys_id>` for live provider schemas. The required definition structure is code-owned; step-specific fields are provider-defined.',
59
108
  builder: yy => dataOptions(yy).option('scope', { type: 'string', describe: 'Exact target scope sys_id, or global' }),
60
109
  handler: run(async (argv, app) => {
61
110
  const guided = !hasDefinitionData(argv) ? await guidedActionCreate(argv, app) : undefined;
@@ -65,6 +114,27 @@ export const actionsCmd = wrap => {
65
114
  app.ok(result, { summary: `Created and verified action ${result.sys_id}` });
66
115
  }),
67
116
  })
117
+ .command({
118
+ command: 'help [identifier]', describe: 'Show action input requirements and examples from flows on this instance',
119
+ builder: scopeOption,
120
+ handler: run(async (argv, app) => {
121
+ const id = await actionID(argv, app);
122
+ if (id === undefined) return;
123
+ const scope = await actionScope(app.sdk, id, argv.scope);
124
+ const definition = await app.sdk.getProcessFlowAction(id, scope);
125
+ const inputs = (definition.inputs || []).map(input => ({
126
+ name: input.name,
127
+ type: input.type || input.parameter?.type || input.parameter?.inputType || input.valueType || 'unknown',
128
+ mandatory: asBoolean(input.mandatory ?? input.required ?? input.parameter?.mandatory ?? input.parameter?.required),
129
+ default: input.defaultValue ?? input.default ?? input.parameter?.defaultValue ?? '',
130
+ description: input.description || input.label || input.parameter?.label || '',
131
+ }));
132
+ const exampleResult = await findActionExamples(app.sdk, definition, inputs);
133
+ app.ok({ action: { id, name: definition.name, scope }, inputs, examples: exampleResult.examples,
134
+ examples_status: exampleResult.status },
135
+ { summary: `Input help for action ${definition.name || id}` });
136
+ }),
137
+ })
68
138
  .command({
69
139
  command: 'definition [identifier]', describe: 'Read full JSON definition including executable step_instances',
70
140
  builder: scopeOption,
@@ -116,13 +186,30 @@ export const actionsCmd = wrap => {
116
186
  }),
117
187
  })
118
188
  .command({
119
- command: 'step-types', describe: 'Read step schema templates for a target scope',
189
+ command: 'step-types', describe: 'Read full live step schemas for a target scope',
120
190
  builder: yy => yy.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id, or global' }),
121
191
  handler: run(async (argv, app) => {
122
192
  const scope = await designerScope(argv, app);
123
193
  app.ok(await app.sdk.getProcessFlowStepTypes(scope), { summary: 'Process Flow step schemas' });
124
194
  }),
125
195
  })
196
+ .command({
197
+ command: 'action-type-schema', describe: 'Read live provider input/output metadata for an exact action type ID and scope',
198
+ builder: yy => yy.option('id', { type: 'string', demandOption: true, describe: 'Exact 32-character action type sys_id' }).option('scope', { type: 'string', demandOption: true, describe: 'Exact transaction scope sys_id, or global' }),
199
+ handler: run(async (argv, app) => {
200
+ if (!/^[a-f\d]{32}$/i.test(argv.id)) throw errUsage('--id must be an exact 32-character action type sys_id');
201
+ if (!/^(?:[a-fA-F0-9]{32}|global)$/.test(argv.scope)) throw errUsage('--scope must be an exact scope sys_id or global');
202
+ app.requireInstance();
203
+ const url = `${app.sdk.baseURL}/api/now/processflow/action/action_types/${encodeURIComponent(argv.id)}?${new URLSearchParams({ sysparm_transaction_scope: argv.scope })}`;
204
+ const response = await app.sdk.request(url, { method: 'GET' });
205
+ const result = response?.result;
206
+ if (response?.error || result?.error || (result?.errorCode !== undefined && result.errorCode !== 0 && result.errorCode !== '0')) {
207
+ throw new Error(result?.errorMessage || response?.error?.message || 'Provider rejected the action type schema request');
208
+ }
209
+ if (!result || typeof result !== 'object') throw new Error('Provider returned no action type metadata');
210
+ app.ok(result, { summary: `Provider action type schema ${argv.id}` });
211
+ }),
212
+ })
126
213
  .command({
127
214
  command: 'delete [identifier]', describe: 'Delete action parent explicitly',
128
215
  builder: yy => scopeOption(yy).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
@@ -63,8 +63,8 @@ export function catalogCmd(wrap) {
63
63
  for (const v of argv.variable) {
64
64
  const parts = String(v).split(':');
65
65
  if (parts.length >= 2) {
66
- const typeName = parts.length >= 2 ? parts[parts.length - 1] : '';
67
- const label = parts.length >= 3 ? parts[parts.length - 1] : parts[0];
66
+ const typeName = parts[1];
67
+ const label = parts.length >= 3 ? parts.slice(2).join(':') : parts[0];
68
68
  const sysName = parts[0].replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
69
69
  try {
70
70
  await app.sdk.create('item_option_new', {
@@ -0,0 +1,82 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { errUsageHint, errUsage } from '../errors.js';
3
+ import { canUsePicker } from '../helpers.js';
4
+ import { paginatedSearch } from '../paginated-search.js';
5
+ import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
6
+ import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
7
+
8
+ // Only capture needs an instance. Saved reports never need network access.
9
+ declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
10
+ const listHint = { action: 'list', cmd: 'jsn customizations list', description: 'Browse saved customization captures' };
11
+ const showHint = id => ({ action: 'show', cmd: `jsn customizations show ${id}`, description: 'Review this saved capture' });
12
+ const compareHint = id => ({ action: 'compare', cmd: `jsn customizations compare ${id} OTHER_RUN_ID`, description: 'Compare captured metadata and evidence' });
13
+ function requireReport(id) {
14
+ const report = getReport(id);
15
+ if (!report) throw errUsageHint(`Unknown customization report: ${id}`, 'Run `jsn customizations list` to find saved captures.');
16
+ return report;
17
+ }
18
+ async function pickReport(app, argv, exclude = null, message = 'Select a customization capture') {
19
+ if (!canUsePicker(app, argv)) throw errUsageHint('A report ID is required when prompts are unavailable.', 'Run `jsn customizations list`, then pass the run ID explicitly.');
20
+ const runs = listReports({ limit: 1000 }).filter(run => run.run_id !== exclude);
21
+ if (!runs.length) throw errUsage('No saved customization captures available');
22
+ const picked = await paginatedSearch({ message, totalCount: runs.length, pageSize: 10, source: async (term = '') => runs.filter(run => `${run.run_id} ${run.label} ${run.instance}`.toLowerCase().includes(term.toLowerCase())).map(run => ({ name: `${run.run_id} ${run.label || '(unlabeled)'} ${run.instance}`, value: run })) });
23
+ return picked?.value ? requireReport(picked.value.run_id) : null;
24
+ }
25
+ function show(app, report, options = {}) {
26
+ const actionable = buildActionableReport(report);
27
+ const triage = actionable.artifacts.map(({ category, category_label, identity, triage: classification }) => ({ category, category_label, identity, ...classification }));
28
+ app.ok({ ...report, triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
29
+ }
30
+ export function customizationsCmd(wrap) {
31
+ return {
32
+ command: 'customizations [subcommand]',
33
+ describe: 'Capture and compare read-only customization evidence locally',
34
+ builder: y => y
35
+ .command({ command: 'capture', describe: 'Capture bounded read-only metadata and historical upgrade evidence',
36
+ builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
37
+ .option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
38
+ .option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
39
+ .option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' }),
40
+ handler: wrap(async (argv, app) => {
41
+ app.requireInstance();
42
+ if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
43
+ const customerScopes = Array.isArray(argv.customerScope) ? argv.customerScope : argv.customerScope ? [argv.customerScope] : [];
44
+ const report = await captureReport({ sdk: app.sdk, instance: app.getEffectiveInstance(), profile: app.session?.profileName || '', label: argv.label || '', customerScopes, pageSize: argv.pageSize, maxPages: argv.maxPages });
45
+ show(app, report);
46
+ }),
47
+ })
48
+ .command({ command: 'list', aliases: ['ls'], describe: 'List saved local captures',
49
+ builder: b => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum captures to list' }),
50
+ handler: wrap(async (argv, app) => {
51
+ if (canUsePicker(app, argv)) { const report = await pickReport(app, argv); if (report) show(app, report); return; }
52
+ const runs = listReports({ limit: argv.limit });
53
+ app.ok({ runs, count: runs.length, _formatted: formatReportList(runs) }, { summary: `${runs.length} customization capture(s)`, breadcrumbs: [listHint, ...(runs[0] ? [showHint(runs[0].run_id)] : [])] });
54
+ }),
55
+ })
56
+ .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
57
+ .option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
58
+ .option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' }), handler: wrap(async (argv, app) => {
59
+ const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
60
+ if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
61
+ }) })
62
+ .command({ command: 'html [run_id]', describe: 'Write a searchable, self-contained HTML review of a saved capture', builder: b => b
63
+ .option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' }), handler: wrap(async (argv, app) => {
64
+ const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
65
+ if (!report) return;
66
+ const file = writeCustomizationHtml(report, { out: argv.out });
67
+ app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0) }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
68
+ }) })
69
+ .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
70
+ if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
71
+ if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
72
+ const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
73
+ if (!baseline) return;
74
+ const newer = argv.new ? requireReport(argv.new) : await pickReport(app, argv, baseline.run_id, 'Select the new capture');
75
+ if (!newer) return;
76
+ if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
77
+ const result = compareReports(baseline, newer);
78
+ app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
79
+ }) }),
80
+ handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
81
+ };
82
+ }
@@ -6,7 +6,7 @@ import { inspectFlow } from '../flow-inspection.js';
6
6
  import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
7
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
8
  import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
- import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES } from '../flow-triggers.js';
9
+ import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES, readTriggerCatalog, readTriggerInputDefinitions } from '../flow-triggers.js';
10
10
  import { testFlow } from '../flow-test.js';
11
11
  import { errUsage } from '../errors.js';
12
12
  import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
@@ -63,6 +63,25 @@ export function flowsCmd(wrap) {
63
63
  describe: 'Manage Flow Designer flows',
64
64
  builder: (yargs) => {
65
65
  return yargs
66
+ .command({
67
+ command: 'schemas',
68
+ describe: 'Read live trigger catalog and declared inputs; supported marks verified CLI creation families',
69
+ handler: wrap(async (_argv, app) => {
70
+ app.requireInstance();
71
+ const catalog = await readTriggerCatalog(app.sdk);
72
+ for (const category of catalog.categories) {
73
+ for (const trigger of category.triggers) {
74
+ if (trigger.supported) trigger.inputs = await readTriggerInputDefinitions(app.sdk, trigger.sysId);
75
+ }
76
+ }
77
+ for (const trigger of catalog.ungrouped) {
78
+ if (trigger.supported) trigger.inputs = await readTriggerInputDefinitions(app.sdk, trigger.sysId);
79
+ }
80
+ const data = { ...catalog, structural_contract: 'Manifest keys and persistence shape are code-owned; trigger definitions and input metadata are instance-derived.' };
81
+ data._formatted = [...catalog.categories.flatMap(c => c.triggers.map(t => `${c.name}: ${t.name} [${t.type}] ${t.supported ? 'supported' : 'not supported'}; inputs: ${(t.inputs || []).map(i => i.name + ':' + i.type).join(', ')}`)), ...catalog.ungrouped.map(t => `${t.name} [${t.type}] ungrouped${t.supported ? '; supported' : '; not supported'}; inputs: ${(t.inputs || []).map(i => i.name + ':' + i.type).join(', ')}`)].join('\n');
82
+ app.ok(data, { summary: 'Live Flow trigger schemas' });
83
+ }),
84
+ })
66
85
  .command({
67
86
  command: 'list',
68
87
  aliases: ['ls'],
@@ -294,7 +313,7 @@ export function flowsCmd(wrap) {
294
313
  })
295
314
  .command({
296
315
  command: 'create',
297
- describe: 'Create a draft flow: pick a trigger category then trigger in a terminal, or use a JSON manifest in scripts',
316
+ describe: 'Create a draft flow from a JSON manifest or guided prompts. Use `jsn flows schemas` for instance-derived trigger and input schemas, and `jsn actions action-type-schema --id <actionTypeId> --scope <scopeId>` for provider action inputs. Manifest structure remains code-owned.',
298
317
  builder: (y) => y
299
318
  .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
300
319
  .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
@@ -322,10 +341,11 @@ export function flowsCmd(wrap) {
322
341
  .command({
323
342
  command: 'update [identifier]',
324
343
  aliases: ['edit'],
325
- describe: 'Safely update supported fields of an existing inactive Draft flow',
344
+ describe: 'Save partial edits to an inactive Draft: record_create_or_update trigger table/condition and existing action inputs. Use --preview for before/after values without writes. Published checkout, Flow Logic, variables, catalog-trigger edits and stage placement require Flow Designer. Never publishes or executes. Use `jsn flows schemas` and `jsn actions action-type-schema --id <actionTypeId> --scope <scopeId>` for live schemas; omitted values are preserved.',
326
345
  builder: (y) => y
327
346
  .option('data', { type: 'string', describe: 'Update manifest as inline JSON' })
328
347
  .option('data-file', { type: 'string', describe: 'Read the update manifest from a JSON file' })
348
+ .option('preview', { type: 'boolean', default: false, describe: 'Validate and show exact before/after input values without saving or versioning' })
329
349
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
330
350
  handler: wrap(async (argv, app) => {
331
351
  app.requireInstance();
@@ -341,12 +361,14 @@ export function flowsCmd(wrap) {
341
361
  } catch (error) {
342
362
  throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
343
363
  }
344
- await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
364
+ if (!argv.preview) await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
345
365
  const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
366
+ preview: argv.preview,
346
367
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
347
368
  });
348
- return app.ok(result, {
349
- summary: `Updated draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
369
+ const diff = result.changes.map(change => `${change.component}${change.order ? ` ${change.order}` : ''}.${change.input}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`).join('\n');
370
+ return app.ok({ ...result, _formatted: diff }, {
371
+ summary: `${result.preview ? 'Preview for' : 'Updated'} draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
350
372
  breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
351
373
  });
352
374
  }),
@@ -0,0 +1,102 @@
1
+ import { checkbox, select, confirm } from '@inquirer/prompts';
2
+ import { canUsePicker, interactiveList, getStringField } from '../helpers.js';
3
+ import { declareCapabilities } from '../capabilities.js';
4
+ import { errUsage } from '../errors.js';
5
+ import { discoverSkippedUpdates, reviewSkippedUpdate, prepareSkippedBatch, processSkippedBatch, raw, SKIP_TABLE } from '../skipped-updates.js';
6
+
7
+ const label = row => `${raw(row.sys_id)} ${getStringField(row, 'target_name') || getStringField(row, 'file_name')}`;
8
+ const selectionOptions = y => y
9
+ .option('ids', { type: 'string', describe: 'Comma-separated literal finding sys_ids, maximum 100' })
10
+ .option('action', { type: 'string', choices: ['retain', 'revert'], describe: 'Explicit selected action, never inferred from recommendations' });
11
+
12
+ export function renderSkippedPreview(preview) {
13
+ const lines = [`Instance: ${preview.instance}`, `Exact selected batch: ${preview.count} finding(s)`];
14
+ for (const item of preview.selected) {
15
+ lines.push(`\n${item.sys_id}: ${item.artifact.update_name}`, `Action: ${item.action}`, item.operation,
16
+ `Recommendation only: ${item.impact.recommendation.action}. ${item.impact.recommendation.basis}`,
17
+ `State: disposition=${item.finding.disposition}, resolution=${item.finding.resolution_status}`,
18
+ `Evidence: base version ${item.impact.evidence.baseline_version || 'unknown'}, source ${item.impact.evidence.baseline_source_table || 'unknown'}/${item.impact.evidence.baseline_source || 'unknown'}`,
19
+ `Finding: ${preview.instance}/${SKIP_TABLE}.do?sys_id=${item.sys_id}`,
20
+ item.impact.direct_behavior.summary);
21
+ for (const change of item.impact.changes) lines.push(` ${change.field}: ${JSON.stringify(change.current)} -> ${JSON.stringify(change.base)}`, ` ${change.effect}`);
22
+ for (const dep of item.impact.known_dependencies) lines.push(` Known dependency: ${dep.table}.${dep.field} / ${dep.sys_id}`);
23
+ for (const gap of item.impact.unknown) lines.push(` Unknown: ${gap}`);
24
+ }
25
+ lines.push(`\nConfirmation token: ${preview.confirmation_token}`, 'No writes have been performed.');
26
+ return lines.join('\n');
27
+ }
28
+
29
+ export function skippedUpdatesCmd(wrap, ui = {}) {
30
+ declareCapabilities('skipped-updates', { mutationSubcommands: ['process'] });
31
+ const prompts = { checkbox, select, confirm, canUsePicker, interactiveList, ...ui };
32
+ async function showReview(app, id) {
33
+ const review = await reviewSkippedUpdate(app.sdk, id);
34
+ const display = { instance: app.getEffectiveInstance(), selected: [{ ...review, action: 'none', operation: 'Read-only review.' }], count: 1, confirmation_token: 'not applicable' };
35
+ app.ok({ ...review, _formatted: renderSkippedPreview(display) }, { summary: 'Skipped update review; deeper effects remain unknown' });
36
+ }
37
+ async function selection(argv, app, guided) {
38
+ let ids = typeof argv.ids === 'string' ? argv.ids.split(',') : undefined;
39
+ let action = argv.action;
40
+ if (!ids && guided) {
41
+ const page = await discoverSkippedUpdates(app.sdk, { limit: 100, offset: 0 });
42
+ if (!page.count) throw errUsage('No unresolved skipped findings were returned.');
43
+ ids = await prompts.checkbox({ message: `Select findings from this page${page.has_more ? ' (more exist; use list --offset for others)' : ''}`,
44
+ choices: page.findings.map(row => ({ name: label(row), value: raw(row.sys_id) })), required: true });
45
+ }
46
+ if (!ids?.length) throw errUsage('Provide --ids with the exact selected finding sys_ids.');
47
+ if (!action && guided) action = await prompts.select({ message: 'Choose the action for these findings',
48
+ choices: [{ name: 'Retain customization, mark reviewed and retained', value: 'retain' },
49
+ { name: 'Return to base system, discard customization', value: 'revert' }] });
50
+ return { ids, action, instance: app.getEffectiveInstance() };
51
+ }
52
+ return { command: 'skipped-updates', describe: 'Review and process existing unresolved skipped updates across all upgrade history',
53
+ builder: y => y
54
+ .command({ command: 'list', describe: 'Discover unresolved skips instance-wide, independent of new upgrades',
55
+ builder: y => y.option('limit', { type: 'number', default: 100, describe: 'Page size, maximum 500' })
56
+ .option('offset', { type: 'number', default: 0, describe: 'Page offset' }),
57
+ handler: wrap(async (argv, app) => {
58
+ app.requireInstance();
59
+ if (prompts.canUsePicker(app, argv)) {
60
+ const selected = await prompts.interactiveList({ app, table: SKIP_TABLE, singular: 'skipped update',
61
+ columns: ['target_name', 'file_name', 'disposition', 'resolution_status'], labelField: 'target_name',
62
+ query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed', formatLabel: label });
63
+ if (selected === undefined) return;
64
+ if (selected) { await showReview(app, raw(selected.sys_id)); return; }
65
+ }
66
+ const page = await discoverSkippedUpdates(app.sdk, argv);
67
+ app.ok(page, { summary: `${page.count} unresolved finding(s) fetched${page.has_more ? '; more available' : ''}. Instance-wide total is not measured.` });
68
+ }) })
69
+ .command({ command: 'review <sys-id>', describe: 'Review artifact evidence, field changes and bounded known dependencies',
70
+ handler: wrap(async (argv, app) => { app.requireInstance();
71
+ await showReview(app, argv['sys-id']);
72
+ }) })
73
+ .command({ command: 'preview', describe: 'Read-only exact batch preview and confirmation token', builder: selectionOptions,
74
+ handler: wrap(async (argv, app) => { app.requireInstance();
75
+ const options = await selection(argv, app, prompts.canUsePicker(app, argv));
76
+ const preview = await prepareSkippedBatch(app.sdk, options);
77
+ app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Read-only batch preview; review before confirming' });
78
+ }) })
79
+ .command({ command: 'process', describe: 'Apply selected action after ONE explicit confirmation of the exact preview',
80
+ builder: y => selectionOptions(y).option('confirm', { type: 'string', describe: 'Confirmation token from a previously reviewed exact preview; required in automation' }),
81
+ handler: wrap(async (argv, app) => { app.requireInstance();
82
+ try {
83
+ const guided = prompts.canUsePicker(app, argv);
84
+ const options = await selection(argv, app, guided);
85
+ const result = await processSkippedBatch({ sdk: app.sdk, ...options,
86
+ readOnly: app.session?.readOnly === true, confirmationToken: argv.confirm,
87
+ onPreview: guided ? async preview => app.ok({ ...preview, _formatted: renderSkippedPreview(preview) }, { summary: 'Review exact batch before confirmation' }) : undefined,
88
+ confirm: guided && !argv.confirm ? async () => prompts.confirm({ message: 'Apply exactly this selected batch?', default: false }) : undefined });
89
+ app.ok({ ...result, _formatted: result.cancelled ? 'Cancelled. No writes performed.'
90
+ : result.results.map(row => `${row.sys_id} ${row.action}: ${row.success ? 'verified success' : `FAILED (${row.effect})`}`).join('\n') },
91
+ { summary: `${result.success_count} succeeded; ${result.failure_count} failed${result.partial ? '; partial batch' : ''}` });
92
+ if (result.failure_count) process.exitCode = 1;
93
+ } catch (err) {
94
+ if (err.name === 'ExitPromptError' || err.name === 'AbortPromptError') {
95
+ app.ok({ cancelled: true, writes_performed: false }, { summary: 'Cancelled. No writes performed.' }); return;
96
+ }
97
+ throw err;
98
+ }
99
+ }) })
100
+ .demandCommand(1, 'Choose list, review, preview or process'),
101
+ };
102
+ }
@@ -1,7 +1,10 @@
1
1
  import fs from 'node:fs';
2
- import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../helpers.js';
2
+ import { editor, confirm } from '@inquirer/prompts';
3
+ import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
4
+ import { getActiveProfile } from '../config.js';
5
+ import { FormatAuto } from '../output.js';
3
6
  import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
4
- import { errAPI, errUsage } from '../errors.js';
7
+ import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
5
8
  import { declareCapabilities } from '../capabilities.js';
6
9
 
7
10
  declareCapabilities('updatesets', {
@@ -133,6 +136,28 @@ async function fetchUpdateSetById(app, sysID) {
133
136
  return Array.isArray(records) && records.length > 0 ? records[0] : null;
134
137
  }
135
138
 
139
+ /** Completion refuses duplicate names rather than guessing a mutation target. */
140
+ async function resolveCompletionTarget(app, name) {
141
+ if (name !== undefined) {
142
+ if (!String(name).trim()) throw errUsage('Update set name must not be empty');
143
+ assertSafeExactMatch(name);
144
+ const params = new URLSearchParams({
145
+ sysparm_query: `name=${name}`, sysparm_limit: '2',
146
+ sysparm_display_value: 'all', sysparm_fields: 'sys_id,name',
147
+ });
148
+ const records = await app.sdk.list('sys_update_set', params);
149
+ if (!records.length) throw errUsage(`Update set not found: ${name}`);
150
+ if (records.length !== 1) {
151
+ throw errUsage(`Multiple update sets named "${name}". Select the intended set with "jsn updatesets set", then run "jsn updatesets complete" without a name.`);
152
+ }
153
+ return { sys_id: rawReferenceId(records[0].sys_id), name: getStringField(records[0], 'name') };
154
+ }
155
+ const userSysID = await requireCurrentUserSysId(app.sdk);
156
+ const current = await getCurrentUpdateSet(app.sdk, userSysID);
157
+ if (!current?.sys_id) throw errUsage('No current update set. Set one first:\n jsn updatesets set');
158
+ return current;
159
+ }
160
+
136
161
  /** Build a safe, flat preorder hierarchy with ancestors before the selected set. */
137
162
  export async function buildUpdateSetHierarchy(app, selectedRecord) {
138
163
  const selectedID = getStringField(selectedRecord, 'sys_id');
@@ -535,25 +560,16 @@ export function updateSetsCmd(wrap) {
535
560
  throw errUsage(`Cannot set "${name}" as current — update set is ${stateRaw === 'ignore' ? 'ignored' : 'complete'}.\nView it instead: jsn updatesets show "${name}"`);
536
561
  }
537
562
  const userSysID = await requireCurrentUserSysId(app.sdk);
538
- await setCurrentUpdateSet(app.sdk, userSysID, sysID);
539
-
540
- // Like the platform update set picker: switching the set also
541
- // switches the application scope into the set's scope. The
542
- // application field is a sys_scope reference, so its raw value
543
- // IS the scope sys_id the apps.current_app preference stores.
563
+ // Scope selection can reset the session's capture set. Select the
564
+ // scope first, then the requested set, and verify both picker writes.
565
+ const scopeSysId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
544
566
  let scopeNote = '';
545
- try {
546
- const scopeSysId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
547
- if (scopeSysId) {
548
- await setCurrentApplication(app.sdk, userSysID, scopeSysId);
549
- const scopeName = updateSetApp?.display_value || updateSetApp;
550
- scopeNote = ` — scope switched to ${scopeName}`;
551
- }
552
- } catch {
553
- // scope switch failed — fall back to a warning
554
- const currentApp = await getCurrentApplication(app.sdk, userSysID).catch(() => null);
555
- scopeNote = scopeMismatchWarning(updateSetApp, currentApp);
567
+ if (scopeSysId) {
568
+ await setCurrentApplication(app.sdk, userSysID, scopeSysId);
569
+ const scopeName = updateSetApp?.display_value || updateSetApp;
570
+ scopeNote = ` — scope switched to ${scopeName}`;
556
571
  }
572
+ await setCurrentUpdateSet(app.sdk, userSysID, sysID);
557
573
 
558
574
  app.ok({ update_set: name, sys_id: sysID }, { summary: `Current update set: ${name}${scopeNote}` });
559
575
  }),
@@ -606,15 +622,18 @@ export function updateSetsCmd(wrap) {
606
622
 
607
623
  // Auto-set as current update set
608
624
  const sysID = record?.sys_id?.value || record?.sys_id;
625
+ let selectionNote = '';
609
626
  try {
610
627
  const userSysID = await requireCurrentUserSysId(app.sdk);
611
628
  await setCurrentUpdateSet(app.sdk, userSysID, sysID);
612
- } catch {
613
- // Non-fatal — auto-set is a convenience, not mandatory
629
+ } catch (error) {
630
+ // Creation succeeded. Report partial success without hiding a
631
+ // failed selection or inviting a duplicate create on retry.
632
+ selectionNote = `; not selected as current: ${error.message}. Run jsn updatesets set "${argv.name}"`;
614
633
  }
615
634
 
616
635
  app.ok(record, {
617
- summary: `Created update set: ${argv.name}`,
636
+ summary: `Created update set: ${argv.name}${selectionNote}`,
618
637
  breadcrumbs: [
619
638
  {
620
639
  action: 'set',
@@ -631,16 +650,57 @@ export function updateSetsCmd(wrap) {
631
650
  }),
632
651
  })
633
652
  .command({
634
- command: 'complete',
635
- describe: 'Mark the current update set as complete',
653
+ command: 'complete [name]',
654
+ describe: 'Complete a named or current update set with completion notes',
655
+ builder: (y) => y
656
+ .positional('name', { type: 'string', describe: 'Unique update set name, defaults to the current set' })
657
+ .option('notes', { type: 'string', describe: 'Completion notes, one bullet per line; opens an editor when omitted interactively' }),
636
658
  handler: wrap(async (argv, app) => {
637
- const userSysID = await requireCurrentUserSysId(app.sdk);
638
- const current = await getCurrentUpdateSet(app.sdk, userSysID);
639
- if (!current?.sys_id) {
640
- throw errUsage('No current update set. Set one first:\n jsn updatesets set');
659
+ const current = await resolveCompletionTarget(app, argv.name);
660
+ const record = await fetchUpdateSetById(app, current.sys_id);
661
+ if (!record) throw errUsage(`Update set not found or unreadable: ${current.sys_id}`);
662
+ if (!Object.hasOwn(record, 'description')
663
+ || (record.description && typeof record.description === 'object' && !Object.hasOwn(record.description, 'value'))) {
664
+ throw errUsage(`Description is unreadable for update set "${current.name}" (${current.sys_id}); refusing to replace unknown text.`);
665
+ }
666
+ const existing = String(record.description && typeof record.description === 'object'
667
+ ? record.description.value ?? '' : record.description ?? '');
668
+ const interactive = !argv.json && !argv.get && (!argv.format || argv.format === FormatAuto)
669
+ && app.output?.getFormat() === FormatAuto && canPrompt();
670
+ let notes = argv.notes;
671
+ let description;
672
+ try {
673
+ if (notes === undefined) {
674
+ if (!interactive) throw errUsage('Provide nonempty completion notes with --notes in noninteractive mode');
675
+ notes = await editor({ message: `Completion notes for "${current.name}". Write concise bullets, one per line` });
676
+ }
677
+ const bullets = String(notes).split(/\r?\n/)
678
+ .map(line => line.trim().replace(/^[-*•]\s*/, '').trim()).filter(Boolean)
679
+ .map(line => `- ${line}`).join('\n');
680
+ if (!bullets) throw errUsage('Provide nonempty completion notes with --notes');
681
+ description = existing ? `${existing}\n\n${bullets}` : bullets;
682
+ if (existing && !argv.force && getActiveProfile(app.config)?.skip_confirmations !== true) {
683
+ const question = `Append notes and complete "${current.name}"?\nExisting Description:\n${existing}\n\nNew notes:\n${bullets}`;
684
+ if (!interactive) throw errConfirmationRequired(question, buildRerunCommand(argv, {
685
+ rerun: ['updatesets', 'complete', ...(argv.name !== undefined ? [argv.name] : []), '--notes', String(notes)],
686
+ }));
687
+ if (!await confirm({ message: question, default: false })) return;
688
+ }
689
+ } catch (error) {
690
+ if (['ExitPromptError', 'AbortPromptError'].includes(error.name)) return;
691
+ throw error;
692
+ }
693
+ const data = { state: 'complete', description };
694
+ try {
695
+ await app.sdk.update('sys_update_set', current.sys_id, data);
696
+ } catch (error) {
697
+ throw errAPI(error.status || 0, `Could not complete update set "${current.name}" (${current.sys_id}): ${error.message}. Check the record before retrying.`);
698
+ }
699
+ const mismatches = await verifyWriteBack(app, 'sys_update_set', current.sys_id, data);
700
+ if (mismatches.length) {
701
+ throw errAPI(200, `Could not verify completion of update set "${current.name}" (${current.sys_id}): ${mismatches.map(item => item.field).join(', ')} did not persist or could not be read. The write may have partially succeeded; inspect the set before retrying.`);
641
702
  }
642
- await app.sdk.update('sys_update_set', current.sys_id, { state: 'complete' });
643
- app.ok({ update_set: current.name, state: 'complete' }, { summary: `Update set marked complete: ${current.name}` });
703
+ app.ok({ update_set: current.name, sys_id: current.sys_id, state: 'complete', description }, { summary: `Update set marked complete: ${current.name}` });
644
704
  }),
645
705
  })
646
706
  .command({
@@ -667,7 +727,7 @@ export function updateSetsCmd(wrap) {
667
727
  console.log(' export <name> Export an update set to XML (stdout or --out)');
668
728
  console.log(' set [name] Set the current update set (picker when run bare)');
669
729
  console.log(' create Create a new update set (auto-sets as current)');
670
- console.log(' complete Mark the current update set as complete');
730
+ console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
671
731
  console.log(' ignore Ignore the current update set');
672
732
  console.log(' parent <name> Set the parent of an update set');
673
733
  console.log('\nRun "jsn updatesets <command> --help" for details.');