@jacebenson/jsn 4.3.8 → 4.3.9

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 CHANGED
@@ -44,6 +44,106 @@ private files and offers a foreground console, not native service registration.
44
44
  See [direct MID installation](docs/mid-direct-install.md) for security boundaries,
45
45
  lifecycle commands and the untested Windows host phases. Docker remains available.
46
46
 
47
+ ## Customization reports
48
+
49
+ ```sh
50
+ jsn customizations capture --label before-upgrade --customer-scope x_acme_app
51
+ jsn customizations list
52
+ jsn customizations show <run_id>
53
+ jsn customizations html <run_id> # writes customizations-<run_id>.html
54
+ jsn customizations html <run_id> --out review.html
55
+ jsn customizations compare <baseline_id> <new_id> --json
56
+ jsn customizations show <run_id> --get data.coverage
57
+ jsn customizations show <run_id> --get data.triage
58
+ ```
59
+
60
+ Capture data access uses Table API GET requests only. Captures never run
61
+ background scripts or create, update or delete instance records. Standard
62
+ authentication still applies, including OAuth refresh, local refreshed-credential
63
+ storage and session/GCK bootstrap, which can make authentication POST requests.
64
+ Expired credentials without a usable refresh token require login. Reports are private local JSON files in
65
+ `$JSN_DATA_HOME/customizations`, defaulting to `~/.jsn/customizations`. List,
66
+ show and compare work offline. Interactive terminals offer local report pickers;
67
+ pass explicit IDs with JSON, pipes or `--get`.
68
+
69
+ The bounded inventory joins visible `sys_metadata`, `sys_scope` and
70
+ `sys_upgrade_history_log` records. `ootb_modifications` contains artifacts
71
+ with historical skipped-upgrade evidence, not a verified current OOTB diff.
72
+ Because the broad `sys_metadata` read is an arbitrary `sys_id`-ordered slice
73
+ (994 of 621972 rows on a large instance), it is reported as a **bounded sample,
74
+ not an inventory**: it only ever describes the rows the page budget reached.
75
+ The skipped-upgrade artifacts are inventoried separately and completely for the
76
+ observed history: after the filtered upgrade-history read, every observed
77
+ `sys_update_name` is resolved with chunked GET-only
78
+ `sys_update_nameIN<chunk of names>` lookups on `sys_metadata`, so an artifact
79
+ with skipped-upgrade evidence appears in `ootb_modifications` even when it falls
80
+ far outside the sample. `coverage.upgrade_evidence` reports the skipped rows
81
+ observed, the distinct update names, how many resolved by direct lookup, the
82
+ unresolved name list and any failed lookup chunks.
83
+ `customer_app_artifacts` contains only scopes explicitly declared with repeatable
84
+ `--customer-scope` values or sys_ids. The instance verifies the scope identity,
85
+ not its ownership. Installed/vendor scopes are never automatically called
86
+ customer-built. Other visible artifacts remain `unclassified`, and both those
87
+ categories cover only what the bounded sample read.
88
+
89
+ Each item reports identity, scope, evidence, unknown risk with rationale and a
90
+ review recommendation. Historical OOTB findings include the known skipped-upgrade
91
+ impact and explicit unknown baseline, runtime impact and dependencies. No
92
+ reversion is performed or recommended as safe without further investigation.
93
+
94
+ `show` adds a computed `data.triage` view with evidence confidence, the reason for
95
+ the classification and concrete review steps; it does not alter saved captures or
96
+ pretend to score risk. `html` exports a self-contained offline report with search,
97
+ classification filters, grouping, evidence, coverage and links back to records.
98
+ The default output is `customizations-<run_id>.html`; existing files are never
99
+ overwritten. The report does not change ServiceNow records.
100
+
101
+ Defaults are 100 records per page and 10 pages per source. Adjust `--page-size`
102
+ and `--max-pages` within the documented limits. Source coverage compares the rows
103
+ fetched with the Stats API count for the same query and reports both. A page
104
+ shorter than the requested size never implies exhaustion; `exhausted` is reported
105
+ only when the fetched rows account for the available total, and an unavailable
106
+ count is reported as unknown rather than implied completeness. Other statuses
107
+ distinguish missing fields, unstable pagination and read failures. Both the count
108
+ and the rows reflect visible ACL-filtered records, so hidden rows cannot be ruled
109
+ out and reports remain `incomplete`. An empty category does not prove that no
110
+ customizations exist. A 2xx table body without a `result` array is treated as an
111
+ unavailable source, never as an empty table.
112
+
113
+ Comparisons show newly observed, no-longer-observed and changed metadata/evidence.
114
+ They ignore local capture IDs, labels, timestamps and enumeration order.
115
+ They do not prove record creation/deletion or detect source-body changes without
116
+ captured metadata changes. Changed entries render the concrete before/after field
117
+ values, and additions/removals list the artifact's name, table, scope, category
118
+ and evidence basis. When either report has an unavailable or empty source, the
119
+ comparison status is `unreliable` and those differences are labelled as missing
120
+ coverage instead of mass creation or deletion. Compare only reports from the same
121
+ instance.
122
+
123
+ Provider contract: [ServiceNow Customizations Unchanged](https://www.servicenow.com/docs/r/xanadu/platform-administration/customizations-unchanged-related-list.html)
124
+ explains that the `Skipped` disposition preserves customizations. Other skip
125
+ reasons are not treated as equivalent evidence. This command cannot establish a
126
+ trusted baseline from that history alone.
127
+
128
+ The verified provider join is `sys_upgrade_history_log.file_name` to
129
+ `sys_metadata.sys_update_name`, not the log's nonexistent `name` field. Capture
130
+ uses raw values from `sysparm_display_value=all`: disposition `4` or `104`
131
+ means Skipped or Skipped (second pass). Resolution `reviewed_reverted` excludes
132
+ that history row; a present empty resolution is distinct from a missing field.
133
+ The upgrade-history read is filtered server-side to those dispositions so a
134
+ bounded capture spends its pages on preserved-customization evidence instead of
135
+ arbitrary history rows, and `unmatched_upgrade_records` counts only that filtered
136
+ population, not all upgrade history. The filtered history is the unit of the
137
+ inventory claim: every observed `file_name` is then resolved against visible
138
+ `sys_metadata` with chunked GET-only `sys_update_nameIN<chunk>` lookups, so
139
+ skipped artifacts are inventoried from the history instead of from the arbitrary
140
+ `sys_id`-ordered metadata sample. `unmatched_upgrade_records` and
141
+ `coverage.upgrade_evidence.unresolved_update_names` describe only names no
142
+ visible metadata row matched; a failed lookup chunk is reported rather than
143
+ treated as an absent artifact.
144
+ These values were checked against the PDI dictionary, choices and actual joined
145
+ rows. Display labels are not used for classification.
146
+
47
147
  ## Quick Start
48
148
 
49
149
  ```bash
@@ -107,6 +207,26 @@ jsn snippets run open-inc
107
207
  jsn logs follow --level error --tail 10
108
208
  ```
109
209
 
210
+ ### Update set completion
211
+
212
+ ```bash
213
+ jsn updatesets complete # Current set, opens a notes editor in a human terminal
214
+ jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
215
+ ```
216
+
217
+ `complete [name]` saves concise bullet-list notes in the selected set's Description
218
+ and marks it complete. Omit the name to use the current set. Explicit names
219
+ must be unique; for duplicate names, select the intended set with `updatesets set`
220
+ and complete it without a name. ServiceNow's normal batch completion rules still
221
+ apply: completing a parent can complete its children. Notes are not copied to children.
222
+
223
+ Existing Description text is preserved exactly. The command shows it and asks
224
+ before appending notes, unless `--force` or the profile's `skip_confirmations`
225
+ policy explicitly allows the append. Scripts and JSON output must supply
226
+ nonempty `--notes`; they never open prompts. Separate multiple notes with real
227
+ newlines. Success requires readback of both Description and state. If verification
228
+ fails, inspect the set before retrying because the write may have partially succeeded.
229
+
110
230
  ### Flow Designer actions
111
231
 
112
232
  `actions` and `action` are the same command. `list`, `show`, and explicit
@@ -324,6 +444,8 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
324
444
 
325
445
  **Automation** — `flows`, `actions`, `rules`, `scrapi`, `updatesets`, `eval`, `rest`
326
446
 
447
+ **Upgrade review** — [`skipped-updates`](docs/skipped-updates.md) discovers existing unresolved skips, reviews current/base field changes and known dependencies, and processes only explicitly selected actions after an exact batch preview and one confirmation.
448
+
327
449
  **Access** — `acls`, `roles`, `scopes`, `properties`, `privileges`
328
450
 
329
451
  **User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.8",
3
+ "version": "4.3.9",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "main": "src/cli.js",
10
10
  "scripts": {
11
- "test": "node --experimental-test-module-mocks --test --test-concurrency=1 $(find test -name '*.test.js')",
11
+ "test": "node --experimental-test-module-mocks --test --test-concurrency=4 $(find test -name '*.test.js')",
12
+ "test:mutation": "stryker run",
12
13
  "lint": "npx eslint src/ bin/ test/",
13
14
  "start": "node bin/jsn.js",
14
15
  "docs": "npm --prefix docs run build",
@@ -35,6 +36,7 @@
35
36
  },
36
37
  "devDependencies": {
37
38
  "@eslint/js": "^10.0.1",
39
+ "@stryker-mutator/core": "^10.0.0",
38
40
  "globals": "^17.6.0"
39
41
  },
40
42
  "files": [
@@ -73,6 +73,60 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
73
73
  | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
74
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
75
75
 
76
+ ### Customization evidence
77
+
78
+ ```sh
79
+ jsn customizations capture --label review --customer-scope x_acme_app --json
80
+ jsn customizations list --json
81
+ jsn customizations show <run_id> --get data.categories
82
+ jsn customizations show <run_id> --get data.triage
83
+ jsn customizations html <run_id> [--out review.html]
84
+ jsn customizations compare <baseline_id> <new_id> --json
85
+ ```
86
+
87
+ Capture performs bounded GET-only table reads and saves local reports. It never
88
+ evals or creates, updates or deletes instance records. Standard authentication
89
+ may refresh OAuth tokens, store refreshed credentials locally and bootstrap a
90
+ session/GCK using authentication POSTs. List/show/compare are offline.
91
+ Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
92
+ never infer customer ownership from a non-global scope. The declaration is
93
+ caller evidence, not independent publisher verification. OOTB findings reflect
94
+ historical upgrade skips, not a current verified baseline deviation. The upgrade
95
+ history is read server-side for the skip dispositions and every observed skipped
96
+ `file_name` is resolved against `sys_metadata` by chunked
97
+ `sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
98
+ skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
99
+ slice and is reported as a bounded sample, not an inventory, describing only the
100
+ unclassified and declared-customer categories. Unresolved skipped names and
101
+ failed lookup chunks stay explicit rather than becoming absent artifacts, and a
102
+ resolved artifact can lack a scope name when its scope record was outside the
103
+ bounded `sys_scope` read. Missing
104
+ ACL access, pagination limits and unmatched evidence remain explicit. All
105
+ reports are incomplete; no findings is not proof of no customizations.
106
+ Comparisons cover captured metadata/evidence, not source payloads. Removals mean
107
+ no longer observed, not proven deletion. Reversion impact and dependencies
108
+ remain unknown until separately investigated. Never recommend automatic reversion.
109
+
110
+ Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
111
+ `4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
112
+ excludes that history row. Use raw values, not display labels or other skip reasons.
113
+
114
+ ### Complete an update set
115
+
116
+ ```bash
117
+ jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
118
+ ```
119
+
120
+ `complete [name]` defaults to the current set when the name is omitted. Explicit
121
+ names must be unique. Supply nonempty `--notes` in automation; real newlines
122
+ separate bullets. A human terminal opens a notes editor when `--notes` is absent.
123
+ Existing Description text is preserved and appended after confirmation. Use
124
+ `--force` only after approval, or the profile's `skip_confirmations` policy.
125
+ Notes apply only to the selected set. ServiceNow's normal batch rules can complete
126
+ child sets when the parent completes; their descriptions are not replaced.
127
+ Both selected-set Description and state are read back before success. On
128
+ verification failure, inspect the set before retrying.
129
+
76
130
  ### Table and raw REST examples
77
131
 
78
132
  ```bash
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { App } from './app.js';
9
9
  import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
+ import { skippedUpdatesCmd } from './commands/skipped-updates.js';
12
13
  import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
13
14
  import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
14
15
  import { formatCommandList } from './helpers.js';
@@ -70,6 +71,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
70
71
  import { atfCmd } from './commands/atf.js';
71
72
  import { approvalsCmd } from './commands/approvals.js';
72
73
  import { perfCmd } from './commands/perf.js';
74
+ import { customizationsCmd } from './commands/customizations.js';
73
75
  import { midserversCmd } from './commands/midservers.js';
74
76
  import { diffCmd } from './commands/diff.js';
75
77
  import { graphqlCmd } from './commands/graphql.js';
@@ -405,7 +407,9 @@ export function buildCLI() {
405
407
  // DEVELOPER
406
408
  .command(atfCmd(wrap))
407
409
  .command(approvalsCmd(wrap))
410
+ .command(skippedUpdatesCmd(wrap))
408
411
  .command(perfCmd(wrap))
412
+ .command(customizationsCmd(wrap))
409
413
  .command(midserversCmd(wrap))
410
414
  .command(diffCmd(wrap))
411
415
  .command(graphqlCmd(wrap))
@@ -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,7 +341,7 @@ 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: 'Safely update an inactive Draft flow from a partial JSON manifest. Use `jsn flows schemas` for live trigger inputs and `jsn actions action-type-schema --id <actionTypeId> --scope <scopeId>` for provider action inputs. Manifest structure remains code-owned; 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' })