@jacebenson/jsn 4.3.13-dev.100.1 → 4.3.13-dev.102.1

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
@@ -32,6 +32,57 @@ under npm's `dev` tag. Versions include the workflow run number and attempt
32
32
  The npm trusted publisher for `publish-dev.yml` must authorize the repository's
33
33
  `dev` GitHub environment, alongside the production publishing configuration.
34
34
 
35
+ ### Knowledge search
36
+
37
+ Use `jsn knowledge search "VPN setup" --profile work` (alias `kb`) to search
38
+ accessible articles, then `jsn knowledge show KB0000010 --profile work` to read
39
+ one. Search supports bounded `--limit`/`--offset`, knowledge-base sys_ids with
40
+ `--kb`, and `--language`. JSON preserves native HTML; human output uses Markdown.
41
+ Hints preserve an explicitly selected profile/instance. The Knowledge API plugin
42
+ (`sn_km_api`) must be available; access is evaluated by that API, with no fallback
43
+ to direct table reads. Reads do not request view-count updates. See the
44
+ [Knowledge API reference](https://www.servicenow.com/docs/r/yokohama/api-reference/rest-apis/knowledge-api.html).
45
+
46
+ ### Instance Scan
47
+
48
+ `jsn scan` manages checks and suites and runs scans against an explicit target.
49
+ Use `jsn scan --help` and each subcommand's `--help` to discover the options.
50
+
51
+ ```bash
52
+ jsn scan checks list --profile dev
53
+ jsn scan checks create --type table --data-file check.json --profile dev
54
+ jsn scan checks activate <check-id> --profile dev
55
+ jsn scan suites add-check <suite-id> --check <check-id> --profile dev
56
+ jsn scan run --update-set "My change" --suite "My suite" --wait --profile dev
57
+ jsn scan run --table sys_script_include --record <record-id> --profile dev
58
+ jsn scan results <execution-id> --profile dev
59
+ ```
60
+
61
+ Check types are `table`, `column`, `linter`, and `script`. Definitions are created
62
+ inactive unless the payload explicitly sets `active: true`; activation never
63
+ starts a scan. Both checks and suites support list/show/create/update/delete and
64
+ activate/deactivate. Suites also support add-check/remove-check.
65
+
66
+ Runs require an update set, scope, record plus table, or explicit `--full`.
67
+ Instance-wide scans may traverse many tables even when a check's script only
68
+ examines a few records. Starting and cancelling a scan require confirmation;
69
+ use `--force` only after approving that operation. `--wait --timeout 120` bounds
70
+ the wait, not the scan's lifetime. A timed-out wait keeps the execution id and
71
+ does not cancel or resubmit the scan. Use `status`, `results`, or `cancel` next.
72
+
73
+ Update-set scans examine current instance records, not the historical XML
74
+ versions. Inactive records may be skipped. Script-only checks do not participate
75
+ in record/update-set scans; use an explicitly instance-wide suite run to execute
76
+ them. Execution success does not mean zero findings or complete test coverage:
77
+ results report recorded check executions, finding counts, unreadable sources,
78
+ and truncated lists separately. A result's rerun hint preserves the previous
79
+ target combination and requires `--full` again for instance-wide scans.
80
+
81
+ Every command returns next-step breadcrumbs in human and JSON output, retaining
82
+ an explicitly selected profile or instance. `updatesets show` also offers a scan
83
+ hint. Recommendation/generation of new checks is not part of this command;
84
+ JSN contains no AI and does not infer missing business requirements.
85
+
35
86
  ### Supported versions
36
87
 
37
88
  Only the current release and the two preceding releases in each channel are
@@ -92,3 +143,26 @@ This applies to root help, root JSON, `discover`, and root shell completion.
92
143
  Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
93
144
  every registered command. Explicit command paths remain available and ServiceNow
94
145
  ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
146
+
147
+ ### Ordering catalog items
148
+
149
+ Use `jsn catalogitems search "laptop" --profile work`, then
150
+ `jsn catalogitems describe <item-sys-id> --profile work` to inspect pricing and
151
+ variable definitions through the native Service Catalog API. Create a JSON file
152
+ mapping variable names to values, then preview with
153
+ `jsn catalogitems order <item-sys-id> --variables-file variables.json --dry-run --profile work`.
154
+ Omit `--dry-run` to submit after confirmation. `--quantity` defaults to one;
155
+ `--requested-for` accepts a user sys_id subject to instance delegation rules.
156
+
157
+ Orders use `order_now`, preserving catalog fulfillment rather than inserting an
158
+ RITM directly. The command handles standard catalog items; record producers and
159
+ order guides require separate workflows. Server rules validate mandatory and
160
+ dynamic variables at submission; a preview does not validate those rules. Preview
161
+ output lists variable names, not entered values. This operation requires a
162
+ write-enabled profile (also for previews), but no development update set.
163
+ Confirmation follows the existing profile policy; automation can supply `--force`
164
+ only for an approved order. Timeout or incomplete response is an uncertain
165
+ outcome: inspect requests before retrying. JSN does not automatically submit a
166
+ second order. `create/list/show` retain their definition-management behavior.
167
+
168
+ 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.100.1",
3
+ "version": "4.3.13-dev.102.1",
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": {
@@ -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
 
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';
@@ -72,6 +73,7 @@ import { logsCmd } from './commands/logs.js';
72
73
  import { scrapiCmd } from './commands/scrapi.js';
73
74
  import { b4rulesCmd } from './commands/b4rules.js';
74
75
  import { atfCmd } from './commands/atf.js';
76
+ import { scanCmd } from './commands/scan.js';
75
77
  import { approvalsCmd } from './commands/approvals.js';
76
78
  import { historyCmd } from './commands/history.js';
77
79
  import { perfCmd } from './commands/perf.js';
@@ -411,6 +413,7 @@ export function buildCLI() {
411
413
  .command(propertiesCmd(wrap))
412
414
  // DATA
413
415
  .command(cmdbCmd(wrap))
416
+ .command(knowledgeCmd(wrap))
414
417
  .command(recordsCmd(wrap))
415
418
  .command(attachmentsCmd(wrap))
416
419
  .command(transactionsCmd(wrap))
@@ -421,6 +424,7 @@ export function buildCLI() {
421
424
  .command(codesearchCmd(wrap))
422
425
  // DEVELOPER
423
426
  .command(atfCmd(wrap))
427
+ .command(scanCmd(wrap))
424
428
  .command(approvalsCmd(wrap))
425
429
  .command(skippedUpdatesCmd(wrap))
426
430
  .command(perfCmd(wrap))
@@ -2,8 +2,10 @@
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
  'version', 'docs', 'history', 'discover', 'completion',
8
+ 'catalogitems',
7
9
  ]);
8
10
 
9
11
  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,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
+ }
@@ -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
+ }
@@ -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',
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
+ }