@jacebenson/jsn 4.3.13-dev.102.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 CHANGED
@@ -83,6 +83,15 @@ an explicitly selected profile or instance. `updatesets show` also offers a scan
83
83
  hint. Recommendation/generation of new checks is not part of this command;
84
84
  JSN contains no AI and does not infer missing business requirements.
85
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
+
86
95
  ### Supported versions
87
96
 
88
97
  Only the current release and the two preceding releases in each channel are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.102.1",
3
+ "version": "4.3.13-dev.104.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": {
@@ -4,6 +4,7 @@ const BASIC_COMMANDS = new Set([
4
4
  'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
5
5
  'knowledge',
6
6
  'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
7
+ 'cmdb',
7
8
  'version', 'docs', 'history', 'discover', 'completion',
8
9
  'catalogitems',
9
10
  ]);
@@ -0,0 +1,38 @@
1
+ import { assertSafeExactMatch, quoteShellArg } from '../helpers.js';
2
+ import { errAPI, errUsage } from '../errors.js';
3
+
4
+ const FIELDS = ['name', 'fqdn', 'ip_address', 'serial_number'];
5
+ const raw = v => v && typeof v === 'object' ? v.value : v;
6
+
7
+ export function cmdbSearchCmd(wrap) {
8
+ return { command: 'search <text>', describe: 'Find CIs by name, FQDN, IP address, or serial number',
9
+ builder: y => y.positional('text', { type: 'string', describe: 'Literal text; quote multiple words' })
10
+ .option('class', { type: 'string', describe: 'Exact CI class name (not descendants)' })
11
+ .option('limit', { type: 'number', default: 20, describe: 'Maximum CIs (1–100)' })
12
+ .option('offset', { type: 'number', default: 0, describe: 'Starting result offset' }),
13
+ handler: wrap(async (argv, app) => {
14
+ if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty CI search text.');
15
+ const text = argv.text.trim();
16
+ assertSafeExactMatch(text);
17
+ if ([...text].some(c => c.codePointAt(0) < 32 || c.codePointAt(0) === 127) || /javascript:/i.test(text)) throw errUsage('Search text must not contain query scripts or control characters.');
18
+ if (argv.class !== undefined && !/^[a-z][a-z0-9_]*$/.test(argv.class)) throw errUsage('--class must be an exact CI table name.');
19
+ if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
20
+ if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
21
+ // Repeat the class restriction in each OR branch: no branch may escape it.
22
+ const query = FIELDS.map(f => `${f}LIKE${text}${argv.class ? `^sys_class_name=${argv.class}` : ''}`).join('^NQ') + '^ORDERBYname^ORDERBYsys_id';
23
+ const params = new URLSearchParams({ sysparm_query: query, sysparm_limit: String(argv.limit + 1), sysparm_offset: String(argv.offset),
24
+ sysparm_fields: 'sys_id,name,sys_class_name,fqdn,ip_address,serial_number', sysparm_display_value: 'all' });
25
+ const rows = await app.sdk.listStrict('cmdb_ci', params);
26
+ if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw errAPI(200, 'CMDB search returned no valid record list.');
27
+ const hasMore = rows.length > argv.limit, records = rows.slice(0, argv.limit);
28
+ const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
29
+ const breadcrumbs = records.flatMap(r => /^[a-f0-9]{32}$/i.test(raw(r.sys_id) || '') ? [
30
+ { action: 'show', cmd: `jsn cmdb show ${raw(r.sys_id)}`, description: String(raw(r.name) || 'Inspect CI') },
31
+ { action: 'relationships', cmd: `jsn cmdb relationships --ci ${raw(r.sys_id)}`, description: 'Inspect CI dependencies' },
32
+ ] : []);
33
+ if (hasMore) breadcrumbs.push({ action: 'next', cmd: `jsn cmdb search ${quoteShellArg(text)} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${argv.class ? ` --class ${argv.class}` : ''}`, description: 'Next matching CIs' });
34
+ app.ok({ table: 'cmdb_ci', search: text, fields: FIELDS, records, count: records.length,
35
+ pagination: { limit: argv.limit, offset: argv.offset, has_more: hasMore } },
36
+ { summary: `${records.length} matching CI(s)`, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
37
+ }) };
38
+ }
@@ -15,6 +15,7 @@ import {
15
15
  resolveFieldsParam,
16
16
  } from '../helpers.js';
17
17
  import { errNotFound, errUsage } from '../errors.js';
18
+ import { cmdbSearchCmd } from './cmdb-search.js';
18
19
 
19
20
  const SYS_ID_RE = /^[0-9a-f]{32}$/i;
20
21
  const MAX_DEPTH = 5;
@@ -371,6 +372,7 @@ export function cmdbCmd(wrap) {
371
372
  }),
372
373
  builder: (yargs) => {
373
374
  return yargs
375
+ .command(cmdbSearchCmd(wrap))
374
376
  .command({
375
377
  command: 'list',
376
378
  describe: 'List CIs from the CMDB (cmdb_ci)',