@jacebenson/jsn 4.3.13-dev.102.1 → 4.3.13-dev.105.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
@@ -23,8 +23,12 @@ npm install -g @jacebenson/jsn@dev
23
23
 
24
24
  Dev builds check the dev channel for updates during normal use, at most once
25
25
  per day. Run `jsn version --check` to check immediately. To install the newest
26
- dev build, run `npm install -g @jacebenson/jsn@dev` again. Updates are manual.
27
- Return to stable with `npm install -g @jacebenson/jsn@latest`.
26
+ dev build, run `jsn upgrade` (alias: `jsn update`). It preserves your installed
27
+ channel and skips installation when no newer version is available. Use
28
+ `--dry-run` to print the npm command without network access or installation,
29
+ `--force` to reinstall, or `--channel dev|latest` to switch channels explicitly.
30
+ For example, `jsn upgrade --channel latest` returns to stable. npm installation
31
+ permissions still apply; the command does not elevate privileges automatically.
28
32
 
29
33
  Pushes to `main` promote the commit to `dev` and publish a tested prerelease
30
34
  under npm's `dev` tag. Versions include the workflow run number and attempt
@@ -83,6 +87,15 @@ an explicitly selected profile or instance. `updatesets show` also offers a scan
83
87
  hint. Recommendation/generation of new checks is not part of this command;
84
88
  JSN contains no AI and does not infer missing business requirements.
85
89
 
90
+ ### CMDB search
91
+
92
+ `jsn cmdb search "web" --profile work` searches names, FQDNs, IP addresses and
93
+ serial numbers on `cmdb_ci`. Use `--class cmdb_ci_server` for an exact class
94
+ match (excluding subclasses), and bounded `--limit`/`--offset` pagination.
95
+ Search text is literal; encoded-query operators and query scripts are rejected.
96
+ Existing `cmdb list --query` remains available for deliberate encoded queries.
97
+ Results include show/relationship hints and preserve the selected connection.
98
+
86
99
  ### Supported versions
87
100
 
88
101
  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.105.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
@@ -39,6 +39,7 @@ import { transactionsCmd } from './commands/transactions.js';
39
39
  import { platformCmd } from './commands/platform.js';
40
40
  import { codesearchCmd } from './commands/codesearch.js';
41
41
  import { versionCmd } from './commands/version.js';
42
+ import { upgradeCmd } from './commands/upgrade.js';
42
43
  import { skillCmd, checkSkill } from './commands/skill.js';
43
44
  import { getVersion, checkLatest, getReleaseChannel, getInstallCommand } from './commands/version.js';
44
45
  import { docsCmd } from './commands/docs/docs.js';
@@ -438,6 +439,7 @@ export function buildCLI() {
438
439
  .command(skillCmd(wrap))
439
440
  .command(docsCmd(wrap))
440
441
  .command(versionCmd(wrap))
442
+ .command(upgradeCmd(wrap))
441
443
  .command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
442
444
  .demandCommand(1, 'You must specify a command')
443
445
  // Completion plumbing under a hidden command name, so the explicit
@@ -4,7 +4,8 @@ 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
- 'version', 'docs', 'history', 'discover', 'completion',
7
+ 'cmdb',
8
+ 'version', 'upgrade', 'docs', 'history', 'discover', 'completion',
8
9
  'catalogitems',
9
10
  ]);
10
11
 
@@ -0,0 +1,38 @@
1
+ import { assertSafeExactMatch, quoteShellArg } from '../helpers.js';
2
+ import { errAPI, errUsage } from '../errors.js';
3
+
4
+ const FIELDS = ['name', 'fqdn', 'ip_address', 'serial_number'];
5
+ const raw = v => v && typeof v === 'object' ? v.value : v;
6
+
7
+ export function cmdbSearchCmd(wrap) {
8
+ return { command: 'search <text>', describe: 'Find CIs by name, FQDN, IP address, or serial number',
9
+ builder: y => y.positional('text', { type: 'string', describe: 'Literal text; quote multiple words' })
10
+ .option('class', { type: 'string', describe: 'Exact CI class name (not descendants)' })
11
+ .option('limit', { type: 'number', default: 20, describe: 'Maximum CIs (1–100)' })
12
+ .option('offset', { type: 'number', default: 0, describe: 'Starting result offset' }),
13
+ handler: wrap(async (argv, app) => {
14
+ if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty CI search text.');
15
+ const text = argv.text.trim();
16
+ assertSafeExactMatch(text);
17
+ if ([...text].some(c => c.codePointAt(0) < 32 || c.codePointAt(0) === 127) || /javascript:/i.test(text)) throw errUsage('Search text must not contain query scripts or control characters.');
18
+ if (argv.class !== undefined && !/^[a-z][a-z0-9_]*$/.test(argv.class)) throw errUsage('--class must be an exact CI table name.');
19
+ if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
20
+ if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
21
+ // Repeat the class restriction in each OR branch: no branch may escape it.
22
+ const query = FIELDS.map(f => `${f}LIKE${text}${argv.class ? `^sys_class_name=${argv.class}` : ''}`).join('^NQ') + '^ORDERBYname^ORDERBYsys_id';
23
+ const params = new URLSearchParams({ sysparm_query: query, sysparm_limit: String(argv.limit + 1), sysparm_offset: String(argv.offset),
24
+ sysparm_fields: 'sys_id,name,sys_class_name,fqdn,ip_address,serial_number', sysparm_display_value: 'all' });
25
+ const rows = await app.sdk.listStrict('cmdb_ci', params);
26
+ if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw errAPI(200, 'CMDB search returned no valid record list.');
27
+ const hasMore = rows.length > argv.limit, records = rows.slice(0, argv.limit);
28
+ const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
29
+ const breadcrumbs = records.flatMap(r => /^[a-f0-9]{32}$/i.test(raw(r.sys_id) || '') ? [
30
+ { action: 'show', cmd: `jsn cmdb show ${raw(r.sys_id)}`, description: String(raw(r.name) || 'Inspect CI') },
31
+ { action: 'relationships', cmd: `jsn cmdb relationships --ci ${raw(r.sys_id)}`, description: 'Inspect CI dependencies' },
32
+ ] : []);
33
+ if (hasMore) breadcrumbs.push({ action: 'next', cmd: `jsn cmdb search ${quoteShellArg(text)} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${argv.class ? ` --class ${argv.class}` : ''}`, description: 'Next matching CIs' });
34
+ app.ok({ table: 'cmdb_ci', search: text, fields: FIELDS, records, count: records.length,
35
+ pagination: { limit: argv.limit, offset: argv.offset, has_more: hasMore } },
36
+ { summary: `${records.length} matching CI(s)`, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
37
+ }) };
38
+ }
@@ -15,6 +15,7 @@ import {
15
15
  resolveFieldsParam,
16
16
  } from '../helpers.js';
17
17
  import { errNotFound, errUsage } from '../errors.js';
18
+ import { cmdbSearchCmd } from './cmdb-search.js';
18
19
 
19
20
  const SYS_ID_RE = /^[0-9a-f]{32}$/i;
20
21
  const MAX_DEPTH = 5;
@@ -371,6 +372,7 @@ export function cmdbCmd(wrap) {
371
372
  }),
372
373
  builder: (yargs) => {
373
374
  return yargs
375
+ .command(cmdbSearchCmd(wrap))
374
376
  .command({
375
377
  command: 'list',
376
378
  describe: 'List CIs from the CMDB (cmdb_ci)',
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { declareCapabilities } from '../capabilities.js';
3
+ import { AppError, CodeAPI, CodeNetwork } from '../errors.js';
4
+ import { checkLatest, getReleaseChannel, getVersion } from './version.js';
5
+
6
+ for (const name of ['upgrade', 'update']) {
7
+ declareCapabilities(name, { noInstance: true, skipDailyChecks: true });
8
+ }
9
+
10
+ function installPackage(args, { structured }) {
11
+ const result = spawnSync('npm', args, { stdio: structured ? 'pipe' : 'inherit', encoding: 'utf8' });
12
+ if (result.error || result.status !== 0) {
13
+ throw new AppError(CodeAPI, 'npm could not upgrade jsn.', 'Check npm installation permissions and connectivity, then retry.');
14
+ }
15
+ }
16
+
17
+ function isNewer(candidate, installed) {
18
+ const parse = (value) => {
19
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
20
+ if (!match) throw new AppError(CodeAPI, 'The registry returned an invalid package version.');
21
+ return { core: match.slice(1, 4).map(Number), pre: match[4]?.split('.') };
22
+ };
23
+ const a = parse(candidate), b = parse(installed);
24
+ for (let i = 0; i < 3; i++) {
25
+ if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i];
26
+ }
27
+ if (!a.pre || !b.pre) return !a.pre && Boolean(b.pre);
28
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
29
+ if (a.pre[i] === b.pre[i]) continue;
30
+ if (a.pre[i] === undefined) return false;
31
+ if (b.pre[i] === undefined) return true;
32
+ const an = /^\d+$/.test(a.pre[i]), bn = /^\d+$/.test(b.pre[i]);
33
+ if (an && bn) return Number(a.pre[i]) > Number(b.pre[i]);
34
+ if (an !== bn) return !an;
35
+ return a.pre[i] > b.pre[i];
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export function upgradeCmd(wrap, { version = getVersion, check = checkLatest, install = installPackage } = {}) {
41
+ return {
42
+ command: 'upgrade',
43
+ aliases: ['update'],
44
+ describe: 'Upgrade jsn through npm (preserves the installed release channel)',
45
+ builder: (y) => y
46
+ .option('channel', { type: 'string', choices: ['dev', 'latest'], describe: 'Choose the npm release channel' })
47
+ .option('dry-run', { type: 'boolean', describe: 'Show the install command without installing' })
48
+ .option('force', { type: 'boolean', describe: 'Reinstall even when the selected version is not newer' }),
49
+ handler: wrap(async (argv, app) => {
50
+ const current = version();
51
+ const channel = argv.channel || getReleaseChannel(current);
52
+ const args = ['install', '-g', `@jacebenson/jsn@${channel}`];
53
+ const command = `npm ${args.join(' ')}`;
54
+ if (argv.dryRun || argv['dry-run']) {
55
+ app.ok({ version: current, channel, dryRun: true, installCmd: command }, { summary: command });
56
+ return;
57
+ }
58
+ const latest = await check({ version: current, channel });
59
+ if (!latest) throw new AppError(CodeNetwork, 'Could not check the npm release channel.', 'Check connectivity and retry; use --dry-run to inspect the command.');
60
+ const switching = argv.channel && channel !== getReleaseChannel(current);
61
+ if (!argv.force && !switching && !isNewer(latest, current)) {
62
+ app.ok({ version: current, latest, channel, upToDate: true }, { summary: `jsn ${current} — no newer version available` });
63
+ return;
64
+ }
65
+ await install(args, { structured: Boolean(!process.stdout.isTTY || argv.json || argv.get || argv.format === 'json') });
66
+ app.ok({ previousVersion: current, version: latest, channel, upgraded: true }, { summary: `Installed jsn from the ${channel} channel (registry version ${latest})` });
67
+ }),
68
+ };
69
+ }
@@ -32,13 +32,13 @@ export function cancelBackgroundChecks() {
32
32
  backgroundChecks.clear();
33
33
  }
34
34
 
35
- export async function checkLatest({ timeout = 1500, background = false, version = getVersion() } = {}) {
35
+ export async function checkLatest({ timeout = 1500, background = false, version = getVersion(), channel = getReleaseChannel(version) } = {}) {
36
36
  const controller = new AbortController();
37
37
  const timer = setTimeout(() => controller.abort(), timeout);
38
38
  timer.unref();
39
39
  if (background) backgroundChecks.add(controller);
40
40
  try {
41
- const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${getReleaseChannel(version)}`, { signal: controller.signal });
41
+ const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${channel}`, { signal: controller.signal });
42
42
  if (!res.ok) return null;
43
43
  const data = await res.json();
44
44
  return typeof data.version === 'string' ? data.version : null;