@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 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 `npm install -g @jacebenson/jsn@dev` again. Updates are manual.
27
- Return to stable with `npm install -g @jacebenson/jsn@latest`.
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
- After each successful npm publish, deployment deprecates older versions in that
43
- channel. Deprecated versions remain installable and appear in history, but npm
44
- prints an update warning on installation. Existing deprecation messages are
45
- preserved. No versions are unpublished.
46
-
47
- The deprecation step uses the repository's `NPM_TOKEN` secret with permission to
48
- modify `@jacebenson/jsn` and noninteractive authentication (including bypass 2FA
49
- where required). Publishing continues to use OIDC. A deprecation failure fails
50
- the workflow after publishing; it does not roll back the published package.
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.13-dev.99.1",
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.1.0",
32
- "@inquirer/prompts": "^7.5.1",
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 create ATFs and instance scans to verify the outcomes.
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
 
@@ -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: false })) throw provider.error;
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
@@ -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
- 'version', 'docs', 'history', 'discover', 'completion',
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
+ }
@@ -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
- console.log('Service Catalog management.');
153
- console.log('');
154
- console.log('Commands:');
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -189,6 +189,7 @@ export function skillCmd(wrap) {
189
189
  }
190
190
  app.ok({
191
191
  content,
192
+ _formatted: content.endsWith('\n') ? content : `${content}\n`,
192
193
  bundled: SKILL_REPO_PATH,
193
194
  }, {
194
195
  summary: 'jsn AI agent skill file (bundled)',
@@ -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
+ }
@@ -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}/${getReleaseChannel(version)}`, { signal: controller.signal });
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
+ }