@jacebenson/jsn 4.3.13-dev.101.1 → 4.3.13-dev.104.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 +43 -0
- package/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/command-audience.js +3 -0
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/knowledge.js +75 -0
- package/src/help.js +1 -0
package/README.md
CHANGED
|
@@ -32,6 +32,17 @@ 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
|
+
|
|
35
46
|
### Instance Scan
|
|
36
47
|
|
|
37
48
|
`jsn scan` manages checks and suites and runs scans against an explicit target.
|
|
@@ -72,6 +83,15 @@ an explicitly selected profile or instance. `updatesets show` also offers a scan
|
|
|
72
83
|
hint. Recommendation/generation of new checks is not part of this command;
|
|
73
84
|
JSN contains no AI and does not infer missing business requirements.
|
|
74
85
|
|
|
86
|
+
### CMDB search
|
|
87
|
+
|
|
88
|
+
`jsn cmdb search "web" --profile work` searches names, FQDNs, IP addresses and
|
|
89
|
+
serial numbers on `cmdb_ci`. Use `--class cmdb_ci_server` for an exact class
|
|
90
|
+
match (excluding subclasses), and bounded `--limit`/`--offset` pagination.
|
|
91
|
+
Search text is literal; encoded-query operators and query scripts are rejected.
|
|
92
|
+
Existing `cmdb list --query` remains available for deliberate encoded queries.
|
|
93
|
+
Results include show/relationship hints and preserve the selected connection.
|
|
94
|
+
|
|
75
95
|
### Supported versions
|
|
76
96
|
|
|
77
97
|
Only the current release and the two preceding releases in each channel are
|
|
@@ -132,3 +152,26 @@ This applies to root help, root JSON, `discover`, and root shell completion.
|
|
|
132
152
|
Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
|
|
133
153
|
every registered command. Explicit command paths remain available and ServiceNow
|
|
134
154
|
ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
|
|
155
|
+
|
|
156
|
+
### Ordering catalog items
|
|
157
|
+
|
|
158
|
+
Use `jsn catalogitems search "laptop" --profile work`, then
|
|
159
|
+
`jsn catalogitems describe <item-sys-id> --profile work` to inspect pricing and
|
|
160
|
+
variable definitions through the native Service Catalog API. Create a JSON file
|
|
161
|
+
mapping variable names to values, then preview with
|
|
162
|
+
`jsn catalogitems order <item-sys-id> --variables-file variables.json --dry-run --profile work`.
|
|
163
|
+
Omit `--dry-run` to submit after confirmation. `--quantity` defaults to one;
|
|
164
|
+
`--requested-for` accepts a user sys_id subject to instance delegation rules.
|
|
165
|
+
|
|
166
|
+
Orders use `order_now`, preserving catalog fulfillment rather than inserting an
|
|
167
|
+
RITM directly. The command handles standard catalog items; record producers and
|
|
168
|
+
order guides require separate workflows. Server rules validate mandatory and
|
|
169
|
+
dynamic variables at submission; a preview does not validate those rules. Preview
|
|
170
|
+
output lists variable names, not entered values. This operation requires a
|
|
171
|
+
write-enabled profile (also for previews), but no development update set.
|
|
172
|
+
Confirmation follows the existing profile policy; automation can supply `--force`
|
|
173
|
+
only for an approved order. Timeout or incomplete response is an uncertain
|
|
174
|
+
outcome: inspect requests before retrying. JSN does not automatically submit a
|
|
175
|
+
second order. `create/list/show` retain their definition-management behavior.
|
|
176
|
+
|
|
177
|
+
See the [Service Catalog API reference](https://www.servicenow.com/docs/r/api-reference/rest-apis/c_ServiceCatalogAPI.html).
|
package/package.json
CHANGED
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';
|
|
@@ -412,6 +413,7 @@ export function buildCLI() {
|
|
|
412
413
|
.command(propertiesCmd(wrap))
|
|
413
414
|
// DATA
|
|
414
415
|
.command(cmdbCmd(wrap))
|
|
416
|
+
.command(knowledgeCmd(wrap))
|
|
415
417
|
.command(recordsCmd(wrap))
|
|
416
418
|
.command(attachmentsCmd(wrap))
|
|
417
419
|
.command(transactionsCmd(wrap))
|
package/src/command-audience.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Explicit command paths and --all-commands remain available to every account.
|
|
3
3
|
const BASIC_COMMANDS = new Set([
|
|
4
4
|
'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
|
|
5
|
+
'knowledge',
|
|
5
6
|
'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
|
|
7
|
+
'cmdb',
|
|
6
8
|
'version', 'docs', 'history', 'discover', 'completion',
|
|
9
|
+
'catalogitems',
|
|
7
10
|
]);
|
|
8
11
|
|
|
9
12
|
export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parseDataArg, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const ID = /^[a-f0-9]{32}$/i;
|
|
5
|
+
const API = '/api/sn_sc/servicecatalog/items';
|
|
6
|
+
const object = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
|
|
8
|
+
function emit(app, argv, data, summary, breadcrumbs = []) {
|
|
9
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
10
|
+
app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
11
|
+
}
|
|
12
|
+
function requireId(id) { if (!ID.test(id || '')) throw errUsage('Use the full catalog item sys_id; find it with catalogitems search.'); }
|
|
13
|
+
async function describe(app, id) {
|
|
14
|
+
requireId(id);
|
|
15
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}/${id}`, { method: 'GET' });
|
|
16
|
+
if (!object(response?.result) || response.result.sys_id !== id) throw errAPI(200, 'Catalog API returned no readable item with the requested identity.');
|
|
17
|
+
return response.result;
|
|
18
|
+
}
|
|
19
|
+
function itemHints(id) {
|
|
20
|
+
return [{ action: 'describe', cmd: `jsn catalogitems describe ${id}`, description: 'Inspect catalog inputs and pricing' },
|
|
21
|
+
{ action: 'preview', cmd: `jsn catalogitems order ${id} --dry-run --variables-file variables.json`, description: 'Preview an order without submitting' }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function catalogOrderCommands(wrap) {
|
|
25
|
+
return [
|
|
26
|
+
{ command: 'search <text>', describe: 'Find requestable items through the Service Catalog API',
|
|
27
|
+
builder: y => y.positional('text', { type: 'string' }).option('limit', { type: 'number', default: 20, describe: 'Maximum items (1–100)' }),
|
|
28
|
+
handler: wrap(async (argv, app) => {
|
|
29
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty catalog search text.');
|
|
30
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
31
|
+
const params = new URLSearchParams({ sysparm_text: argv.text.trim(), sysparm_limit: String(argv.limit) });
|
|
32
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}?${params}`, { method: 'GET' });
|
|
33
|
+
if (!Array.isArray(response?.result) || response.result.some(i => !object(i) || !ID.test(i.sys_id || ''))) throw errAPI(200, 'Catalog API returned no valid item list.');
|
|
34
|
+
emit(app, argv, { items: response.result, count: response.result.length, limit: argv.limit,
|
|
35
|
+
possibly_truncated: response.result.length === argv.limit }, `${response.result.length} catalog item(s)`,
|
|
36
|
+
response.result.flatMap(i => itemHints(i.sys_id).slice(0, 1)));
|
|
37
|
+
}) },
|
|
38
|
+
{ command: 'describe <id>', aliases: ['inputs'], describe: 'Read requestable item details, pricing and variable definitions',
|
|
39
|
+
builder: y => y.positional('id', { type: 'string' }),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
41
|
+
const item = await describe(app, argv.id);
|
|
42
|
+
emit(app, argv, item, item.name || item.short_description || argv.id, itemHints(argv.id));
|
|
43
|
+
}) },
|
|
44
|
+
{ command: 'order <id>', describe: 'Submit a catalog order with explicit confirmation',
|
|
45
|
+
builder: y => y.positional('id', { type: 'string' })
|
|
46
|
+
.option('variables', { type: 'string', describe: 'JSON object of catalog variable names/values' })
|
|
47
|
+
.option('variables-file', { type: 'string', describe: 'Read the variables object from a JSON file' })
|
|
48
|
+
.option('quantity', { type: 'number', default: 1, describe: 'Positive integer quantity (max 1000)' })
|
|
49
|
+
.option('requested-for', { type: 'string', describe: 'User sys_id; instance delegation rules still apply' })
|
|
50
|
+
.option('dry-run', { type: 'boolean', default: false, describe: 'Read item details and preview variable names; no order is submitted' })
|
|
51
|
+
.epilogue('Use describe to inspect variables first. Server-side rules validate the order; a preview does not prove submission will succeed. Existing create/list/show commands manage definitions. Orders require a write-enabled profile, including dry runs. No application/update-set selection is needed.'),
|
|
52
|
+
handler: wrap(async (argv, app) => {
|
|
53
|
+
requireId(argv.id);
|
|
54
|
+
if (!Number.isInteger(argv.quantity) || argv.quantity < 1 || argv.quantity > 1000) throw errUsage('--quantity must be an integer from 1 to 1000.');
|
|
55
|
+
const requestedFor = argv.requestedFor ?? argv['requested-for'];
|
|
56
|
+
if (requestedFor !== undefined && !ID.test(requestedFor)) throw errUsage('--requested-for requires a user sys_id.');
|
|
57
|
+
const file = argv.variablesFile ?? argv['variables-file'];
|
|
58
|
+
if (argv.variables !== undefined && file !== undefined) throw errUsage('Choose --variables or --variables-file, not both.');
|
|
59
|
+
const variables = argv.variables !== undefined || file !== undefined
|
|
60
|
+
? parseDataArg({ data: argv.variables, 'data-file': file }) : {};
|
|
61
|
+
if (!object(variables)) throw errUsage('Catalog variables must be a JSON object.');
|
|
62
|
+
const item = await describe(app, argv.id);
|
|
63
|
+
if (item.sys_class_name && item.sys_class_name !== 'sc_cat_item') throw errUsage('order supports standard catalog items; record producers and order guides need their own submission workflows.');
|
|
64
|
+
if (item.active === false || item.active === 'false' || item.can_order === false) throw errUsage('This item is not currently orderable.');
|
|
65
|
+
const plan = { item_id: argv.id, name: item.name || item.short_description, quantity: argv.quantity,
|
|
66
|
+
requested_for: requestedFor || null, variable_names: Object.keys(variables) };
|
|
67
|
+
if (argv.dryRun ?? argv['dry-run']) return emit(app, argv, { ...plan, submitted: false, server_validation: 'not performed' }, 'Catalog order preview; nothing submitted', itemHints(argv.id));
|
|
68
|
+
const rerun = ['catalogitems', 'order', argv.id, '--quantity', String(argv.quantity),
|
|
69
|
+
...(requestedFor ? ['--requested-for', requestedFor] : []),
|
|
70
|
+
...(file ? ['--variables-file', file] : Object.keys(variables).length ? ['--variables-file', '<variables.json>'] : [])];
|
|
71
|
+
try {
|
|
72
|
+
await confirmDelete(app, argv, `Order ${argv.quantity} × ${plan.name || argv.id}${requestedFor ? ` for ${requestedFor}` : ''}?`, { rerun });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.message === 'Deletion cancelled') throw errUsage('Catalog order cancelled.');
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const body = { sysparm_quantity: argv.quantity, variables, ...(requestedFor ? { sysparm_requested_for: requestedFor } : {}) };
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await app.sdk.request(`${app.sdk.baseURL}${API}/${argv.id}/order_now`, { method: 'POST', body: JSON.stringify(body) });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.status >= 400 && error.status < 500 && error.status !== 408) throw errAPI(error.status, 'Catalog order was rejected. Check item access, required variables and requested-for eligibility.');
|
|
83
|
+
throw errAPI(error.status || 0, 'Catalog submission did not return a confirmed order. Inspect your requests before retrying; the order may have been placed.');
|
|
84
|
+
}
|
|
85
|
+
const result = response?.result;
|
|
86
|
+
const requestId = result?.request_id || result?.sys_id, number = result?.request_number || result?.number;
|
|
87
|
+
if (!object(result) || result.error || !ID.test(requestId || '') || typeof number !== 'string' || !number.trim()) throw errAPI(200, 'Catalog response did not confirm an order identity. Inspect your requests before retrying; do not resubmit automatically.');
|
|
88
|
+
emit(app, argv, { item_id: argv.id, quantity: argv.quantity, request_id: requestId, request_number: number, submitted: true },
|
|
89
|
+
`Ordered: ${number}`, [{ action: 'request', cmd: `jsn records get --table sc_request --sys-id ${requestId}`, description: 'Read the request header' },
|
|
90
|
+
{ action: 'items', cmd: `jsn requests list --query ${quoteShellArg(`request=${requestId}`)}`, description: 'Track the requested items' }]);
|
|
91
|
+
}) },
|
|
92
|
+
];
|
|
93
|
+
}
|
package/src/commands/catalog.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { getStringField, interactiveList, resolveItemOptionType } from '../helpers.js';
|
|
1
|
+
import { getStringField, interactiveList, resolveItemOptionType, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { catalogOrderCommands } from './catalog-order.js';
|
|
2
3
|
import { resolveSysId } from '../resolve-record.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
import { enrichCatalogItem, buildCatalogFormatted } from './catalog/enrich.js';
|
|
5
6
|
|
|
6
|
-
declareCapabilities('catalogitems', { mutationSubcommands: ['create'] });
|
|
7
|
+
declareCapabilities('catalogitems', { mutationSubcommands: ['create', 'order'], mutationContextExemptSubcommands: ['order'], localPreviewSubcommands: ['order'] });
|
|
7
8
|
|
|
8
9
|
export function catalogCmd(wrap) {
|
|
9
10
|
return {
|
|
@@ -11,6 +12,7 @@ export function catalogCmd(wrap) {
|
|
|
11
12
|
aliases: ['ci'],
|
|
12
13
|
describe: 'Manage Service Catalog items',
|
|
13
14
|
builder: (yargs) => {
|
|
15
|
+
for (const command of catalogOrderCommands(wrap)) yargs.command(command);
|
|
14
16
|
return yargs
|
|
15
17
|
.command({
|
|
16
18
|
command: 'create',
|
|
@@ -148,14 +150,10 @@ export function catalogCmd(wrap) {
|
|
|
148
150
|
}),
|
|
149
151
|
});
|
|
150
152
|
},
|
|
151
|
-
handler: () => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(' create Create a catalog item');
|
|
156
|
-
console.log(' list Browse catalog items');
|
|
157
|
-
console.log(' show <id> Show catalog item');
|
|
158
|
-
},
|
|
153
|
+
handler: wrap(async (argv, app) => app.ok({ subcommands: argv.subcommands || [],
|
|
154
|
+
_formatted: formatCommandList({ title: 'Service Catalog', commands: argv.subcommands || [] }) },
|
|
155
|
+
{ summary: 'Service Catalog: search, describe and order items; manage definitions with create/list/show',
|
|
156
|
+
breadcrumbs: [{ action: 'search', cmd: 'jsn catalogitems search <text>' + (argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : ''), description: 'Find requestable items' }] })),
|
|
159
157
|
};
|
|
160
158
|
}
|
|
161
159
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { assertSafeExactMatch, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const FIELDS = ['name', 'fqdn', 'ip_address', 'serial_number'];
|
|
5
|
+
const raw = v => v && typeof v === 'object' ? v.value : v;
|
|
6
|
+
|
|
7
|
+
export function cmdbSearchCmd(wrap) {
|
|
8
|
+
return { command: 'search <text>', describe: 'Find CIs by name, FQDN, IP address, or serial number',
|
|
9
|
+
builder: y => y.positional('text', { type: 'string', describe: 'Literal text; quote multiple words' })
|
|
10
|
+
.option('class', { type: 'string', describe: 'Exact CI class name (not descendants)' })
|
|
11
|
+
.option('limit', { type: 'number', default: 20, describe: 'Maximum CIs (1–100)' })
|
|
12
|
+
.option('offset', { type: 'number', default: 0, describe: 'Starting result offset' }),
|
|
13
|
+
handler: wrap(async (argv, app) => {
|
|
14
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty CI search text.');
|
|
15
|
+
const text = argv.text.trim();
|
|
16
|
+
assertSafeExactMatch(text);
|
|
17
|
+
if ([...text].some(c => c.codePointAt(0) < 32 || c.codePointAt(0) === 127) || /javascript:/i.test(text)) throw errUsage('Search text must not contain query scripts or control characters.');
|
|
18
|
+
if (argv.class !== undefined && !/^[a-z][a-z0-9_]*$/.test(argv.class)) throw errUsage('--class must be an exact CI table name.');
|
|
19
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
20
|
+
if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
21
|
+
// Repeat the class restriction in each OR branch: no branch may escape it.
|
|
22
|
+
const query = FIELDS.map(f => `${f}LIKE${text}${argv.class ? `^sys_class_name=${argv.class}` : ''}`).join('^NQ') + '^ORDERBYname^ORDERBYsys_id';
|
|
23
|
+
const params = new URLSearchParams({ sysparm_query: query, sysparm_limit: String(argv.limit + 1), sysparm_offset: String(argv.offset),
|
|
24
|
+
sysparm_fields: 'sys_id,name,sys_class_name,fqdn,ip_address,serial_number', sysparm_display_value: 'all' });
|
|
25
|
+
const rows = await app.sdk.listStrict('cmdb_ci', params);
|
|
26
|
+
if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw errAPI(200, 'CMDB search returned no valid record list.');
|
|
27
|
+
const hasMore = rows.length > argv.limit, records = rows.slice(0, argv.limit);
|
|
28
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
29
|
+
const breadcrumbs = records.flatMap(r => /^[a-f0-9]{32}$/i.test(raw(r.sys_id) || '') ? [
|
|
30
|
+
{ action: 'show', cmd: `jsn cmdb show ${raw(r.sys_id)}`, description: String(raw(r.name) || 'Inspect CI') },
|
|
31
|
+
{ action: 'relationships', cmd: `jsn cmdb relationships --ci ${raw(r.sys_id)}`, description: 'Inspect CI dependencies' },
|
|
32
|
+
] : []);
|
|
33
|
+
if (hasMore) breadcrumbs.push({ action: 'next', cmd: `jsn cmdb search ${quoteShellArg(text)} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${argv.class ? ` --class ${argv.class}` : ''}`, description: 'Next matching CIs' });
|
|
34
|
+
app.ok({ table: 'cmdb_ci', search: text, fields: FIELDS, records, count: records.length,
|
|
35
|
+
pagination: { limit: argv.limit, offset: argv.offset, has_more: hasMore } },
|
|
36
|
+
{ summary: `${records.length} matching CI(s)`, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
37
|
+
}) };
|
|
38
|
+
}
|
package/src/commands/cmdb.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveFieldsParam,
|
|
16
16
|
} from '../helpers.js';
|
|
17
17
|
import { errNotFound, errUsage } from '../errors.js';
|
|
18
|
+
import { cmdbSearchCmd } from './cmdb-search.js';
|
|
18
19
|
|
|
19
20
|
const SYS_ID_RE = /^[0-9a-f]{32}$/i;
|
|
20
21
|
const MAX_DEPTH = 5;
|
|
@@ -371,6 +372,7 @@ export function cmdbCmd(wrap) {
|
|
|
371
372
|
}),
|
|
372
373
|
builder: (yargs) => {
|
|
373
374
|
return yargs
|
|
375
|
+
.command(cmdbSearchCmd(wrap))
|
|
374
376
|
.command({
|
|
375
377
|
command: 'list',
|
|
376
378
|
describe: 'List CIs from the CMDB (cmdb_ci)',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import TurndownService from 'turndown';
|
|
2
|
+
import { formatCommandList, quoteShellArg } from '../helpers.js';
|
|
3
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
const API = '/api/sn_km_api/knowledge/articles';
|
|
6
|
+
const markdown = new TurndownService();
|
|
7
|
+
const sysId = /^[a-f0-9]{32}$/i;
|
|
8
|
+
const articleId = value => typeof value === 'string' && (sysId.test(value) || /^KB\d+$/i.test(value));
|
|
9
|
+
|
|
10
|
+
function emit(app, argv, data, summary, breadcrumbs = []) {
|
|
11
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
|
|
12
|
+
: argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
13
|
+
app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function read(sdk, path, params) {
|
|
17
|
+
const response = await sdk.request(`${sdk.baseURL}${API}${path}?${params}`, { method: 'GET' });
|
|
18
|
+
const result = response?.result;
|
|
19
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) throw errAPI(200, 'Malformed Knowledge API response.');
|
|
20
|
+
const code = result.meta?.status?.code;
|
|
21
|
+
if (code !== undefined && Number(code) !== 200) throw errAPI(Number(code) || 500, 'Knowledge API rejected the request.');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function knowledgeCmd(wrap) {
|
|
26
|
+
return {
|
|
27
|
+
command: 'knowledge [subcommand]', aliases: ['kb'],
|
|
28
|
+
describe: 'Search and read knowledge articles through the Knowledge API',
|
|
29
|
+
handler: wrap(async (argv, app) => emit(app, argv,
|
|
30
|
+
{ subcommands: argv.subcommands || [], _formatted: formatCommandList({ title: 'Knowledge articles', commands: argv.subcommands || [] }) },
|
|
31
|
+
'Search and read knowledge articles', [{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find accessible articles' }])),
|
|
32
|
+
builder: y => y
|
|
33
|
+
.epilogue('Requires the Knowledge API (sn_km_api). Uses its access checks; no fallback to direct knowledge-table reads. Reading does not request view-count updates.')
|
|
34
|
+
.command({ command: 'search <text>', describe: 'Search accessible knowledge articles',
|
|
35
|
+
builder: p => p.positional('text', { type: 'string', describe: 'Search text; quote multiple words' })
|
|
36
|
+
.option('limit', { type: 'number', default: 20, describe: 'Maximum articles (1–100)' })
|
|
37
|
+
.option('offset', { type: 'number', default: 0, describe: 'Starting result offset' })
|
|
38
|
+
.option('kb', { type: 'string', describe: 'Comma-separated knowledge-base sys_ids' })
|
|
39
|
+
.option('language', { type: 'string', describe: 'Two-letter language codes, comma-separated, or all' }),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
41
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty search text.');
|
|
42
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
43
|
+
if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
44
|
+
if (argv.kb !== undefined && !String(argv.kb).split(',').every(id => sysId.test(id))) throw errUsage('--kb requires knowledge-base sys_ids.');
|
|
45
|
+
if (argv.language !== undefined && !/^(all|[a-z]{2}(,[a-z]{2})*)$/i.test(argv.language)) throw errUsage('Use two-letter --language codes or all.');
|
|
46
|
+
const params = new URLSearchParams({ query: argv.text.trim(), limit: String(argv.limit), offset: String(argv.offset) });
|
|
47
|
+
if (argv.kb) params.set('kb', argv.kb);
|
|
48
|
+
if (argv.language) params.set('language', argv.language);
|
|
49
|
+
const result = await read(app.sdk, '', params);
|
|
50
|
+
if (!Array.isArray(result.articles) || result.articles.some(a => !a || typeof a !== 'object' || Array.isArray(a))) throw errAPI(200, 'Knowledge API returned no valid article list.');
|
|
51
|
+
const breadcrumbs = result.articles.flatMap(a => {
|
|
52
|
+
const id = articleId(a.number) ? a.number : String(a.id || '').replace(/^kb_knowledge:/, '');
|
|
53
|
+
return articleId(id) ? [{ action: 'show', cmd: `jsn knowledge show ${id}`, description: a.title || id }] : [];
|
|
54
|
+
});
|
|
55
|
+
const base = `jsn knowledge search ${quoteShellArg(argv.text.trim())} --limit ${argv.limit}`
|
|
56
|
+
+ (argv.kb ? ` --kb ${quoteShellArg(argv.kb)}` : '') + (argv.language ? ` --language ${quoteShellArg(argv.language)}` : '');
|
|
57
|
+
const total = result.meta?.count;
|
|
58
|
+
const hasMore = typeof total === 'number' ? argv.offset + result.articles.length < total : result.articles.length === argv.limit;
|
|
59
|
+
if (hasMore) breadcrumbs.push({ action: 'next', cmd: `${base} --offset ${argv.offset + argv.limit}`, description: 'Next result page' });
|
|
60
|
+
emit(app, argv, { ...result, count: result.articles.length,
|
|
61
|
+
_formatted: result.articles.map(a => `${a.number || a.id || ''} ${a.title || ''}\n${a.snippet || ''}`).join('\n\n') || 'No accessible articles matched.' },
|
|
62
|
+
`${result.articles.length} knowledge article(s)`, breadcrumbs);
|
|
63
|
+
}) })
|
|
64
|
+
.command({ command: 'show <id>', describe: 'Read an article by KB number or sys_id',
|
|
65
|
+
builder: p => p.positional('id', { type: 'string' }),
|
|
66
|
+
handler: wrap(async (argv, app) => {
|
|
67
|
+
if (!articleId(argv.id)) throw errUsage('Use a KB number or full article sys_id.');
|
|
68
|
+
const article = await read(app.sdk, `/${encodeURIComponent(argv.id)}`, new URLSearchParams());
|
|
69
|
+
if (!sysId.test(article.sys_id || '') || typeof article.content !== 'string') throw errAPI(200, 'Knowledge API returned no readable article content.');
|
|
70
|
+
emit(app, argv, { ...article, _formatted: `${article.number || argv.id}: ${article.short_description || ''}\n\n${markdown.turndown(article.content)}` },
|
|
71
|
+
article.short_description || article.number || argv.id,
|
|
72
|
+
[{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find more articles' }]);
|
|
73
|
+
}) }),
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/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' },
|