@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 +122 -0
- package/package.json +4 -2
- package/skills/servicenow/SKILL.md +54 -0
- package/src/cli.js +4 -0
- package/src/commands/actions.js +89 -2
- package/src/commands/catalog.js +2 -2
- package/src/commands/customizations.js +82 -0
- package/src/commands/flows.js +22 -3
- package/src/commands/skipped-updates.js +102 -0
- package/src/commands/updatesets.js +91 -31
- package/src/context.js +16 -25
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +517 -0
- package/src/flow-publish.js +15 -3
- package/src/impact-analysis.js +67 -0
- package/src/sdk.js +33 -2
- package/src/skipped-update-provider.js +45 -0
- package/src/skipped-updates.js +177 -0
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.
|
|
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=
|
|
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))
|
package/src/commands/actions.js
CHANGED
|
@@ -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
|
|
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
|
|
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' }),
|
package/src/commands/catalog.js
CHANGED
|
@@ -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
|
|
67
|
-
const label = parts.length >= 3 ? parts
|
|
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
|
+
}
|
package/src/commands/flows.js
CHANGED
|
@@ -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
|
|
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
|
|
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' })
|