@jacebenson/jsn 4.3.13-dev.99.1 → 4.4.0
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 +98 -16
- package/package.json +3 -3
- package/skills/servicenow/SKILL.md +1 -1
- package/src/auth-recovery.js +1 -1
- package/src/cli.js +6 -0
- package/src/command-audience.js +4 -1
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/knowledge.js +75 -0
- package/src/commands/records.js +3 -0
- package/src/commands/scan.js +234 -0
- package/src/commands/skill.js +1 -0
- package/src/commands/updatesets.js +1 -0
- package/src/commands/upgrade.js +69 -0
- package/src/commands/version.js +2 -2
- package/src/help.js +2 -0
- package/src/scan.js +249 -0
package/README.md
CHANGED
|
@@ -23,8 +23,12 @@ npm install -g @jacebenson/jsn@dev
|
|
|
23
23
|
|
|
24
24
|
Dev builds check the dev channel for updates during normal use, at most once
|
|
25
25
|
per day. Run `jsn version --check` to check immediately. To install the newest
|
|
26
|
-
dev build, run `
|
|
27
|
-
|
|
26
|
+
dev build, run `jsn upgrade` (alias: `jsn update`). It preserves your installed
|
|
27
|
+
channel and skips installation when no newer version is available. Use
|
|
28
|
+
`--dry-run` to print the npm command without network access or installation,
|
|
29
|
+
`--force` to reinstall, or `--channel dev|latest` to switch channels explicitly.
|
|
30
|
+
For example, `jsn upgrade --channel latest` returns to stable. npm installation
|
|
31
|
+
permissions still apply; the command does not elevate privileges automatically.
|
|
28
32
|
|
|
29
33
|
Pushes to `main` promote the commit to `dev` and publish a tested prerelease
|
|
30
34
|
under npm's `dev` tag. Versions include the workflow run number and attempt
|
|
@@ -32,6 +36,66 @@ under npm's `dev` tag. Versions include the workflow run number and attempt
|
|
|
32
36
|
The npm trusted publisher for `publish-dev.yml` must authorize the repository's
|
|
33
37
|
`dev` GitHub environment, alongside the production publishing configuration.
|
|
34
38
|
|
|
39
|
+
### Knowledge search
|
|
40
|
+
|
|
41
|
+
Use `jsn knowledge search "VPN setup" --profile work` (alias `kb`) to search
|
|
42
|
+
accessible articles, then `jsn knowledge show KB0000010 --profile work` to read
|
|
43
|
+
one. Search supports bounded `--limit`/`--offset`, knowledge-base sys_ids with
|
|
44
|
+
`--kb`, and `--language`. JSON preserves native HTML; human output uses Markdown.
|
|
45
|
+
Hints preserve an explicitly selected profile/instance. The Knowledge API plugin
|
|
46
|
+
(`sn_km_api`) must be available; access is evaluated by that API, with no fallback
|
|
47
|
+
to direct table reads. Reads do not request view-count updates. See the
|
|
48
|
+
[Knowledge API reference](https://www.servicenow.com/docs/r/yokohama/api-reference/rest-apis/knowledge-api.html).
|
|
49
|
+
|
|
50
|
+
### Instance Scan
|
|
51
|
+
|
|
52
|
+
`jsn scan` manages checks and suites and runs scans against an explicit target.
|
|
53
|
+
Use `jsn scan --help` and each subcommand's `--help` to discover the options.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
jsn scan checks list --profile dev
|
|
57
|
+
jsn scan checks create --type table --data-file check.json --profile dev
|
|
58
|
+
jsn scan checks activate <check-id> --profile dev
|
|
59
|
+
jsn scan suites add-check <suite-id> --check <check-id> --profile dev
|
|
60
|
+
jsn scan run --update-set "My change" --suite "My suite" --wait --profile dev
|
|
61
|
+
jsn scan run --table sys_script_include --record <record-id> --profile dev
|
|
62
|
+
jsn scan results <execution-id> --profile dev
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Check types are `table`, `column`, `linter`, and `script`. Definitions are created
|
|
66
|
+
inactive unless the payload explicitly sets `active: true`; activation never
|
|
67
|
+
starts a scan. Both checks and suites support list/show/create/update/delete and
|
|
68
|
+
activate/deactivate. Suites also support add-check/remove-check.
|
|
69
|
+
|
|
70
|
+
Runs require an update set, scope, record plus table, or explicit `--full`.
|
|
71
|
+
Instance-wide scans may traverse many tables even when a check's script only
|
|
72
|
+
examines a few records. Starting and cancelling a scan require confirmation;
|
|
73
|
+
use `--force` only after approving that operation. `--wait --timeout 120` bounds
|
|
74
|
+
the wait, not the scan's lifetime. A timed-out wait keeps the execution id and
|
|
75
|
+
does not cancel or resubmit the scan. Use `status`, `results`, or `cancel` next.
|
|
76
|
+
|
|
77
|
+
Update-set scans examine current instance records, not the historical XML
|
|
78
|
+
versions. Inactive records may be skipped. Script-only checks do not participate
|
|
79
|
+
in record/update-set scans; use an explicitly instance-wide suite run to execute
|
|
80
|
+
them. Execution success does not mean zero findings or complete test coverage:
|
|
81
|
+
results report recorded check executions, finding counts, unreadable sources,
|
|
82
|
+
and truncated lists separately. A result's rerun hint preserves the previous
|
|
83
|
+
target combination and requires `--full` again for instance-wide scans.
|
|
84
|
+
|
|
85
|
+
Every command returns next-step breadcrumbs in human and JSON output, retaining
|
|
86
|
+
an explicitly selected profile or instance. `updatesets show` also offers a scan
|
|
87
|
+
hint. Recommendation/generation of new checks is not part of this command;
|
|
88
|
+
JSN contains no AI and does not infer missing business requirements.
|
|
89
|
+
|
|
90
|
+
### CMDB search
|
|
91
|
+
|
|
92
|
+
`jsn cmdb search "web" --profile work` searches names, FQDNs, IP addresses and
|
|
93
|
+
serial numbers on `cmdb_ci`. Use `--class cmdb_ci_server` for an exact class
|
|
94
|
+
match (excluding subclasses), and bounded `--limit`/`--offset` pagination.
|
|
95
|
+
Search text is literal; encoded-query operators and query scripts are rejected.
|
|
96
|
+
Existing `cmdb list --query` remains available for deliberate encoded queries.
|
|
97
|
+
Results include show/relationship hints and preserve the selected connection.
|
|
98
|
+
|
|
35
99
|
### Supported versions
|
|
36
100
|
|
|
37
101
|
Only the current release and the two preceding releases in each channel are
|
|
@@ -39,20 +103,15 @@ supported (three stable releases and three dev builds). For example, with
|
|
|
39
103
|
stable `4.3.11`, versions `4.3.11`, `4.3.10`, and `4.3.9` are supported.
|
|
40
104
|
Update to the newest version in your channel before reporting an issue.
|
|
41
105
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
To retry only deprecation after fixing authentication, use the matching release
|
|
52
|
-
checkout and run `node scripts/deprecate-old-versions.mjs latest --apply` (or
|
|
53
|
-
`dev --apply`) with npm authentication configured and `NODE_AUTH_TOKEN` set.
|
|
54
|
-
Omit `--apply` to preview the affected versions without changing npm. The script
|
|
55
|
-
requires the checkout's package version to match the channel's current npm tag.
|
|
106
|
+
Publishing uses npm trusted publishing (OIDC) and advances the channel's dist-tag.
|
|
107
|
+
CI/CD does not deprecate older versions or require an `NPM_TOKEN` secret. Older
|
|
108
|
+
versions remain available; their availability does not extend the support window.
|
|
109
|
+
|
|
110
|
+
The optional maintenance script `scripts/deprecate-old-versions.mjs` can preview
|
|
111
|
+
older versions with `node scripts/deprecate-old-versions.mjs latest` (or `dev`).
|
|
112
|
+
Applying deprecations with `--apply` requires npm authentication and
|
|
113
|
+
`NODE_AUTH_TOKEN`; it is separate from publishing. The script requires the
|
|
114
|
+
checkout's package version to match the channel's current npm tag.
|
|
56
115
|
|
|
57
116
|
## Try it
|
|
58
117
|
|
|
@@ -92,3 +151,26 @@ This applies to root help, root JSON, `discover`, and root shell completion.
|
|
|
92
151
|
Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
|
|
93
152
|
every registered command. Explicit command paths remain available and ServiceNow
|
|
94
153
|
ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
|
|
154
|
+
|
|
155
|
+
### Ordering catalog items
|
|
156
|
+
|
|
157
|
+
Use `jsn catalogitems search "laptop" --profile work`, then
|
|
158
|
+
`jsn catalogitems describe <item-sys-id> --profile work` to inspect pricing and
|
|
159
|
+
variable definitions through the native Service Catalog API. Create a JSON file
|
|
160
|
+
mapping variable names to values, then preview with
|
|
161
|
+
`jsn catalogitems order <item-sys-id> --variables-file variables.json --dry-run --profile work`.
|
|
162
|
+
Omit `--dry-run` to submit after confirmation. `--quantity` defaults to one;
|
|
163
|
+
`--requested-for` accepts a user sys_id subject to instance delegation rules.
|
|
164
|
+
|
|
165
|
+
Orders use `order_now`, preserving catalog fulfillment rather than inserting an
|
|
166
|
+
RITM directly. The command handles standard catalog items; record producers and
|
|
167
|
+
order guides require separate workflows. Server rules validate mandatory and
|
|
168
|
+
dynamic variables at submission; a preview does not validate those rules. Preview
|
|
169
|
+
output lists variable names, not entered values. This operation requires a
|
|
170
|
+
write-enabled profile (also for previews), but no development update set.
|
|
171
|
+
Confirmation follows the existing profile policy; automation can supply `--force`
|
|
172
|
+
only for an approved order. Timeout or incomplete response is an uncertain
|
|
173
|
+
outcome: inspect requests before retrying. JSN does not automatically submit a
|
|
174
|
+
second order. `create/list/show` retain their definition-management behavior.
|
|
175
|
+
|
|
176
|
+
See the [Service Catalog API reference](https://www.servicenow.com/docs/r/api-reference/rest-apis/c_ServiceCatalogAPI.html).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
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": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"node": ">=22.5.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@inquirer/core": "^10.
|
|
32
|
-
"@inquirer/prompts": "^7.
|
|
31
|
+
"@inquirer/core": "^10.3.2",
|
|
32
|
+
"@inquirer/prompts": "^7.10.1",
|
|
33
33
|
"better-sqlite3": "^13.0.3",
|
|
34
34
|
"js-yaml": "^5.5.0",
|
|
35
35
|
"saxes": "^6.0.0",
|
|
@@ -24,7 +24,7 @@ For development:
|
|
|
24
24
|
1. Choose or create the application scope and update set.
|
|
25
25
|
2. Do the work, keeping related changes together.
|
|
26
26
|
3. Verify the results on the instance and report where changes were captured.
|
|
27
|
-
4. Offer to
|
|
27
|
+
4. Offer to run relevant existing ATFs and instance scans, or create missing checks.
|
|
28
28
|
5. Ask whether anything else belongs in the update set. If the user confirms
|
|
29
29
|
it is finished, complete it with notes.
|
|
30
30
|
|
package/src/auth-recovery.js
CHANGED
|
@@ -67,7 +67,7 @@ export function createAuthRecovery({ app, session, argv = () => app._authArgv ||
|
|
|
67
67
|
&& canInteract(app, argv())) {
|
|
68
68
|
try {
|
|
69
69
|
if ((profile?.reauth ?? 'confirm') === 'confirm'
|
|
70
|
-
&& !await prompt({ message: `Sign in again in a browser for ${label}?`, default:
|
|
70
|
+
&& !await prompt({ message: `Sign in again in a browser for ${label}?`, default: true })) throw provider.error;
|
|
71
71
|
// Reuse the canonical GCK refresh/login operation without emitting a
|
|
72
72
|
// second success envelope or replacing the in-flight command's SDK.
|
|
73
73
|
const targetApp = Object.create(app);
|
package/src/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { authCmd } from './commands/auth.js';
|
|
|
22
22
|
import { recordsCmd } from './commands/records.js';
|
|
23
23
|
import { attachmentsCmd } from './commands/attachments.js';
|
|
24
24
|
import { cmdbCmd } from './commands/cmdb.js';
|
|
25
|
+
import { knowledgeCmd } from './commands/knowledge.js';
|
|
25
26
|
import { snippetsCmd } from './commands/snippets.js';
|
|
26
27
|
import { catalogCmd } from './commands/catalog.js';
|
|
27
28
|
import { incidentsCmd } from './commands/incidents.js';
|
|
@@ -38,6 +39,7 @@ import { transactionsCmd } from './commands/transactions.js';
|
|
|
38
39
|
import { platformCmd } from './commands/platform.js';
|
|
39
40
|
import { codesearchCmd } from './commands/codesearch.js';
|
|
40
41
|
import { versionCmd } from './commands/version.js';
|
|
42
|
+
import { upgradeCmd } from './commands/upgrade.js';
|
|
41
43
|
import { skillCmd, checkSkill } from './commands/skill.js';
|
|
42
44
|
import { getVersion, checkLatest, getReleaseChannel, getInstallCommand } from './commands/version.js';
|
|
43
45
|
import { docsCmd } from './commands/docs/docs.js';
|
|
@@ -72,6 +74,7 @@ import { logsCmd } from './commands/logs.js';
|
|
|
72
74
|
import { scrapiCmd } from './commands/scrapi.js';
|
|
73
75
|
import { b4rulesCmd } from './commands/b4rules.js';
|
|
74
76
|
import { atfCmd } from './commands/atf.js';
|
|
77
|
+
import { scanCmd } from './commands/scan.js';
|
|
75
78
|
import { approvalsCmd } from './commands/approvals.js';
|
|
76
79
|
import { historyCmd } from './commands/history.js';
|
|
77
80
|
import { perfCmd } from './commands/perf.js';
|
|
@@ -411,6 +414,7 @@ export function buildCLI() {
|
|
|
411
414
|
.command(propertiesCmd(wrap))
|
|
412
415
|
// DATA
|
|
413
416
|
.command(cmdbCmd(wrap))
|
|
417
|
+
.command(knowledgeCmd(wrap))
|
|
414
418
|
.command(recordsCmd(wrap))
|
|
415
419
|
.command(attachmentsCmd(wrap))
|
|
416
420
|
.command(transactionsCmd(wrap))
|
|
@@ -421,6 +425,7 @@ export function buildCLI() {
|
|
|
421
425
|
.command(codesearchCmd(wrap))
|
|
422
426
|
// DEVELOPER
|
|
423
427
|
.command(atfCmd(wrap))
|
|
428
|
+
.command(scanCmd(wrap))
|
|
424
429
|
.command(approvalsCmd(wrap))
|
|
425
430
|
.command(skippedUpdatesCmd(wrap))
|
|
426
431
|
.command(perfCmd(wrap))
|
|
@@ -434,6 +439,7 @@ export function buildCLI() {
|
|
|
434
439
|
.command(skillCmd(wrap))
|
|
435
440
|
.command(docsCmd(wrap))
|
|
436
441
|
.command(versionCmd(wrap))
|
|
442
|
+
.command(upgradeCmd(wrap))
|
|
437
443
|
.command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
|
|
438
444
|
.demandCommand(1, 'You must specify a command')
|
|
439
445
|
// Completion plumbing under a hidden command name, so the explicit
|
package/src/command-audience.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Explicit command paths and --all-commands remain available to every account.
|
|
3
3
|
const BASIC_COMMANDS = new Set([
|
|
4
4
|
'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
|
|
5
|
+
'knowledge',
|
|
5
6
|
'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
|
|
6
|
-
'
|
|
7
|
+
'cmdb',
|
|
8
|
+
'version', 'upgrade', 'docs', 'history', 'discover', 'completion',
|
|
9
|
+
'catalogitems',
|
|
7
10
|
]);
|
|
8
11
|
|
|
9
12
|
export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parseDataArg, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const ID = /^[a-f0-9]{32}$/i;
|
|
5
|
+
const API = '/api/sn_sc/servicecatalog/items';
|
|
6
|
+
const object = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
|
|
8
|
+
function emit(app, argv, data, summary, breadcrumbs = []) {
|
|
9
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
10
|
+
app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
11
|
+
}
|
|
12
|
+
function requireId(id) { if (!ID.test(id || '')) throw errUsage('Use the full catalog item sys_id; find it with catalogitems search.'); }
|
|
13
|
+
async function describe(app, id) {
|
|
14
|
+
requireId(id);
|
|
15
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}/${id}`, { method: 'GET' });
|
|
16
|
+
if (!object(response?.result) || response.result.sys_id !== id) throw errAPI(200, 'Catalog API returned no readable item with the requested identity.');
|
|
17
|
+
return response.result;
|
|
18
|
+
}
|
|
19
|
+
function itemHints(id) {
|
|
20
|
+
return [{ action: 'describe', cmd: `jsn catalogitems describe ${id}`, description: 'Inspect catalog inputs and pricing' },
|
|
21
|
+
{ action: 'preview', cmd: `jsn catalogitems order ${id} --dry-run --variables-file variables.json`, description: 'Preview an order without submitting' }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function catalogOrderCommands(wrap) {
|
|
25
|
+
return [
|
|
26
|
+
{ command: 'search <text>', describe: 'Find requestable items through the Service Catalog API',
|
|
27
|
+
builder: y => y.positional('text', { type: 'string' }).option('limit', { type: 'number', default: 20, describe: 'Maximum items (1–100)' }),
|
|
28
|
+
handler: wrap(async (argv, app) => {
|
|
29
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty catalog search text.');
|
|
30
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
31
|
+
const params = new URLSearchParams({ sysparm_text: argv.text.trim(), sysparm_limit: String(argv.limit) });
|
|
32
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}?${params}`, { method: 'GET' });
|
|
33
|
+
if (!Array.isArray(response?.result) || response.result.some(i => !object(i) || !ID.test(i.sys_id || ''))) throw errAPI(200, 'Catalog API returned no valid item list.');
|
|
34
|
+
emit(app, argv, { items: response.result, count: response.result.length, limit: argv.limit,
|
|
35
|
+
possibly_truncated: response.result.length === argv.limit }, `${response.result.length} catalog item(s)`,
|
|
36
|
+
response.result.flatMap(i => itemHints(i.sys_id).slice(0, 1)));
|
|
37
|
+
}) },
|
|
38
|
+
{ command: 'describe <id>', aliases: ['inputs'], describe: 'Read requestable item details, pricing and variable definitions',
|
|
39
|
+
builder: y => y.positional('id', { type: 'string' }),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
41
|
+
const item = await describe(app, argv.id);
|
|
42
|
+
emit(app, argv, item, item.name || item.short_description || argv.id, itemHints(argv.id));
|
|
43
|
+
}) },
|
|
44
|
+
{ command: 'order <id>', describe: 'Submit a catalog order with explicit confirmation',
|
|
45
|
+
builder: y => y.positional('id', { type: 'string' })
|
|
46
|
+
.option('variables', { type: 'string', describe: 'JSON object of catalog variable names/values' })
|
|
47
|
+
.option('variables-file', { type: 'string', describe: 'Read the variables object from a JSON file' })
|
|
48
|
+
.option('quantity', { type: 'number', default: 1, describe: 'Positive integer quantity (max 1000)' })
|
|
49
|
+
.option('requested-for', { type: 'string', describe: 'User sys_id; instance delegation rules still apply' })
|
|
50
|
+
.option('dry-run', { type: 'boolean', default: false, describe: 'Read item details and preview variable names; no order is submitted' })
|
|
51
|
+
.epilogue('Use describe to inspect variables first. Server-side rules validate the order; a preview does not prove submission will succeed. Existing create/list/show commands manage definitions. Orders require a write-enabled profile, including dry runs. No application/update-set selection is needed.'),
|
|
52
|
+
handler: wrap(async (argv, app) => {
|
|
53
|
+
requireId(argv.id);
|
|
54
|
+
if (!Number.isInteger(argv.quantity) || argv.quantity < 1 || argv.quantity > 1000) throw errUsage('--quantity must be an integer from 1 to 1000.');
|
|
55
|
+
const requestedFor = argv.requestedFor ?? argv['requested-for'];
|
|
56
|
+
if (requestedFor !== undefined && !ID.test(requestedFor)) throw errUsage('--requested-for requires a user sys_id.');
|
|
57
|
+
const file = argv.variablesFile ?? argv['variables-file'];
|
|
58
|
+
if (argv.variables !== undefined && file !== undefined) throw errUsage('Choose --variables or --variables-file, not both.');
|
|
59
|
+
const variables = argv.variables !== undefined || file !== undefined
|
|
60
|
+
? parseDataArg({ data: argv.variables, 'data-file': file }) : {};
|
|
61
|
+
if (!object(variables)) throw errUsage('Catalog variables must be a JSON object.');
|
|
62
|
+
const item = await describe(app, argv.id);
|
|
63
|
+
if (item.sys_class_name && item.sys_class_name !== 'sc_cat_item') throw errUsage('order supports standard catalog items; record producers and order guides need their own submission workflows.');
|
|
64
|
+
if (item.active === false || item.active === 'false' || item.can_order === false) throw errUsage('This item is not currently orderable.');
|
|
65
|
+
const plan = { item_id: argv.id, name: item.name || item.short_description, quantity: argv.quantity,
|
|
66
|
+
requested_for: requestedFor || null, variable_names: Object.keys(variables) };
|
|
67
|
+
if (argv.dryRun ?? argv['dry-run']) return emit(app, argv, { ...plan, submitted: false, server_validation: 'not performed' }, 'Catalog order preview; nothing submitted', itemHints(argv.id));
|
|
68
|
+
const rerun = ['catalogitems', 'order', argv.id, '--quantity', String(argv.quantity),
|
|
69
|
+
...(requestedFor ? ['--requested-for', requestedFor] : []),
|
|
70
|
+
...(file ? ['--variables-file', file] : Object.keys(variables).length ? ['--variables-file', '<variables.json>'] : [])];
|
|
71
|
+
try {
|
|
72
|
+
await confirmDelete(app, argv, `Order ${argv.quantity} × ${plan.name || argv.id}${requestedFor ? ` for ${requestedFor}` : ''}?`, { rerun });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.message === 'Deletion cancelled') throw errUsage('Catalog order cancelled.');
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const body = { sysparm_quantity: argv.quantity, variables, ...(requestedFor ? { sysparm_requested_for: requestedFor } : {}) };
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await app.sdk.request(`${app.sdk.baseURL}${API}/${argv.id}/order_now`, { method: 'POST', body: JSON.stringify(body) });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.status >= 400 && error.status < 500 && error.status !== 408) throw errAPI(error.status, 'Catalog order was rejected. Check item access, required variables and requested-for eligibility.');
|
|
83
|
+
throw errAPI(error.status || 0, 'Catalog submission did not return a confirmed order. Inspect your requests before retrying; the order may have been placed.');
|
|
84
|
+
}
|
|
85
|
+
const result = response?.result;
|
|
86
|
+
const requestId = result?.request_id || result?.sys_id, number = result?.request_number || result?.number;
|
|
87
|
+
if (!object(result) || result.error || !ID.test(requestId || '') || typeof number !== 'string' || !number.trim()) throw errAPI(200, 'Catalog response did not confirm an order identity. Inspect your requests before retrying; do not resubmit automatically.');
|
|
88
|
+
emit(app, argv, { item_id: argv.id, quantity: argv.quantity, request_id: requestId, request_number: number, submitted: true },
|
|
89
|
+
`Ordered: ${number}`, [{ action: 'request', cmd: `jsn records get --table sc_request --sys-id ${requestId}`, description: 'Read the request header' },
|
|
90
|
+
{ action: 'items', cmd: `jsn requests list --query ${quoteShellArg(`request=${requestId}`)}`, description: 'Track the requested items' }]);
|
|
91
|
+
}) },
|
|
92
|
+
];
|
|
93
|
+
}
|
package/src/commands/catalog.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { getStringField, interactiveList, resolveItemOptionType } from '../helpers.js';
|
|
1
|
+
import { getStringField, interactiveList, resolveItemOptionType, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { catalogOrderCommands } from './catalog-order.js';
|
|
2
3
|
import { resolveSysId } from '../resolve-record.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
import { enrichCatalogItem, buildCatalogFormatted } from './catalog/enrich.js';
|
|
5
6
|
|
|
6
|
-
declareCapabilities('catalogitems', { mutationSubcommands: ['create'] });
|
|
7
|
+
declareCapabilities('catalogitems', { mutationSubcommands: ['create', 'order'], mutationContextExemptSubcommands: ['order'], localPreviewSubcommands: ['order'] });
|
|
7
8
|
|
|
8
9
|
export function catalogCmd(wrap) {
|
|
9
10
|
return {
|
|
@@ -11,6 +12,7 @@ export function catalogCmd(wrap) {
|
|
|
11
12
|
aliases: ['ci'],
|
|
12
13
|
describe: 'Manage Service Catalog items',
|
|
13
14
|
builder: (yargs) => {
|
|
15
|
+
for (const command of catalogOrderCommands(wrap)) yargs.command(command);
|
|
14
16
|
return yargs
|
|
15
17
|
.command({
|
|
16
18
|
command: 'create',
|
|
@@ -148,14 +150,10 @@ export function catalogCmd(wrap) {
|
|
|
148
150
|
}),
|
|
149
151
|
});
|
|
150
152
|
},
|
|
151
|
-
handler: () => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(' create Create a catalog item');
|
|
156
|
-
console.log(' list Browse catalog items');
|
|
157
|
-
console.log(' show <id> Show catalog item');
|
|
158
|
-
},
|
|
153
|
+
handler: wrap(async (argv, app) => app.ok({ subcommands: argv.subcommands || [],
|
|
154
|
+
_formatted: formatCommandList({ title: 'Service Catalog', commands: argv.subcommands || [] }) },
|
|
155
|
+
{ summary: 'Service Catalog: search, describe and order items; manage definitions with create/list/show',
|
|
156
|
+
breadcrumbs: [{ action: 'search', cmd: 'jsn catalogitems search <text>' + (argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : ''), description: 'Find requestable items' }] })),
|
|
159
157
|
};
|
|
160
158
|
}
|
|
161
159
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { assertSafeExactMatch, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const FIELDS = ['name', 'fqdn', 'ip_address', 'serial_number'];
|
|
5
|
+
const raw = v => v && typeof v === 'object' ? v.value : v;
|
|
6
|
+
|
|
7
|
+
export function cmdbSearchCmd(wrap) {
|
|
8
|
+
return { command: 'search <text>', describe: 'Find CIs by name, FQDN, IP address, or serial number',
|
|
9
|
+
builder: y => y.positional('text', { type: 'string', describe: 'Literal text; quote multiple words' })
|
|
10
|
+
.option('class', { type: 'string', describe: 'Exact CI class name (not descendants)' })
|
|
11
|
+
.option('limit', { type: 'number', default: 20, describe: 'Maximum CIs (1–100)' })
|
|
12
|
+
.option('offset', { type: 'number', default: 0, describe: 'Starting result offset' }),
|
|
13
|
+
handler: wrap(async (argv, app) => {
|
|
14
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty CI search text.');
|
|
15
|
+
const text = argv.text.trim();
|
|
16
|
+
assertSafeExactMatch(text);
|
|
17
|
+
if ([...text].some(c => c.codePointAt(0) < 32 || c.codePointAt(0) === 127) || /javascript:/i.test(text)) throw errUsage('Search text must not contain query scripts or control characters.');
|
|
18
|
+
if (argv.class !== undefined && !/^[a-z][a-z0-9_]*$/.test(argv.class)) throw errUsage('--class must be an exact CI table name.');
|
|
19
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
20
|
+
if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
21
|
+
// Repeat the class restriction in each OR branch: no branch may escape it.
|
|
22
|
+
const query = FIELDS.map(f => `${f}LIKE${text}${argv.class ? `^sys_class_name=${argv.class}` : ''}`).join('^NQ') + '^ORDERBYname^ORDERBYsys_id';
|
|
23
|
+
const params = new URLSearchParams({ sysparm_query: query, sysparm_limit: String(argv.limit + 1), sysparm_offset: String(argv.offset),
|
|
24
|
+
sysparm_fields: 'sys_id,name,sys_class_name,fqdn,ip_address,serial_number', sysparm_display_value: 'all' });
|
|
25
|
+
const rows = await app.sdk.listStrict('cmdb_ci', params);
|
|
26
|
+
if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw errAPI(200, 'CMDB search returned no valid record list.');
|
|
27
|
+
const hasMore = rows.length > argv.limit, records = rows.slice(0, argv.limit);
|
|
28
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
29
|
+
const breadcrumbs = records.flatMap(r => /^[a-f0-9]{32}$/i.test(raw(r.sys_id) || '') ? [
|
|
30
|
+
{ action: 'show', cmd: `jsn cmdb show ${raw(r.sys_id)}`, description: String(raw(r.name) || 'Inspect CI') },
|
|
31
|
+
{ action: 'relationships', cmd: `jsn cmdb relationships --ci ${raw(r.sys_id)}`, description: 'Inspect CI dependencies' },
|
|
32
|
+
] : []);
|
|
33
|
+
if (hasMore) breadcrumbs.push({ action: 'next', cmd: `jsn cmdb search ${quoteShellArg(text)} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${argv.class ? ` --class ${argv.class}` : ''}`, description: 'Next matching CIs' });
|
|
34
|
+
app.ok({ table: 'cmdb_ci', search: text, fields: FIELDS, records, count: records.length,
|
|
35
|
+
pagination: { limit: argv.limit, offset: argv.offset, has_more: hasMore } },
|
|
36
|
+
{ summary: `${records.length} matching CI(s)`, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
37
|
+
}) };
|
|
38
|
+
}
|
package/src/commands/cmdb.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveFieldsParam,
|
|
16
16
|
} from '../helpers.js';
|
|
17
17
|
import { errNotFound, errUsage } from '../errors.js';
|
|
18
|
+
import { cmdbSearchCmd } from './cmdb-search.js';
|
|
18
19
|
|
|
19
20
|
const SYS_ID_RE = /^[0-9a-f]{32}$/i;
|
|
20
21
|
const MAX_DEPTH = 5;
|
|
@@ -371,6 +372,7 @@ export function cmdbCmd(wrap) {
|
|
|
371
372
|
}),
|
|
372
373
|
builder: (yargs) => {
|
|
373
374
|
return yargs
|
|
375
|
+
.command(cmdbSearchCmd(wrap))
|
|
374
376
|
.command({
|
|
375
377
|
command: 'list',
|
|
376
378
|
describe: 'List CIs from the CMDB (cmdb_ci)',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import TurndownService from 'turndown';
|
|
2
|
+
import { formatCommandList, quoteShellArg } from '../helpers.js';
|
|
3
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
const API = '/api/sn_km_api/knowledge/articles';
|
|
6
|
+
const markdown = new TurndownService();
|
|
7
|
+
const sysId = /^[a-f0-9]{32}$/i;
|
|
8
|
+
const articleId = value => typeof value === 'string' && (sysId.test(value) || /^KB\d+$/i.test(value));
|
|
9
|
+
|
|
10
|
+
function emit(app, argv, data, summary, breadcrumbs = []) {
|
|
11
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
|
|
12
|
+
: argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
13
|
+
app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function read(sdk, path, params) {
|
|
17
|
+
const response = await sdk.request(`${sdk.baseURL}${API}${path}?${params}`, { method: 'GET' });
|
|
18
|
+
const result = response?.result;
|
|
19
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) throw errAPI(200, 'Malformed Knowledge API response.');
|
|
20
|
+
const code = result.meta?.status?.code;
|
|
21
|
+
if (code !== undefined && Number(code) !== 200) throw errAPI(Number(code) || 500, 'Knowledge API rejected the request.');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function knowledgeCmd(wrap) {
|
|
26
|
+
return {
|
|
27
|
+
command: 'knowledge [subcommand]', aliases: ['kb'],
|
|
28
|
+
describe: 'Search and read knowledge articles through the Knowledge API',
|
|
29
|
+
handler: wrap(async (argv, app) => emit(app, argv,
|
|
30
|
+
{ subcommands: argv.subcommands || [], _formatted: formatCommandList({ title: 'Knowledge articles', commands: argv.subcommands || [] }) },
|
|
31
|
+
'Search and read knowledge articles', [{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find accessible articles' }])),
|
|
32
|
+
builder: y => y
|
|
33
|
+
.epilogue('Requires the Knowledge API (sn_km_api). Uses its access checks; no fallback to direct knowledge-table reads. Reading does not request view-count updates.')
|
|
34
|
+
.command({ command: 'search <text>', describe: 'Search accessible knowledge articles',
|
|
35
|
+
builder: p => p.positional('text', { type: 'string', describe: 'Search text; quote multiple words' })
|
|
36
|
+
.option('limit', { type: 'number', default: 20, describe: 'Maximum articles (1–100)' })
|
|
37
|
+
.option('offset', { type: 'number', default: 0, describe: 'Starting result offset' })
|
|
38
|
+
.option('kb', { type: 'string', describe: 'Comma-separated knowledge-base sys_ids' })
|
|
39
|
+
.option('language', { type: 'string', describe: 'Two-letter language codes, comma-separated, or all' }),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
41
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty search text.');
|
|
42
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
43
|
+
if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
44
|
+
if (argv.kb !== undefined && !String(argv.kb).split(',').every(id => sysId.test(id))) throw errUsage('--kb requires knowledge-base sys_ids.');
|
|
45
|
+
if (argv.language !== undefined && !/^(all|[a-z]{2}(,[a-z]{2})*)$/i.test(argv.language)) throw errUsage('Use two-letter --language codes or all.');
|
|
46
|
+
const params = new URLSearchParams({ query: argv.text.trim(), limit: String(argv.limit), offset: String(argv.offset) });
|
|
47
|
+
if (argv.kb) params.set('kb', argv.kb);
|
|
48
|
+
if (argv.language) params.set('language', argv.language);
|
|
49
|
+
const result = await read(app.sdk, '', params);
|
|
50
|
+
if (!Array.isArray(result.articles) || result.articles.some(a => !a || typeof a !== 'object' || Array.isArray(a))) throw errAPI(200, 'Knowledge API returned no valid article list.');
|
|
51
|
+
const breadcrumbs = result.articles.flatMap(a => {
|
|
52
|
+
const id = articleId(a.number) ? a.number : String(a.id || '').replace(/^kb_knowledge:/, '');
|
|
53
|
+
return articleId(id) ? [{ action: 'show', cmd: `jsn knowledge show ${id}`, description: a.title || id }] : [];
|
|
54
|
+
});
|
|
55
|
+
const base = `jsn knowledge search ${quoteShellArg(argv.text.trim())} --limit ${argv.limit}`
|
|
56
|
+
+ (argv.kb ? ` --kb ${quoteShellArg(argv.kb)}` : '') + (argv.language ? ` --language ${quoteShellArg(argv.language)}` : '');
|
|
57
|
+
const total = result.meta?.count;
|
|
58
|
+
const hasMore = typeof total === 'number' ? argv.offset + result.articles.length < total : result.articles.length === argv.limit;
|
|
59
|
+
if (hasMore) breadcrumbs.push({ action: 'next', cmd: `${base} --offset ${argv.offset + argv.limit}`, description: 'Next result page' });
|
|
60
|
+
emit(app, argv, { ...result, count: result.articles.length,
|
|
61
|
+
_formatted: result.articles.map(a => `${a.number || a.id || ''} ${a.title || ''}\n${a.snippet || ''}`).join('\n\n') || 'No accessible articles matched.' },
|
|
62
|
+
`${result.articles.length} knowledge article(s)`, breadcrumbs);
|
|
63
|
+
}) })
|
|
64
|
+
.command({ command: 'show <id>', describe: 'Read an article by KB number or sys_id',
|
|
65
|
+
builder: p => p.positional('id', { type: 'string' }),
|
|
66
|
+
handler: wrap(async (argv, app) => {
|
|
67
|
+
if (!articleId(argv.id)) throw errUsage('Use a KB number or full article sys_id.');
|
|
68
|
+
const article = await read(app.sdk, `/${encodeURIComponent(argv.id)}`, new URLSearchParams());
|
|
69
|
+
if (!sysId.test(article.sys_id || '') || typeof article.content !== 'string') throw errAPI(200, 'Knowledge API returned no readable article content.');
|
|
70
|
+
emit(app, argv, { ...article, _formatted: `${article.number || argv.id}: ${article.short_description || ''}\n\n${markdown.turndown(article.content)}` },
|
|
71
|
+
article.short_description || article.number || argv.id,
|
|
72
|
+
[{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find more articles' }]);
|
|
73
|
+
}) }),
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/commands/records.js
CHANGED
|
@@ -101,6 +101,9 @@ export function recordsCmd(wrap) {
|
|
|
101
101
|
'and press Enter to select or Esc to cancel. --sys-id skips the picker.',
|
|
102
102
|
'Use --json for noninteractive structured output; pipes and explicit formats also skip the picker.',
|
|
103
103
|
'Example: jsn records list --table incident --query "active=true" --limit 5 --json',
|
|
104
|
+
'JSON shape (abbreviated): {"ok":true,"data":{"records":[{"sys_id":"...","number":"INC0010001"}]},...}',
|
|
105
|
+
'Records are at data.records; fields are directly on each record (e.g. record.number).',
|
|
106
|
+
'Array only: jsn records list --table incident --get data.records',
|
|
104
107
|
'Discover fields with: jsn columns --table incident',
|
|
105
108
|
].join('\n')),
|
|
106
109
|
handler: wrap(async (argv, app) => {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { buildRecordPayload, confirmDelete, getStringField, quoteShellArg, verifyWriteBack } from '../helpers.js';
|
|
2
|
+
import { createRecord, updateRecord, deleteRecord } from '../records-engine.js';
|
|
3
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
4
|
+
import { getCurrentApplication, getCurrentUser } from '../context.js';
|
|
5
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
6
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
7
|
+
import { CHECK_TABLES, active, raw, referenceId, boundedLimit, scanList, resolveScanRecord,
|
|
8
|
+
validateScanPayload, checkKind, prepareScan, startScan, scanStatus, scanResults, cancelScan } from '../scan.js';
|
|
9
|
+
|
|
10
|
+
declareCapabilities('scan', { mutationSubcommands: [
|
|
11
|
+
...['checks', 'suites'].flatMap(group => ['create', 'update', 'delete', 'activate', 'deactivate'].map(verb => `${group} ${verb}`)),
|
|
12
|
+
'suites add-check', 'suites remove-check', 'run', 'cancel',
|
|
13
|
+
] });
|
|
14
|
+
|
|
15
|
+
const hint = (action, cmd, description) => ({ action, cmd, description });
|
|
16
|
+
function report(app, argv, data, options) {
|
|
17
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
|
|
18
|
+
: argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
19
|
+
app.ok(data, { ...options, breadcrumbs: options.breadcrumbs.map(item => ({ ...item, cmd: item.cmd + connection })) });
|
|
20
|
+
}
|
|
21
|
+
const rootHints = () => [hint('checks', 'jsn scan checks list', 'Browse available checks'),
|
|
22
|
+
hint('suites', 'jsn scan suites list', 'Browse suites'), hint('run', 'jsn scan run --help', 'Choose an explicit scan target')];
|
|
23
|
+
const itemHints = (group, record) => {
|
|
24
|
+
const id = unwrapSysId(record);
|
|
25
|
+
return [hint('list', `jsn scan ${group} list`, `Browse ${group}`),
|
|
26
|
+
hint('show', `jsn scan ${group} show ${id}`, 'Inspect this definition'),
|
|
27
|
+
hint('update', `jsn scan ${group} update ${id} --data-file ${group === 'checks' ? 'check' : 'suite'}.json`, 'Update selected fields'),
|
|
28
|
+
hint(active(record) ? 'deactivate' : 'activate', `jsn scan ${group} ${active(record) ? 'deactivate' : 'activate'} ${id}`, 'Change eligibility without starting a scan'),
|
|
29
|
+
...(group === 'suites' ? [hint('add-check', `jsn scan suites add-check ${id} --check '<check-id-or-name>'`, 'Add an existing check'),
|
|
30
|
+
hint('run', `jsn scan run --suite ${id} --update-set '<update-set>'`, 'Run against an explicit update set')] :
|
|
31
|
+
[hint('suites', 'jsn scan suites list', 'Choose a suite for this check')]),
|
|
32
|
+
hint('delete', `jsn scan ${group} delete ${id}`, 'Delete this definition after confirmation')];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function executionHints(data) {
|
|
36
|
+
const id = data.execution_id;
|
|
37
|
+
return [hint('status', `jsn scan status ${id}`, 'Read execution progress'),
|
|
38
|
+
hint('results', `jsn scan results ${id}`, 'Inspect findings and recorded check executions'),
|
|
39
|
+
...(!data.terminal ? [hint('cancel', `jsn scan cancel ${id}`, 'Request cancellation')] : []),
|
|
40
|
+
...(data.reusable_target ? [hint('rerun', `jsn scan run --previous ${id}${data.rerun_full ? ' --full' : ''}`, 'Rerun the previous target with current check definitions')] : []),
|
|
41
|
+
hint('checks', 'jsn scan checks list', 'Review available checks')];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payloadOptions = y => y.option('data', { type: 'string', describe: 'JSON fields' })
|
|
45
|
+
.option('data-file', { type: 'string', describe: 'Read JSON fields from a file (recommended for scripts)' })
|
|
46
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON fields from stdin' })
|
|
47
|
+
.option('field', { alias: 'f', type: 'array', describe: 'Set field=value; repeatable' });
|
|
48
|
+
const lookupOptions = y => y.positional('identifier', { type: 'string', describe: 'Unique name or sys_id' });
|
|
49
|
+
|
|
50
|
+
async function guardScope(app, record) {
|
|
51
|
+
const scopeId = referenceId(record.sys_scope) || raw(record.sys_scope);
|
|
52
|
+
if (!scopeId) throw errUsage('The definition scope is unreadable; inspect permissions before changing it.');
|
|
53
|
+
const user = await getCurrentUser(app.sdk);
|
|
54
|
+
if (!user) throw errUsage('Could not verify the current scope.');
|
|
55
|
+
const scope = await getCurrentApplication(app.sdk, user.sys_id, { unknownWhenMissing: true });
|
|
56
|
+
if (scope.appSysId !== scopeId && scope.scope !== scopeId) {
|
|
57
|
+
throw errUsage(`Select the definition scope before changing it: jsn scopes set ${quoteShellArg(scopeId)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function verify(app, table, id, data) {
|
|
62
|
+
if (!id) throw errAPI(200, 'The write returned no sys_id. Inspect the instance before retrying.');
|
|
63
|
+
const mismatches = await verifyWriteBack(app, table, id, data);
|
|
64
|
+
if (mismatches.length) throw errAPI(200, `Write-back verification failed for ${table}/${id}: ${mismatches.map(item => item.field).join(', ')}. The write may have succeeded partially; inspect before retrying.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function definitions(group, wrap) {
|
|
68
|
+
const suite = group === 'suites';
|
|
69
|
+
const baseTable = suite ? 'scan_check_suite' : 'scan_check';
|
|
70
|
+
const emit = (app, argv, record, summary) => report(app, argv, record, { summary, breadcrumbs: itemHints(group, record) });
|
|
71
|
+
return {
|
|
72
|
+
command: `${group} [subcommand]`,
|
|
73
|
+
describe: suite ? 'Manage scan suites and their check memberships' : 'Manage typed Instance Scan check definitions',
|
|
74
|
+
builder: y => {
|
|
75
|
+
y.command({ command: 'list', describe: `List ${group}`,
|
|
76
|
+
builder: p => {
|
|
77
|
+
p.option('query', { type: 'string', describe: 'Encoded query' })
|
|
78
|
+
.option('limit', { type: 'number', default: 50, describe: 'Maximum records (1–200)' })
|
|
79
|
+
.option('offset', { type: 'number', default: 0, describe: 'Pagination offset' });
|
|
80
|
+
return suite ? p : p.option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', describe: 'Filter by check type' });
|
|
81
|
+
}, handler: wrap(async (argv, app) => {
|
|
82
|
+
const table = suite ? baseTable : (argv.type ? CHECK_TABLES[argv.type] : baseTable);
|
|
83
|
+
const records = await scanList(app.sdk, table, { limit: argv.limit, offset: argv.offset, query: argv.query,
|
|
84
|
+
fields: ['sys_id', 'name', 'active', ...(suite ? ['description'] : ['sys_class_name', 'short_description'])] });
|
|
85
|
+
report(app, argv, { table, records, count: records.length, limit: argv.limit, offset: argv.offset,
|
|
86
|
+
more_available: records.length === argv.limit ? null : false }, {
|
|
87
|
+
summary: `${records.length} scan ${group}`, breadcrumbs: [
|
|
88
|
+
...records.slice(0, 5).map(record => hint('show', `jsn scan ${group} show ${unwrapSysId(record)}`, `Inspect ${getStringField(record, 'name')}`)),
|
|
89
|
+
hint('create', `jsn scan ${group} create${suite ? '' : ' --type table'} --data-file ${suite ? 'suite' : 'check'}.json`, 'Create an inactive definition'),
|
|
90
|
+
...(records.length === argv.limit ? [hint('next', `jsn scan ${group} list --offset ${argv.offset + argv.limit} --limit ${argv.limit}${argv.type ? ` --type ${argv.type}` : ''}${argv.query ? ` --query ${quoteShellArg(argv.query)}` : ''}`, 'Next page')] : []),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
}) });
|
|
94
|
+
y.command({ command: 'show <identifier>', describe: `Inspect a ${suite ? 'suite and its members' : 'check'}`, builder: lookupOptions,
|
|
95
|
+
handler: wrap(async (argv, app) => {
|
|
96
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
97
|
+
if (suite) {
|
|
98
|
+
const members = await scanList(app.sdk, 'scan_check_suite_check', { limit: 200, query: `suite=${unwrapSysId(record)}^ORDERBYsys_id`, fields: ['sys_id', 'check'] });
|
|
99
|
+
record.members = members;
|
|
100
|
+
record.members_may_be_truncated = members.length === 200;
|
|
101
|
+
}
|
|
102
|
+
emit(app, argv, record, `Scan ${suite ? 'suite' : 'check'}: ${getStringField(record, 'name')}`);
|
|
103
|
+
}) });
|
|
104
|
+
y.command({ command: 'create', describe: `Create an inactive ${suite ? 'suite' : 'typed check'} unless active is explicitly supplied`,
|
|
105
|
+
builder: p => suite ? payloadOptions(p) : payloadOptions(p).option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', demandOption: true, describe: 'Check type' }),
|
|
106
|
+
handler: wrap(async (argv, app) => {
|
|
107
|
+
const data = validateScanPayload(argv.type, buildRecordPayload(argv), { create: true, suite });
|
|
108
|
+
const table = suite ? baseTable : CHECK_TABLES[argv.type];
|
|
109
|
+
const record = await createRecord(app.sdk, { table, data });
|
|
110
|
+
await verify(app, table, unwrapSysId(record), data);
|
|
111
|
+
emit(app, argv, record, `Created scan ${suite ? 'suite' : 'check'}`);
|
|
112
|
+
}) });
|
|
113
|
+
for (const operation of ['update', 'activate', 'deactivate']) y.command({
|
|
114
|
+
command: `${operation} <identifier>`, describe: `${operation} a scan ${suite ? 'suite' : 'check'} without running it`,
|
|
115
|
+
builder: p => operation === 'update' ? payloadOptions(lookupOptions(p)) : lookupOptions(p),
|
|
116
|
+
handler: wrap(async (argv, app) => {
|
|
117
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
118
|
+
const kind = suite ? null : checkKind(record);
|
|
119
|
+
const patch = operation === 'update' ? validateScanPayload(kind, buildRecordPayload(argv), { suite, current: record }) : { active: operation === 'activate' };
|
|
120
|
+
if (!suite && operation === 'activate') validateScanPayload(kind, {
|
|
121
|
+
name: raw(record.name), script: raw(record.script), ...(kind === 'table' ? { table: raw(record.table), advanced: raw(record.advanced) } : {}),
|
|
122
|
+
...(kind === 'column' ? { column_type: raw(record.column_type) } : {}),
|
|
123
|
+
}, { create: true });
|
|
124
|
+
await guardScope(app, record);
|
|
125
|
+
const table = suite ? baseTable : CHECK_TABLES[kind];
|
|
126
|
+
const updated = await updateRecord(app.sdk, { table, record, data: patch });
|
|
127
|
+
await verify(app, table, unwrapSysId(record), patch);
|
|
128
|
+
emit(app, argv, updated, `Updated scan ${suite ? 'suite' : 'check'}`);
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
y.command({ command: 'delete <identifier>', describe: `Delete a ${suite ? 'suite' : 'check'} definition after confirmation`, builder: lookupOptions,
|
|
132
|
+
handler: wrap(async (argv, app) => {
|
|
133
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
134
|
+
await guardScope(app, record);
|
|
135
|
+
await confirmDelete(app, argv, `Delete scan ${suite ? 'suite' : 'check'} "${getStringField(record, 'name')}"?`);
|
|
136
|
+
const id = await deleteRecord(app.sdk, { table: suite ? baseTable : CHECK_TABLES[checkKind(record)], record });
|
|
137
|
+
report(app, argv, { sys_id: id, deleted: true }, { summary: 'Deleted scan definition', breadcrumbs: [hint('list', `jsn scan ${group} list`, 'Browse remaining definitions')] });
|
|
138
|
+
}) });
|
|
139
|
+
if (suite) for (const operation of ['add-check', 'remove-check']) y.command({
|
|
140
|
+
command: `${operation} <identifier>`, describe: `${operation === 'add-check' ? 'Add a check to' : 'Remove a check from'} a suite`,
|
|
141
|
+
builder: p => lookupOptions(p).option('check', { type: 'string', demandOption: true, describe: 'Check name or sys_id' }),
|
|
142
|
+
handler: wrap(async (argv, app) => {
|
|
143
|
+
const suiteRecord = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
144
|
+
const check = await resolveScanRecord(app.sdk, 'scan_check', argv.check);
|
|
145
|
+
await guardScope(app, suiteRecord);
|
|
146
|
+
const data = { suite: unwrapSysId(suiteRecord), check: unwrapSysId(check) };
|
|
147
|
+
const table = 'scan_check_suite_check';
|
|
148
|
+
const members = await scanList(app.sdk, table, { query: `suite=${data.suite}^check=${data.check}`, limit: 2 });
|
|
149
|
+
if (members.length > 1) throw errUsage('Duplicate suite memberships exist; inspect and reconcile them before changing membership.');
|
|
150
|
+
let changed = false;
|
|
151
|
+
if (operation === 'add-check' && !members.length) {
|
|
152
|
+
const member = await createRecord(app.sdk, { table, data });
|
|
153
|
+
await verify(app, table, unwrapSysId(member), data);
|
|
154
|
+
changed = true;
|
|
155
|
+
} else if (operation === 'remove-check' && members.length) {
|
|
156
|
+
await confirmDelete(app, argv, 'Remove this check from the suite? The check definition will be retained.');
|
|
157
|
+
await deleteRecord(app.sdk, { table, record: members[0] });
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
report(app, argv, { ...data, changed, member: operation === 'add-check' }, { summary: 'Suite membership reconciled', breadcrumbs: itemHints(group, suiteRecord) });
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
return y;
|
|
164
|
+
},
|
|
165
|
+
handler: wrap(async (argv, app) => report(app, argv, { commands: ['list', 'show', 'create', 'update', 'delete', 'activate', 'deactivate', ...(suite ? ['add-check', 'remove-check'] : [])] }, {
|
|
166
|
+
summary: `Scan ${group}`, breadcrumbs: [hint('list', `jsn scan ${group} list`, `Browse ${group}`)],
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function scanCmd(wrap) {
|
|
172
|
+
return {
|
|
173
|
+
command: 'scan [subcommand]', describe: 'Instance Scan checks, suites, explicit targets, progress and findings',
|
|
174
|
+
builder: y => y.command(definitions('checks', wrap)).command(definitions('suites', wrap))
|
|
175
|
+
.command({ command: 'run', describe: 'Start an explicit target scan (confirmation required; no implicit full scan)',
|
|
176
|
+
builder: p => p.option('update-set', { type: 'string', describe: 'Update set name or sys_id; scans current records' })
|
|
177
|
+
.option('scope', { type: 'string', describe: 'Application scope value or sys_id' })
|
|
178
|
+
.option('record', { type: 'string', describe: 'Record sys_id; requires --table' })
|
|
179
|
+
.option('table', { type: 'string', describe: 'Table for --record' })
|
|
180
|
+
.option('full', { type: 'boolean', describe: 'Explicitly allow an instance-wide scan' })
|
|
181
|
+
.option('previous', { type: 'string', describe: 'Rerun a previous execution target; full scans also require --full' })
|
|
182
|
+
.option('suite', { type: 'string', describe: 'Optional active suite name or sys_id' })
|
|
183
|
+
.option('wait', { type: 'boolean', default: false, describe: 'Wait for completion; timeout does not cancel the scan' })
|
|
184
|
+
.option('timeout', { type: 'number', default: 120, describe: 'Maximum wait in seconds (1–3600)' }),
|
|
185
|
+
handler: wrap(async (argv, app) => {
|
|
186
|
+
if (!Number.isFinite(argv.timeout) || argv.timeout < 1 || argv.timeout > 3600) throw errUsage('--timeout must be from 1 to 3600 seconds.');
|
|
187
|
+
const plan = await prepareScan(app.sdk, argv);
|
|
188
|
+
await confirmDelete(app, argv, `Start scan of ${plan.target.kind} ${plan.target.name || plan.target.sys_id || plan.target.execution_id || 'the full instance'}${plan.suite_id ? ` using suite ${plan.suite_id}` : ''}?`);
|
|
189
|
+
const scheduled = await startScan(app.sdk, plan);
|
|
190
|
+
let data = { ...scheduled, status: 'scheduled', terminal: false };
|
|
191
|
+
if (argv.wait) {
|
|
192
|
+
try {
|
|
193
|
+
const deadline = Date.now() + argv.timeout * 1000;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
try {
|
|
196
|
+
data = { ...scheduled, ...await scanStatus(app.sdk, scheduled.execution_id, { allowInitializing: true }) };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code !== 'not_found' && error.status !== 404) throw error;
|
|
199
|
+
// The processor can return before its execution tracker is visible.
|
|
200
|
+
data = { ...scheduled, status: 'scheduled', terminal: false };
|
|
201
|
+
}
|
|
202
|
+
if (data.terminal) { data = { ...scheduled, ...await scanResults(app.sdk, scheduled.execution_id) }; break; }
|
|
203
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
|
|
204
|
+
}
|
|
205
|
+
if (!data.terminal) data.wait_timed_out = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw errAPI(error.status || 0, `Scan ${scheduled.execution_id} was scheduled, but progress could not be read: ${error.message}. Use jsn scan status ${scheduled.execution_id}; do not resubmit automatically.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const breadcrumbs = executionHints(data);
|
|
211
|
+
if (data.terminal && !data.reusable_target) breadcrumbs.push(hint('rerun', 'jsn scan run --help', 'Choose a target for another scan'));
|
|
212
|
+
report(app, argv, data, { summary: `Scan ${data.execution_id}: ${data.status}${data.wait_timed_out ? ' (still running; wait timed out)' : ''}`, breadcrumbs });
|
|
213
|
+
}) })
|
|
214
|
+
.command({ command: 'status <id>', describe: 'Read scan execution progress',
|
|
215
|
+
handler: wrap(async (argv, app) => {
|
|
216
|
+
const data = await scanStatus(app.sdk, argv.id);
|
|
217
|
+
report(app, argv, data, { summary: `Scan ${data.status}: ${data.message}`, breadcrumbs: executionHints(data) });
|
|
218
|
+
}) })
|
|
219
|
+
.command({ command: 'results <id>', describe: 'Read findings and check execution coverage; unreadable data is not zero',
|
|
220
|
+
builder: p => p.option('limit', { type: 'number', default: 50, describe: 'Maximum findings/check rows (1–200); truncation is reported' }),
|
|
221
|
+
handler: wrap(async (argv, app) => {
|
|
222
|
+
const data = await scanResults(app.sdk, argv.id, boundedLimit(argv.limit));
|
|
223
|
+
report(app, argv, data, { summary: `Scan ${data.status}; ${data.findings_count ?? 'unknown'} findings; ${data.checks_executed ?? 'unknown'} recorded check executions${data.incomplete ? '; incomplete data' : ''}`, breadcrumbs: executionHints(data) });
|
|
224
|
+
}) })
|
|
225
|
+
.command({ command: 'cancel <id>', describe: 'Request cancellation of this scan after confirmation',
|
|
226
|
+
handler: wrap(async (argv, app) => {
|
|
227
|
+
const status = await scanStatus(app.sdk, argv.id);
|
|
228
|
+
if (!status.terminal) await confirmDelete(app, argv, `Cancel scan ${argv.id}?`);
|
|
229
|
+
const data = status.terminal ? { ...status, cancellation_requested: false } : await cancelScan(app.sdk, argv.id);
|
|
230
|
+
report(app, argv, data, { summary: data.cancellation_requested ? `Cancellation requested: ${argv.id}` : 'Scan already finished; no cancellation sent', breadcrumbs: executionHints(data) });
|
|
231
|
+
}) }),
|
|
232
|
+
handler: wrap(async (argv, app) => report(app, argv, { commands: ['checks', 'suites', 'run', 'status', 'results', 'cancel'] }, { summary: 'Instance Scan', breadcrumbs: rootHints() })),
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/commands/skill.js
CHANGED
|
@@ -365,6 +365,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
365
365
|
const stateRaw = String(full.state?.value ?? full.state ?? '').toLowerCase();
|
|
366
366
|
const isClosed = ['complete', 'committed'].includes(stateRaw);
|
|
367
367
|
const hints = [];
|
|
368
|
+
hints.push({ action: 'scan', cmd: `jsn scan run --update-set ${sysID}${app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : ''}`, description: 'Offer an explicit scan of current records before completing or promoting this set' });
|
|
368
369
|
if (isClosed) {
|
|
369
370
|
hints.push({
|
|
370
371
|
action: 'set',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
import { AppError, CodeAPI, CodeNetwork } from '../errors.js';
|
|
4
|
+
import { checkLatest, getReleaseChannel, getVersion } from './version.js';
|
|
5
|
+
|
|
6
|
+
for (const name of ['upgrade', 'update']) {
|
|
7
|
+
declareCapabilities(name, { noInstance: true, skipDailyChecks: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function installPackage(args, { structured }) {
|
|
11
|
+
const result = spawnSync('npm', args, { stdio: structured ? 'pipe' : 'inherit', encoding: 'utf8' });
|
|
12
|
+
if (result.error || result.status !== 0) {
|
|
13
|
+
throw new AppError(CodeAPI, 'npm could not upgrade jsn.', 'Check npm installation permissions and connectivity, then retry.');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isNewer(candidate, installed) {
|
|
18
|
+
const parse = (value) => {
|
|
19
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
20
|
+
if (!match) throw new AppError(CodeAPI, 'The registry returned an invalid package version.');
|
|
21
|
+
return { core: match.slice(1, 4).map(Number), pre: match[4]?.split('.') };
|
|
22
|
+
};
|
|
23
|
+
const a = parse(candidate), b = parse(installed);
|
|
24
|
+
for (let i = 0; i < 3; i++) {
|
|
25
|
+
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i];
|
|
26
|
+
}
|
|
27
|
+
if (!a.pre || !b.pre) return !a.pre && Boolean(b.pre);
|
|
28
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
29
|
+
if (a.pre[i] === b.pre[i]) continue;
|
|
30
|
+
if (a.pre[i] === undefined) return false;
|
|
31
|
+
if (b.pre[i] === undefined) return true;
|
|
32
|
+
const an = /^\d+$/.test(a.pre[i]), bn = /^\d+$/.test(b.pre[i]);
|
|
33
|
+
if (an && bn) return Number(a.pre[i]) > Number(b.pre[i]);
|
|
34
|
+
if (an !== bn) return !an;
|
|
35
|
+
return a.pre[i] > b.pre[i];
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function upgradeCmd(wrap, { version = getVersion, check = checkLatest, install = installPackage } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
command: 'upgrade',
|
|
43
|
+
aliases: ['update'],
|
|
44
|
+
describe: 'Upgrade jsn through npm (preserves the installed release channel)',
|
|
45
|
+
builder: (y) => y
|
|
46
|
+
.option('channel', { type: 'string', choices: ['dev', 'latest'], describe: 'Choose the npm release channel' })
|
|
47
|
+
.option('dry-run', { type: 'boolean', describe: 'Show the install command without installing' })
|
|
48
|
+
.option('force', { type: 'boolean', describe: 'Reinstall even when the selected version is not newer' }),
|
|
49
|
+
handler: wrap(async (argv, app) => {
|
|
50
|
+
const current = version();
|
|
51
|
+
const channel = argv.channel || getReleaseChannel(current);
|
|
52
|
+
const args = ['install', '-g', `@jacebenson/jsn@${channel}`];
|
|
53
|
+
const command = `npm ${args.join(' ')}`;
|
|
54
|
+
if (argv.dryRun || argv['dry-run']) {
|
|
55
|
+
app.ok({ version: current, channel, dryRun: true, installCmd: command }, { summary: command });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const latest = await check({ version: current, channel });
|
|
59
|
+
if (!latest) throw new AppError(CodeNetwork, 'Could not check the npm release channel.', 'Check connectivity and retry; use --dry-run to inspect the command.');
|
|
60
|
+
const switching = argv.channel && channel !== getReleaseChannel(current);
|
|
61
|
+
if (!argv.force && !switching && !isNewer(latest, current)) {
|
|
62
|
+
app.ok({ version: current, latest, channel, upToDate: true }, { summary: `jsn ${current} — no newer version available` });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await install(args, { structured: Boolean(!process.stdout.isTTY || argv.json || argv.get || argv.format === 'json') });
|
|
66
|
+
app.ok({ previousVersion: current, version: latest, channel, upgraded: true }, { summary: `Installed jsn from the ${channel} channel (registry version ${latest})` });
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/commands/version.js
CHANGED
|
@@ -32,13 +32,13 @@ export function cancelBackgroundChecks() {
|
|
|
32
32
|
backgroundChecks.clear();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export async function checkLatest({ timeout = 1500, background = false, version = getVersion() } = {}) {
|
|
35
|
+
export async function checkLatest({ timeout = 1500, background = false, version = getVersion(), channel = getReleaseChannel(version) } = {}) {
|
|
36
36
|
const controller = new AbortController();
|
|
37
37
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
38
38
|
timer.unref();
|
|
39
39
|
if (background) backgroundChecks.add(controller);
|
|
40
40
|
try {
|
|
41
|
-
const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${
|
|
41
|
+
const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${channel}`, { signal: controller.signal });
|
|
42
42
|
if (!res.ok) return null;
|
|
43
43
|
const data = await res.json();
|
|
44
44
|
return typeof data.version === 'string' ? data.version : null;
|
package/src/help.js
CHANGED
|
@@ -28,6 +28,7 @@ function renderGroup(name, commands) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const CORE_COMMANDS = [
|
|
31
|
+
{ name: 'knowledge', alias: 'kb', desc: 'Search and read knowledge articles' },
|
|
31
32
|
{ name: 'incidents', alias: 'inc', desc: 'Manage incidents' },
|
|
32
33
|
{ name: 'changes', alias: 'chg', desc: 'Manage change requests' },
|
|
33
34
|
{ name: 'requests', alias: 'req', desc: 'Manage service catalog requests' },
|
|
@@ -132,6 +133,7 @@ const CONFIG_COMMANDS = [
|
|
|
132
133
|
];
|
|
133
134
|
|
|
134
135
|
const DEVELOPER_COMMANDS = [
|
|
136
|
+
{ name: 'scan', desc: 'Instance Scan checks, suites, explicit targets, progress and findings' },
|
|
135
137
|
{ name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
|
|
136
138
|
{ name: 'eval', desc: 'Run server-side JavaScript' },
|
|
137
139
|
{ name: 'rest', desc: 'Raw REST API calls' },
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { SaxesParser } from 'saxes';
|
|
2
|
+
import { getRecord, recordListParams } from './records-engine.js';
|
|
3
|
+
import { isSysId, unwrapSysId } from './resolve-record.js';
|
|
4
|
+
import { errAPI, errNotFound, errUsage } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export const CHECK_TABLES = {
|
|
7
|
+
table: 'scan_table_check', column: 'scan_column_type_check',
|
|
8
|
+
linter: 'scan_linter_check', script: 'scan_script_only_check',
|
|
9
|
+
};
|
|
10
|
+
export const raw = value => value && typeof value === 'object' ? (value.value ?? '') : value;
|
|
11
|
+
export const active = record => [true, 'true', '1', 1].includes(raw(record?.active));
|
|
12
|
+
export const referenceId = value => {
|
|
13
|
+
const id = raw(value) || value?.link?.split('/').at(-1);
|
|
14
|
+
return isSysId(id) ? id : '';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function requireId(value, label = 'execution') {
|
|
18
|
+
if (!isSysId(value)) throw errUsage(`Use a full 32-character sys_id for ${label}.`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function boundedLimit(value = 50) {
|
|
23
|
+
if (!Number.isInteger(value) || value < 1 || value > 200) throw errUsage('--limit must be an integer from 1 to 200.');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function scanList(sdk, table, { limit = 50, offset = 0, fields, query } = {}) {
|
|
28
|
+
boundedLimit(limit);
|
|
29
|
+
if (!Number.isInteger(offset) || offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
30
|
+
const params = recordListParams({ limit, offset, fields, query, displayValue: 'false' });
|
|
31
|
+
return sdk.listStrict(table, params);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function resolveScanRecord(sdk, table, identifier, matchField = 'name') {
|
|
35
|
+
const record = await getRecord(sdk, { table, identifier, matchField, displayValues: 'raw', resource: table });
|
|
36
|
+
if (table !== 'scan_check') return record;
|
|
37
|
+
const subtype = String(raw(record.sys_class_name) || '');
|
|
38
|
+
if (!Object.values(CHECK_TABLES).includes(subtype)) return record;
|
|
39
|
+
const concrete = await sdk.get(subtype, unwrapSysId(record));
|
|
40
|
+
if (!concrete || unwrapSysId(concrete) !== unwrapSysId(record)) throw errUsage('The concrete check definition is unavailable; inspect its class and permissions before changing it.');
|
|
41
|
+
return { ...record, ...concrete };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMMON_FIELDS = ['name', 'description', 'short_description', 'active', 'category', 'priority',
|
|
45
|
+
'script', 'run_condition', 'finding_type', 'resolution_details', 'documentation_url', 'score_min', 'score_max', 'score_scale'];
|
|
46
|
+
|
|
47
|
+
export function validateScanPayload(kind, input, { create = false, suite = false, current = {} } = {}) {
|
|
48
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw errUsage('The payload must be a JSON object.');
|
|
49
|
+
if (!suite && !CHECK_TABLES[kind]) throw errUsage('Choose --type table, column, linter, or script.');
|
|
50
|
+
const fields = suite ? ['name', 'description', 'active', 'private'] : [...COMMON_FIELDS,
|
|
51
|
+
...(kind === 'table' ? ['table', 'conditions', 'advanced', 'use_manifest'] : []),
|
|
52
|
+
...(kind === 'column' ? ['column_type'] : [])];
|
|
53
|
+
for (const field of Object.keys(input)) {
|
|
54
|
+
if (!fields.includes(field)) throw errUsage(`Unsupported ${suite ? 'suite' : kind + ' check'} field: ${field}`);
|
|
55
|
+
}
|
|
56
|
+
const data = { ...input };
|
|
57
|
+
for (const field of ['active', 'private', 'advanced', 'use_manifest']) {
|
|
58
|
+
if (data[field] === undefined) continue;
|
|
59
|
+
if (![true, false, 'true', 'false'].includes(data[field])) throw errUsage(`${field} must be true or false.`);
|
|
60
|
+
data[field] = data[field] === true || data[field] === 'true';
|
|
61
|
+
}
|
|
62
|
+
if ((create || data.name !== undefined) && (typeof data.name !== 'string' || !data.name.trim())) throw errUsage('A nonempty name is required.');
|
|
63
|
+
if (data.script !== undefined && typeof data.script !== 'string') throw errUsage('script must be a string.');
|
|
64
|
+
if (create && data.active === undefined) data.active = false;
|
|
65
|
+
if (data.table !== undefined && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(data.table)) throw errUsage('Invalid target table name.');
|
|
66
|
+
if (data.column_type !== undefined && !['script', 'xml', 'html'].includes(data.column_type)) throw errUsage('column_type must be script, xml, or html.');
|
|
67
|
+
if (!suite) {
|
|
68
|
+
const script = data.script ?? raw(current.script);
|
|
69
|
+
const advanced = data.advanced ?? active({ active: current.advanced });
|
|
70
|
+
if (create && kind === 'table' && !data.table) throw errUsage('Table checks require a table.');
|
|
71
|
+
if (create && kind === 'column' && !data.column_type) throw errUsage('Column checks require column_type.');
|
|
72
|
+
if ((create || Object.hasOwn(data, 'script') || Object.hasOwn(data, 'advanced'))
|
|
73
|
+
&& (kind !== 'table' || advanced) && (typeof script !== 'string' || !script.trim())) {
|
|
74
|
+
throw errUsage('This check type requires a nonempty script.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkKind(record) {
|
|
81
|
+
const table = String(raw(record.sys_class_name) || '');
|
|
82
|
+
const kind = Object.keys(CHECK_TABLES).find(key => CHECK_TABLES[key] === table);
|
|
83
|
+
if (!kind) throw errUsage('Unknown or unreadable check class; inspect the check before changing it.');
|
|
84
|
+
return kind;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decodeAjax(xml) {
|
|
88
|
+
let answer;
|
|
89
|
+
const parser = new SaxesParser();
|
|
90
|
+
parser.on('doctype', () => { throw errAPI(200, 'Unexpected document type in scan response.'); });
|
|
91
|
+
parser.on('opentag', node => { if (node.name === 'xml') answer = node.attributes.answer; });
|
|
92
|
+
try { parser.write(xml).close(); } catch { throw errAPI(200, 'Malformed scan processor XML response.'); }
|
|
93
|
+
if (typeof answer !== 'string') throw errAPI(200, 'Scan processor returned no answer; check endpoint access.');
|
|
94
|
+
return answer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function scanAjax(sdk, method, parameters) {
|
|
98
|
+
const query = new URLSearchParams({ sysparm_processor: 'ScanAjaxProcessor', sysparm_name: method, ...parameters });
|
|
99
|
+
return decodeAjax(await sdk.rawRequest(`${sdk.baseURL}/xmlhttp.do?${query}`, { method: 'POST' }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const TERMINAL = new Set(['successful', 'failed', 'cancelled']);
|
|
103
|
+
const STATES = { '0': 'pending', '1': 'running', '2': 'successful', '3': 'failed', '4': 'cancelled',
|
|
104
|
+
pending: 'pending', running: 'running', successful: 'successful', failed: 'failed',
|
|
105
|
+
cancelled: 'cancelled', canceled: 'cancelled' };
|
|
106
|
+
|
|
107
|
+
export async function scanStatus(sdk, id, { allowInitializing = false } = {}) {
|
|
108
|
+
requireId(id);
|
|
109
|
+
const tracker = await sdk.get('sys_execution_tracker', id);
|
|
110
|
+
if (!tracker) throw errNotFound('scan execution', id);
|
|
111
|
+
if (!raw(tracker.type) && allowInitializing) return { execution_id: id, status: 'pending', terminal: false,
|
|
112
|
+
percent_complete: null, message: 'Execution tracker is initializing', result_id: null };
|
|
113
|
+
if (raw(tracker.type) === 'sys.instance_scan.queue') return { execution_id: id, status: 'pending', terminal: false,
|
|
114
|
+
percent_complete: raw(tracker.percent_complete) ?? null, message: raw(tracker.message) || 'Scan is queued', result_id: null };
|
|
115
|
+
if (raw(tracker.type) !== 'instance_scan') throw errUsage('This execution tracker is not an Instance Scan.');
|
|
116
|
+
const state = STATES[String(raw(tracker.state)).toLowerCase()] || 'unknown';
|
|
117
|
+
let resultId = '';
|
|
118
|
+
if (raw(tracker.result)) {
|
|
119
|
+
try { resultId = referenceId(JSON.parse(raw(tracker.result)).result_id); } catch { /* reported as unavailable below */ }
|
|
120
|
+
}
|
|
121
|
+
return { execution_id: id, status: state, terminal: TERMINAL.has(state),
|
|
122
|
+
percent_complete: raw(tracker.percent_complete) ?? null,
|
|
123
|
+
message: raw(tracker.message) || '', result_id: resultId || null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function resolveSuite(sdk, identifier) {
|
|
127
|
+
const suite = await resolveScanRecord(sdk, 'scan_check_suite', identifier);
|
|
128
|
+
if (!active(suite)) throw errUsage('The selected suite is inactive or its active state is unreadable. Activate it explicitly first.');
|
|
129
|
+
return unwrapSysId(suite);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function prepareScan(sdk, argv) {
|
|
133
|
+
const previous = argv.previous;
|
|
134
|
+
const updateSet = argv.updateSet ?? argv['update-set'];
|
|
135
|
+
for (const value of [previous, updateSet, argv.scope, argv.record, argv.suite]) {
|
|
136
|
+
if (value !== undefined && (typeof value !== 'string' || !value.trim())) throw errUsage('Scan target and suite identifiers must be nonempty strings.');
|
|
137
|
+
}
|
|
138
|
+
const selectors = [updateSet, argv.scope, argv.record, previous].filter(value => value !== undefined).length;
|
|
139
|
+
if (selectors + Number(Boolean(argv.full)) !== 1 && !(previous && argv.full && selectors === 1)) {
|
|
140
|
+
throw errUsage('Choose exactly one target: --update-set, --scope, --record with --table, --full, or --previous.');
|
|
141
|
+
}
|
|
142
|
+
if (Boolean(argv.table) !== Boolean(argv.record)) throw errUsage('--record and --table must be supplied together.');
|
|
143
|
+
if (previous && argv.suite) throw errUsage('--previous preserves its source suite; do not combine it with --suite.');
|
|
144
|
+
let target;
|
|
145
|
+
if (previous) {
|
|
146
|
+
const status = await scanStatus(sdk, previous);
|
|
147
|
+
if (!status.terminal || !status.result_id) throw errUsage('The previous scan must have finished and have a readable result.');
|
|
148
|
+
const result = await sdk.get('scan_result', status.result_id);
|
|
149
|
+
const comboId = referenceId(result?.combo);
|
|
150
|
+
if (!comboId) throw errUsage('The previous scan has no reusable target combination. Choose an explicit target.');
|
|
151
|
+
const combo = await sdk.get('scan_combo', comboId);
|
|
152
|
+
if (!combo) throw errUsage('The previous scan combination is unavailable.');
|
|
153
|
+
if (!raw(combo.targets) && !argv.full) throw errUsage('The previous scan was instance-wide. Repeat with --full to approve that breadth.');
|
|
154
|
+
target = { kind: 'previous', execution_id: previous, combo_id: comboId, full: !raw(combo.targets) };
|
|
155
|
+
} else if (updateSet !== undefined) {
|
|
156
|
+
const record = await resolveScanRecord(sdk, 'sys_update_set', updateSet);
|
|
157
|
+
target = { kind: 'update_set', sys_id: unwrapSysId(record), name: raw(record.name),
|
|
158
|
+
note: 'Scans current instance records referenced by this update set, not historical XML versions. Script-only checks do not participate.' };
|
|
159
|
+
} else if (argv.scope !== undefined) {
|
|
160
|
+
const record = await resolveScanRecord(sdk, 'sys_scope', argv.scope, 'scope');
|
|
161
|
+
target = { kind: 'scope', sys_id: unwrapSysId(record), name: raw(record.scope) };
|
|
162
|
+
} else if (argv.record !== undefined) {
|
|
163
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(argv.table)) throw errUsage('Invalid target table.');
|
|
164
|
+
const record = await resolveScanRecord(sdk, argv.table, argv.record, 'sys_id');
|
|
165
|
+
target = { kind: 'record', table: argv.table, sys_id: unwrapSysId(record),
|
|
166
|
+
inactive: raw(record.active) === false || raw(record.active) === 'false',
|
|
167
|
+
note: 'Inactive target records may be skipped by the scan engine. Script-only checks do not participate.' };
|
|
168
|
+
} else target = { kind: 'full', note: 'Instance-wide scans can traverse many tables and take substantially longer.' };
|
|
169
|
+
if (!target.sys_id && !['full', 'previous'].includes(target.kind)) throw errUsage('The target has no readable sys_id.');
|
|
170
|
+
const suiteId = argv.suite ? await resolveSuite(sdk, argv.suite) : null;
|
|
171
|
+
return { target, suite_id: suiteId };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function startScan(sdk, plan) {
|
|
175
|
+
const { target, suite_id: suite } = plan;
|
|
176
|
+
let response;
|
|
177
|
+
if (suite && ['update_set', 'scope'].includes(target.kind)) {
|
|
178
|
+
const kind = target.kind === 'scope' ? 'scoped_apps' : 'update_sets';
|
|
179
|
+
const field = target.kind === 'scope' ? 'app_scope_sys_ids' : 'update_set_sys_ids';
|
|
180
|
+
const data = await sdk.request(`${sdk.baseURL}/api/sn_cicd/instance_scan/suite_scan/${requireId(suite, 'suite')}/${kind}`, {
|
|
181
|
+
method: 'POST', body: JSON.stringify({ [field]: [target.sys_id] }),
|
|
182
|
+
});
|
|
183
|
+
response = data?.result;
|
|
184
|
+
if (response?.error || !['0', '1', '2'].includes(String(response?.status))) throw errAPI(200, 'Scan dispatch was rejected by the provider.');
|
|
185
|
+
const executionId = response?.links?.progress?.id;
|
|
186
|
+
if (!isSysId(executionId)) throw errAPI(200, 'Scan dispatch returned no valid execution id. Inspect existing scans before retrying.');
|
|
187
|
+
return { execution_id: executionId, via: 'sn_cicd', ...plan };
|
|
188
|
+
}
|
|
189
|
+
let method, parameters;
|
|
190
|
+
if (target.kind === 'previous') { method = 'executeScanFromCombo'; parameters = { sysparm_combo_id: target.combo_id }; }
|
|
191
|
+
else if (suite && target.kind === 'full') { method = 'executeSuiteScan'; parameters = { sysparm_sys_id: suite }; }
|
|
192
|
+
else if (suite) {
|
|
193
|
+
method = 'executeSuiteScanWithTargets'; parameters = { sysparm_suite_sys_id: suite,
|
|
194
|
+
sysparm_target_table: target.table, sysparm_target_sys_ids: JSON.stringify([target.sys_id]) };
|
|
195
|
+
} else if (target.kind === 'update_set') { method = 'executeUpdateSetScan'; parameters = { sysparm_sys_id: target.sys_id }; }
|
|
196
|
+
else if (target.kind === 'scope') { method = 'executeAppScan'; parameters = { sysparm_scope_id: target.sys_id }; }
|
|
197
|
+
else if (target.kind === 'record') { method = 'executePointScan'; parameters = { sysparm_sys_id: target.sys_id, sysparm_table_name: target.table }; }
|
|
198
|
+
else { method = 'executeFullScan'; parameters = {}; }
|
|
199
|
+
try { response = JSON.parse(await scanAjax(sdk, method, parameters)); }
|
|
200
|
+
catch (error) { if (error.code) throw error; throw errAPI(200, 'Malformed scan dispatch answer. Inspect existing scans before retrying.'); }
|
|
201
|
+
if (response.status !== 0 || !isSysId(response.progressId)) throw errAPI(200, 'Scan dispatch failed or returned no valid execution id. Inspect existing scans before retrying.');
|
|
202
|
+
return { execution_id: response.progressId, via: 'ScanAjaxProcessor', ...plan };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function scanResults(sdk, id, limit = 50) {
|
|
206
|
+
boundedLimit(limit);
|
|
207
|
+
const status = await scanStatus(sdk, id);
|
|
208
|
+
const data = { ...status, checks_executed: null, findings_count: null, checks: [], findings: [],
|
|
209
|
+
incomplete: !status.terminal, unavailable: [], truncated: {}, rerun_full: false, reusable_target: false };
|
|
210
|
+
if (!status.result_id) { data.unavailable.push('scan_result'); data.incomplete = true; return data; }
|
|
211
|
+
let result;
|
|
212
|
+
try {
|
|
213
|
+
result = await sdk.get('scan_result', status.result_id);
|
|
214
|
+
if (!result) throw new Error('Result unavailable');
|
|
215
|
+
data.scan_number = raw(result.number);
|
|
216
|
+
const value = raw(result.finding_count);
|
|
217
|
+
const count = Number(value);
|
|
218
|
+
if ((typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) && Number.isInteger(count) && count >= 0) data.findings_count = count;
|
|
219
|
+
else { data.unavailable.push('scan_result.finding_count'); data.incomplete = true; }
|
|
220
|
+
} catch { data.unavailable.push('scan_result'); data.incomplete = true; }
|
|
221
|
+
for (const [key, table, fields] of [
|
|
222
|
+
['checks', 'scan_check_execution', ['check', 'message', 'execution_time']],
|
|
223
|
+
['findings', 'scan_finding', ['check', 'source_table', 'source', 'finding_details', 'count']],
|
|
224
|
+
]) {
|
|
225
|
+
try {
|
|
226
|
+
const rows = await sdk.listStrict(table, recordListParams({ limit: limit + 1,
|
|
227
|
+
query: `result=${status.result_id}^ORDERBYsys_id`, fields, displayValue: 'all' }));
|
|
228
|
+
data[key] = rows.slice(0, limit);
|
|
229
|
+
data.truncated[key] = rows.length > limit;
|
|
230
|
+
if (key === 'checks' && !data.truncated[key] && status.terminal) data.checks_executed = rows.length;
|
|
231
|
+
} catch { data.unavailable.push(table); data.incomplete = true; }
|
|
232
|
+
}
|
|
233
|
+
const comboId = referenceId(result?.combo);
|
|
234
|
+
if (comboId) {
|
|
235
|
+
try { const combo = await sdk.get('scan_combo', comboId); if (combo) { data.reusable_target = true; data.rerun_full = !raw(combo.targets); } }
|
|
236
|
+
catch { /* Optional rerun hint is omitted when target metadata is inaccessible. */ }
|
|
237
|
+
}
|
|
238
|
+
data.coverage_note = 'Execution success does not prove functional correctness or complete coverage. Zero findings may mean records or checks were skipped.';
|
|
239
|
+
return data;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function cancelScan(sdk, id) {
|
|
243
|
+
const status = await scanStatus(sdk, id);
|
|
244
|
+
if (status.terminal) return { ...status, cancellation_requested: false };
|
|
245
|
+
const answer = await scanAjax(sdk, 'cancelScan', { sysparm_progress_id: id });
|
|
246
|
+
const after = await scanStatus(sdk, id);
|
|
247
|
+
if (after.status !== 'cancelled' && !/cancelled successfully/i.test(answer)) throw errAPI(200, 'Cancellation was not confirmed. Check the execution status before retrying.');
|
|
248
|
+
return { ...after, cancellation_requested: true };
|
|
249
|
+
}
|