@jacebenson/jsn 4.3.13-dev.101.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,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.
@@ -132,3 +143,26 @@ This applies to root help, root JSON, `discover`, and root shell completion.
132
143
  Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
133
144
  every registered command. Explicit command paths remain available and ServiceNow
134
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.101.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": {
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))
@@ -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
+ }
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' },