@jacebenson/jsn 4.0.4 → 4.2.0

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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -92,6 +92,103 @@ export function logsCmd(wrap) {
92
92
  const level = getStringField(record, 'level') || '?';
93
93
  app.ok(record, { summary: `${logIcon(level)} ${level}: ${(getStringField(record, 'message') || '').substring(0, 80)}` });
94
94
  }),
95
+ })
96
+ .command({
97
+ command: 'follow',
98
+ describe: 'Tail system logs in real time (Ctrl+C to stop)',
99
+ builder: (y) => y
100
+ .option('query', { type: 'string', describe: 'Additional encoded query filter (e.g. "sourceLIKEscript")' })
101
+ .option('level', { type: 'string', describe: 'Filter by level (error, warning, information)' })
102
+ .option('source', { type: 'string', describe: 'Filter by source' })
103
+ .option('tail', { type: 'number', default: 0, describe: 'Show the last N existing entries first, then follow' })
104
+ .option('interval', { type: 'number', default: 2000, describe: 'Poll interval in ms (min 500)' }),
105
+ handler: wrap(async (argv, app) => {
106
+ app.requireInstance();
107
+ const filters = [argv.query || ''];
108
+ if (argv.level) filters.push(`level=${argv.level}`);
109
+ if (argv.source) filters.push(`sourceLIKE${argv.source}`);
110
+ const base = filters.filter(Boolean).join('^');
111
+ const intervalMs = Math.max(500, argv.interval || 2000);
112
+ let watermark = '';
113
+ const seen = new Set();
114
+
115
+ // sys_created_on> comparisons run in UTC; the display_value is server-local
116
+ // time, so a display-based watermark never advances on a non-UTC instance.
117
+ // Normalize the raw UTC .value to 'YYYY-MM-DD HH:mm:ss' for the query and
118
+ // lexicographic comparison. The sys_id seen-set guarantees a row can never
119
+ // re-print even if two polls fall in the same timestamp bucket.
120
+ const toQueryTs = (v) => {
121
+ if (typeof v !== 'string') return v;
122
+ return v.replace('T', ' ').replace(/\.\d{1,3}[Zz]?$/, '').replace(/[Zz]$/, '');
123
+ };
124
+ const rawTs = (r) => {
125
+ const f = r.sys_created_on;
126
+ if (f && typeof f === 'object') {
127
+ const v = f.value != null ? f.value : f.display_value;
128
+ return v ? toQueryTs(String(v)) : '';
129
+ }
130
+ return f ? toQueryTs(String(f)) : '';
131
+ };
132
+ const sysIdOf = (r) => getStringField(r, 'sys_id');
133
+
134
+ const formatRow = (r) => {
135
+ const ts = getStringField(r, 'sys_created_on');
136
+ const level = getStringField(r, 'level');
137
+ const src = getStringField(r, 'source');
138
+ const msg = getStringField(r, 'message');
139
+ return `${ts} ${logIcon(level).padEnd(2)} [${src}] ${msg}`;
140
+ };
141
+
142
+ const emit = (r) => {
143
+ const id = sysIdOf(r);
144
+ if (id) {
145
+ if (seen.has(id)) return; // never re-print a row this run
146
+ seen.add(id);
147
+ }
148
+ const ts = rawTs(r);
149
+ if (ts && !watermark) watermark = ts;
150
+ else if (ts && ts > watermark) watermark = ts;
151
+ process.stdout.write(formatRow(r) + '\n');
152
+ };
153
+
154
+ const fetchNew = async () => {
155
+ const params = new URLSearchParams();
156
+ params.set('sysparm_display_value', 'all');
157
+ params.set('sysparm_fields', 'sys_created_on,level,source,message,sys_id');
158
+ params.set('sysparm_limit', '100');
159
+ let q = watermark ? `sys_created_on>${watermark}^ORDERBYsys_created_on` : 'ORDERBYsys_created_on';
160
+ if (base) q = `${base}^${q}`;
161
+ params.set('sysparm_query', q);
162
+ let records;
163
+ try { records = await app.sdk.list('syslog', params); } catch (err) {
164
+ process.stderr.write(`⚠ poll error: ${err.message}\n`);
165
+ return;
166
+ }
167
+ for (const r of records) emit(r);
168
+ };
169
+
170
+ // Optional backfill: show last N newest-first, then follow.
171
+ if (argv.tail > 0) {
172
+ const params = new URLSearchParams();
173
+ params.set('sysparm_display_value', 'all');
174
+ params.set('sysparm_fields', 'sys_created_on,level,source,message,sys_id');
175
+ let q = base ? `${base}^ORDERBYDESCsys_created_on` : 'ORDERBYDESCsys_created_on';
176
+ params.set('sysparm_query', q);
177
+ params.set('sysparm_limit', String(argv.tail));
178
+ try {
179
+ const recent = await app.sdk.list('syslog', params);
180
+ const ordered = [...recent].reverse(); // oldest->newest on screen
181
+ for (const r of ordered) emit(r);
182
+ } catch (err) { process.stderr.write(`⚠ backfill error: ${err.message}\n`); }
183
+ }
184
+
185
+ await fetchNew();
186
+ const timer = setInterval(fetchNew, intervalMs);
187
+ process.stdout.write(`\n[jsn logs follow] watching syslog every ${intervalMs}ms — Ctrl+C to stop\n`);
188
+ const stop = () => { clearInterval(timer); process.exit(0); };
189
+ process.on('SIGINT', stop);
190
+ process.on('SIGTERM', stop);
191
+ }),
95
192
  });
96
193
  },
97
194
  handler: () => {
@@ -1,3 +1,9 @@
1
+ import { declareCapabilities } from '../../capabilities.js';
2
+
3
+ // Raw REST passthrough — can hit any endpoint with any method, so the whole
4
+ // command is a mutation surface on read-only profiles (both spellings).
5
+ declareCapabilities('rest', { mutationSubcommands: [''], devAlias: true });
6
+
1
7
  export function restCmd(wrap) {
2
8
  return {
3
9
  command: 'rest [endpoint]',
@@ -5,6 +5,7 @@ export const restmessageCmd = (wrap) => buildDevCmd('restmessage', 'sys_rest_mes
5
5
  ['name', 'endpoint', 'active', 'sys_scope'], wrap, {
6
6
  singular: 'REST message',
7
7
  scopeValidation: true,
8
+ devAlias: false,
8
9
  onShow: async (record, app) => {
9
10
  const sysID = getStringField(record, 'sys_id') || '';
10
11
  if (!sysID) return;
@@ -1,5 +1,50 @@
1
1
  import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
2
2
  import { requireCurrentUserSysId, setCurrentApplication } from '../../context.js';
3
+ import { declareCapabilities } from '../../capabilities.js';
4
+ import { unwrapSysId } from '../../resolve-record.js';
5
+
6
+ declareCapabilities('scopes', { mutationSubcommands: ['create', 'set'], devAlias: true });
7
+
8
+ /**
9
+ * Resolve a scope identifier to a single sys_scope record.
10
+ *
11
+ * Precedence (issue #190):
12
+ * 1. sys_id — tried first so explicit selectors win. This catches both the
13
+ * 32-hex form AND the canonical Global record, whose sys_id is the
14
+ * literal string 'global' (not hex, so isSysId() alone would miss it).
15
+ * 2. scope value — e.g. 'global', 'x_417611_daves_o_0'. When several rows
16
+ * share the value (a third-party app can also report scope=global), the
17
+ * canonical Global record (sys_id='global') is preferred; any other tie
18
+ * is an ambiguous error naming the candidates.
19
+ * 3. name — the display name, last resort.
20
+ *
21
+ * @param {object} sdk — SDK handle (uses sdk.list)
22
+ * @param {string} identifier — sys_id, scope value, or name
23
+ * @returns {Promise<object>} the sys_scope record (display values requested)
24
+ * @throws {Error} 'Scope not found: <id>' when nothing matches, or an
25
+ * 'Ambiguous scope' error listing candidate sys_ids on a non-global tie.
26
+ */
27
+ export async function resolveScope(sdk, identifier) {
28
+ assertSafeExactMatch(identifier);
29
+ for (const queryField of ['sys_id', 'scope', 'name']) {
30
+ const params = new URLSearchParams();
31
+ params.set('sysparm_query', `${queryField}=${identifier}`);
32
+ params.set('sysparm_display_value', 'all');
33
+ params.set('sysparm_fields', 'sys_id,scope,name');
34
+ const records = await sdk.list('sys_scope', params);
35
+ if (records.length === 0) continue;
36
+ if (records.length === 1) return records[0];
37
+ // Multiple matches on one field. Only the 'scope' value can legitimately
38
+ // collide (sys_id and name are unique). Prefer the canonical Global
39
+ // record when 'global' is in play; otherwise force an explicit choice.
40
+ const canonical = records.find(r => unwrapSysId(r) === 'global');
41
+ if (queryField === 'scope' && canonical) return canonical;
42
+ const candidates = records.map(r => `${unwrapSysId(r)} (${getStringField(r, 'name') || 'unnamed'})`).join(', ');
43
+ throw new Error(`Ambiguous scope '${identifier}' matches ${records.length} records: ${candidates}. Re-run with an explicit sys_id.`);
44
+ }
45
+ throw new Error(`Scope not found: ${identifier}`);
46
+ }
47
+
3
48
 
4
49
  /** Format a picker label for a scope: "Name [scope]". */
5
50
  export function formatScopeLabel(r) {
@@ -117,21 +162,8 @@ export function scopesCmd(wrap) {
117
162
  type: 'string',
118
163
  }),
119
164
  handler: wrap(async (argv, app) => {
120
- assertSafeExactMatch(argv.scope);
121
- let records;
122
- // Try by scope value first, then by name
123
- for (const queryField of ['scope', 'name']) {
124
- const params = new URLSearchParams();
125
- params.set('sysparm_query', `${queryField}=${argv.scope}`);
126
- params.set('sysparm_limit', '1');
127
- params.set('sysparm_display_value', 'all');
128
- records = await app.sdk.list('sys_scope', params);
129
- if (records.length > 0) break;
130
- }
131
- if (!records || records.length === 0) {
132
- throw new Error(`Scope not found: ${argv.scope}`);
133
- }
134
- const detail = await formatScopeDetail(app, records[0]);
165
+ const record = await resolveScope(app.sdk, argv.scope);
166
+ const detail = await formatScopeDetail(app, record);
135
167
  app.ok(detail, { summary: `Scope ${argv.scope}` });
136
168
  }),
137
169
  })
@@ -148,16 +180,8 @@ export function scopesCmd(wrap) {
148
180
  if (!picked) return; // cancelled or non-interactive
149
181
  scopeArg = getStringField(picked, 'scope');
150
182
  }
151
- assertSafeExactMatch(scopeArg);
152
- const params = new URLSearchParams();
153
- params.set('sysparm_query', `scope=${scopeArg}`);
154
- params.set('sysparm_limit', '1');
155
- params.set('sysparm_fields', 'sys_id,scope,name');
156
- const records = await app.sdk.list('sys_scope', params);
157
- if (records.length === 0) {
158
- throw new Error(`Scope not found: ${scopeArg}`);
159
- }
160
- const scopeSysID = getStringField(records[0], 'sys_id');
183
+ const record = await resolveScope(app.sdk, scopeArg);
184
+ const scopeSysID = unwrapSysId(record);
161
185
  const userSysID = await requireCurrentUserSysId(app.sdk);
162
186
  await setCurrentApplication(app.sdk, userSysID, scopeSysID);
163
187
  app.ok({ scope: scopeArg, sys_id: scopeSysID }, { summary: `Current scope: ${scopeArg}` });
@@ -10,6 +10,11 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { formatRecordForDisplay, getStringField, isHexString } from '../../helpers.js';
12
12
  import { errUsageHint } from '../../errors.js';
13
+ import { declareCapabilities } from '../../capabilities.js';
14
+
15
+ // `scrapi create` builds a full Scripted REST API in one call (list/show are
16
+ // reads). Registered at root and under `jsn dev`.
17
+ declareCapabilities('scrapi', { mutationSubcommands: ['create'], devAlias: true });
13
18
 
14
19
  /**
15
20
  * Parse a --resource flag value of the form:
@@ -5,6 +5,7 @@ export const soapmessagesCmd = (wrap) => buildDevCmd('soapmessages', 'sys_soap_m
5
5
  ['name', 'active', 'sys_scope'], wrap, {
6
6
  singular: 'SOAP message',
7
7
  scopeValidation: true,
8
+ devAlias: false,
8
9
  onShow: async (record, app) => {
9
10
  const sysID = getStringField(record, 'sys_id') || '';
10
11
  if (!sysID) return;
@@ -1,6 +1,13 @@
1
+ import fs from 'node:fs';
1
2
  import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
2
3
  import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../../context.js';
3
- import { errUsage } from '../../errors.js';
4
+ import { errAPI, errUsage } from '../../errors.js';
5
+ import { declareCapabilities } from '../../capabilities.js';
6
+
7
+ declareCapabilities('updatesets', {
8
+ mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent'],
9
+ devAlias: true,
10
+ });
4
11
 
5
12
  /**
6
13
  * Scope-mismatch check for an update set vs the current app.
@@ -48,21 +55,38 @@ export async function resolveUpdateSetByName(app, name) {
48
55
  if (records.length === 1) return records[0];
49
56
 
50
57
  // Multiple sets share the name — prefer the one in the current scope.
58
+ let scopeNote = '';
51
59
  try {
52
60
  const userSysID = await requireCurrentUserSysId(app.sdk);
53
61
  const currentApp = await getCurrentApplication(app.sdk, userSysID);
54
62
  const appIdOf = (r) => r.application?.value || (typeof r.application === 'string' ? r.application : '');
55
- const inScope = records.find((r) => {
63
+ const inScope = currentApp.appSysId ? records.find((r) => {
56
64
  const appId = appIdOf(r);
57
- return appId && currentApp.appSysId && appId === currentApp.appSysId;
58
- });
65
+ return appId && appId === currentApp.appSysId;
66
+ }) : undefined;
59
67
  if (inScope) return inScope;
60
- } catch {
61
- // fall through to ambiguity error
68
+
69
+ // OAuth/service accounts often lack the apps.current_app user preference,
70
+ // so getCurrentApplication yields an empty appSysId and nothing matches.
71
+ // Fall back to the user's CURRENT update set sys_id, then a unique
72
+ // Global-scope match.
73
+ let current;
74
+ try { current = await getCurrentUpdateSet(app.sdk, userSysID); } catch { current = null; }
75
+ if (current?.sys_id) {
76
+ const byId = records.find((r) => getStringField(r, 'sys_id') === current.sys_id);
77
+ if (byId) return byId;
78
+ }
79
+ const globalOnes = records.filter((r) => appIdOf(r) === 'global');
80
+ if (globalOnes.length === 1) return globalOnes[0];
81
+ if (!currentApp.appSysId) {
82
+ scopeNote = ' (no apps.current_app preference — resolved nothing by scope)';
83
+ }
84
+ } catch (err) {
85
+ scopeNote = ` (couldn't resolve scope: ${err.message})`;
62
86
  }
63
87
 
64
88
  const scopes = records.map((r) => r.application?.display_value || r.application || '?').join(', ');
65
- throw errUsage(`Multiple update sets named "${name}" (${records.length}): ${scopes}.\nRun bare "jsn updatesets set" and pick by scope.`);
89
+ throw errUsage(`Multiple update sets named "${name}" (${records.length}): ${scopes}.${scopeNote}\nRun bare "jsn updatesets set" and pick by scope.`);
66
90
  }
67
91
 
68
92
  /**
@@ -101,16 +125,47 @@ export async function formatUpdateSetDetail(app, record) {
101
125
  const created = getStringField(full, 'sys_created_on') || '';
102
126
  const updated = getStringField(full, 'sys_updated_on') || '';
103
127
 
104
- // Child updates: filename only, newest first by sys_updated_on
128
+ // Child updates: filename only, newest first by sys_updated_on.
129
+ // Also group by element type (sys_class_name) and flag risky types for
130
+ // pre-promotion review (update-set members that can clobber prod).
131
+ const RISKY_TYPES = new Set([
132
+ 'sys_script', // business rules
133
+ 'sys_script_include', // script includes
134
+ 'sys_script_client', // client scripts
135
+ 'sys_security_acl', // ACLs / access controls
136
+ 'sys_ui_action', // UI actions
137
+ 'sys_process_flow', // flows
138
+ 'sys_flow', // flow definitions
139
+ 'sys_hub_flow', // flow definitions (flow-designer)
140
+ 'sys_script_trigger', // workflow/script triggers
141
+ 'sys_script_email_event',// email notifications/actions
142
+ 'sys_script_queue', // scheduled jobs
143
+ 'sys_script_widget',
144
+ ]);
105
145
  let children = [];
146
+ const byType = {};
147
+ const risky = [];
106
148
  try {
107
149
  const params = new URLSearchParams();
108
150
  params.set('sysparm_query', `update_set=${sysID}^ORDERBYDESCsys_updated_on`);
109
151
  params.set('sysparm_limit', '500');
110
- params.set('sysparm_fields', 'name,sys_updated_on');
152
+ params.set('sysparm_fields', 'name,sys_updated_on,sys_class_name');
111
153
  const updates = await app.sdk.list('sys_update_xml', params);
112
154
  if (Array.isArray(updates)) {
113
- children = updates.map(u => getStringField(u, 'name')).filter(Boolean);
155
+ for (const u of updates) {
156
+ const name = getStringField(u, 'name');
157
+ if (!name) continue;
158
+ // sys_class_name is often empty on dev; fall back to the element-type
159
+ // prefix baked into the update name (e.g. sys_security_acl_<sysid32>).
160
+ let type = getStringField(u, 'sys_class_name');
161
+ if (!type) {
162
+ const m = /^(sys_[a-z_]+?)_[0-9a-f]{32}$/i.exec(name);
163
+ type = m ? m[1] : '?';
164
+ }
165
+ children.push({ name, type });
166
+ if (type && type !== '?') byType[type] = (byType[type] || 0) + 1;
167
+ if (type && RISKY_TYPES.has(type)) risky.push(name);
168
+ }
114
169
  }
115
170
  } catch {
116
171
  // children are best-effort — a broken query shouldn't kill the display
@@ -123,9 +178,20 @@ export async function formatUpdateSetDetail(app, record) {
123
178
  if (parent) lines.push(` Parent: ${parent}`);
124
179
  if (created) lines.push(` Created: ${created}`);
125
180
  if (updated) lines.push(` Updated: ${updated}`);
126
- lines.push(` Updates: ${children.length}`);
181
+ lines.push(`Updates: ${children.length}`);
182
+ const typeEntries = Object.entries(byType).sort((a, b) => b[1] - a[1]);
183
+ if (typeEntries.length > 0) {
184
+ lines.push(` By type: ${typeEntries.map(([t, n]) => `${t}×${n}`).join(', ')}`);
185
+ }
186
+ if (risky.length > 0) {
187
+ lines.push(` ⚠️ Risky: ${risky.length} (business rules, ACLs, client scripts, flows, ...)`);
188
+ for (const r of risky.slice(0, 10)) {
189
+ lines.push(` ⚠️ ${r}`);
190
+ }
191
+ if (risky.length > 10) lines.push(` … and ${risky.length - 10} more`);
192
+ }
127
193
  for (const c of children) {
128
- lines.push(` ${c}`);
194
+ lines.push(` ${c.name}`);
129
195
  }
130
196
  lines.push(` Link: ${link}`);
131
197
 
@@ -152,6 +218,11 @@ export async function formatUpdateSetDetail(app, record) {
152
218
  cmd: `jsn updatesets parent "${name}" --parent "<parent set name>"`,
153
219
  description: 'Set the parent of this update set',
154
220
  });
221
+ hints.push({
222
+ action: 'export',
223
+ cmd: `jsn updatesets export "${name}" --out ${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.xml`,
224
+ description: 'Export this update set to XML',
225
+ });
155
226
  if (!isClosed) {
156
227
  hints.push({
157
228
  action: 'complete',
@@ -174,6 +245,9 @@ export async function formatUpdateSetDetail(app, record) {
174
245
  sys_created_on: created,
175
246
  sys_updated_on: updated,
176
247
  children,
248
+ by_type: byType,
249
+ risky_count: risky.length,
250
+ risky,
177
251
  link,
178
252
  _formatted: lines.join('\n'),
179
253
  hints,
@@ -227,14 +301,69 @@ export function updateSetsCmd(wrap) {
227
301
  })
228
302
  .command({
229
303
  command: 'show <name>',
230
- aliases: ['get'],
231
- describe: 'Show an update set',
304
+ aliases: ['get', 'review'],
305
+ describe: 'Show an update set (members, type counts, risky items)',
232
306
  handler: wrap(async (argv, app) => {
233
307
  const record = await resolveUpdateSetByName(app, argv.name);
234
308
  const detail = await formatUpdateSetDetail(app, record);
235
309
  app.ok(detail, { summary: `Update set ${argv.name}`, breadcrumbs: detail.hints });
236
310
  }),
237
311
  })
312
+ .command({
313
+ command: 'export <name>',
314
+ describe: 'Export an update set to XML (raw XML to stdout, or --out file)',
315
+ builder: (y) => y
316
+ .positional('name', { describe: 'Update set name', type: 'string' })
317
+ .option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' }),
318
+ handler: wrap(async (argv, app) => {
319
+ app.requireInstance();
320
+ const record = await resolveUpdateSetByName(app, argv.name);
321
+ const sysID = getStringField(record, 'sys_id');
322
+ // The fluent export endpoint wants the update set's SCOPE sys_id
323
+ // (application is a sys_scope reference) so it can resolve the
324
+ // app's update set members.
325
+ const appSysId = record.application?.value
326
+ || (typeof record.application === 'string' ? record.application : '');
327
+
328
+ // Fluent export endpoint: warm session → CSRF → GET. Returns the
329
+ // full update set XML (header + all sys_update_xml payloads).
330
+ const xml = await app.sdk.exportUpdateSet(sysID, appSysId);
331
+
332
+ // Guard: the endpoint returns the XML document itself. If we got
333
+ // anything that doesn't start like XML it's an error/login page.
334
+ const trimmed = xml.trimStart();
335
+ if (!/^(<\?xml|<unload)/i.test(trimmed)) {
336
+ throw errAPI(200, `Export returned a non-XML response (${trimmed.slice(0, 60)}…) — the instance may need an authenticated session.`);
337
+ }
338
+
339
+ const bytes = Buffer.byteLength(xml, 'utf-8');
340
+ if (argv.out) {
341
+ await fs.promises.writeFile(argv.out, xml);
342
+ app.ok({
343
+ update_set: getStringField(record, 'name'),
344
+ sys_id: sysID,
345
+ out: argv.out,
346
+ bytes,
347
+ }, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${argv.out}` });
348
+ return;
349
+ }
350
+
351
+ // No --out: raw XML to stdout so it pipes cleanly
352
+ // (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
353
+ // envelope when the user explicitly asks for it — auto format
354
+ // detection must NOT hijack the payload for piped consumers.
355
+ if (argv.json || argv.format === 'json') {
356
+ app.ok({
357
+ update_set: getStringField(record, 'name'),
358
+ sys_id: sysID,
359
+ xml,
360
+ bytes,
361
+ }, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes)` });
362
+ return;
363
+ }
364
+ process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
365
+ }),
366
+ })
238
367
  .command({
239
368
  command: 'set [name]',
240
369
  describe: 'Set the current update set (interactive picker with scope when run bare)',
@@ -395,7 +524,8 @@ export function updateSetsCmd(wrap) {
395
524
  console.log('Manage ServiceNow update sets.\n');
396
525
  console.log('Commands:');
397
526
  console.log(' list List update sets (shows scope)');
398
- console.log(' show <name> Show an update set');
527
+ console.log(' show <name> Show an update set (type counts + risky items)');
528
+ console.log(' export <name> Export an update set to XML (stdout or --out)');
399
529
  console.log(' set [name] Set the current update set (picker when run bare)');
400
530
  console.log(' create Create a new update set (auto-sets as current)');
401
531
  console.log(' complete Mark the current update set as complete');
@@ -2,13 +2,14 @@
2
2
 
3
3
  import {
4
4
  actionsCmd, includesCmd, rulesCmd,
5
- clientScriptsCmd, uiActionsCmd, uiPoliciesCmd,
5
+ clientScriptsCmd, uiActionsCmd,
6
6
  tablesCmd, columnsCmd, importCmd,
7
7
  spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
8
8
  aclsCmd, rolesCmd, propertiesCmd,
9
9
  relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
10
10
  privilegesCmd, uxscriptsCmd, aliasesCmd,
11
11
  catalogscriptsCmd, cataloguipoliciesCmd,
12
+ uipoliciesCmd,
12
13
  } from './dev/_simple.js';
13
14
  import { flowsCmd } from './dev/flows.js';
14
15
  import { formsCmd } from './dev/forms.js';
@@ -33,7 +34,7 @@ export function devCmd(wrap) {
33
34
  .command(rulesCmd(wrap))
34
35
  .command(clientScriptsCmd(wrap))
35
36
  .command(uiActionsCmd(wrap))
36
- .command(uiPoliciesCmd(wrap))
37
+ .command(uipoliciesCmd(wrap))
37
38
  .command(tablesCmd(wrap))
38
39
  .command(columnsCmd(wrap))
39
40
  .command(formsCmd(wrap))
@@ -171,6 +171,20 @@ export function getMeta(db, key, defaultValue = null) {
171
171
  }
172
172
  }
173
173
 
174
+ /**
175
+ * Index statistics for `jsn docs status`: document/bundle/doc-type counts,
176
+ * embedding coverage, and the synced_at meta stamp. All docs-table aggregate
177
+ * SQL lives here so command handlers never touch the schema.
178
+ */
179
+ export function getDocsIndexStats(db) {
180
+ const documents = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
181
+ const bundles = db.prepare('SELECT COUNT(DISTINCT bundle) AS n FROM docs').get().n;
182
+ const docTypes = db.prepare('SELECT COUNT(DISTINCT doc_type) AS n FROM docs').get().n;
183
+ const embeddedCount = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n;
184
+ const syncedAt = getMeta(db, 'synced_at');
185
+ return { documents, bundles, docTypes, embeddedCount, syncedAt };
186
+ }
187
+
174
188
  export function setMeta(db, key, value) {
175
189
  db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
176
190
  }
@@ -6,14 +6,17 @@ import net from 'node:net';
6
6
  import process from 'node:process';
7
7
  import {
8
8
  getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists,
9
- openDocsDb, closeDocsDb, getMeta,
9
+ openDocsDb, closeDocsDb, getDocsIndexStats,
10
10
  } from './db.js';
11
11
  import { syncDocs } from './sync.js';
12
12
  import { refreshDocs } from './refresh.js';
13
13
  import { embedDocs } from './embed.js';
14
- import { searchDocs } from './search.js';
14
+ import { searchDocs, getDocByIdOrPath } from './search.js';
15
15
  import { serveDocs } from './serve.js';
16
16
  import { syncCommunity, listCommunityDocs } from './community.js';
17
+ import { declareCapabilities } from '../../capabilities.js';
18
+
19
+ declareCapabilities('docs', { noInstance: true, skipDailyChecks: true });
17
20
 
18
21
  function formatBytes(n) {
19
22
  if (n == null) return '-';
@@ -57,16 +60,13 @@ export function docsCmd(wrap) {
57
60
  let state, embedded, documents, bundles, docTypes, syncedAt, dbSize;
58
61
  if (dbExists) {
59
62
  const db = openDocsDb();
60
- documents = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
61
- bundles = db.prepare('SELECT COUNT(DISTINCT bundle) AS n FROM docs').get().n;
62
- docTypes = db.prepare('SELECT COUNT(DISTINCT doc_type) AS n FROM docs').get().n;
63
- const embCount = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n;
64
- syncedAt = getMeta(db, 'synced_at');
63
+ const stats = getDocsIndexStats(db);
64
+ ({ documents, bundles, docTypes, syncedAt } = stats);
65
65
  const stat = fs.statSync(dbPath);
66
66
  dbSize = stat.size;
67
67
  closeDocsDb(db);
68
68
 
69
- embedded = embCount >= documents && documents > 0;
69
+ embedded = stats.embeddedCount >= documents && documents > 0;
70
70
  if (embedded) state = 'ready';
71
71
  else if (documents > 0) state = 'indexed (not embedded)';
72
72
  else state = 'empty';
@@ -154,19 +154,7 @@ export function docsCmd(wrap) {
154
154
  .option('lines', { type: 'number', default: 80, describe: 'Lines of body to show in terminal (use --json for full content)' }),
155
155
  handler: wrap(async (argv, app) => {
156
156
  const idOrPath = argv['id-or-path'];
157
- const db = openDocsDb();
158
- let row;
159
- if (/^\d+$/.test(idOrPath)) {
160
- row = db.prepare('SELECT * FROM docs WHERE id = ?').get(parseInt(idOrPath, 10));
161
- } else {
162
- row = db.prepare('SELECT * FROM docs WHERE path LIKE ?').get(`%${idOrPath}%`);
163
- if (!row) {
164
- row = db.prepare(
165
- `SELECT d.* FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH ? LIMIT 1`
166
- ).get(idOrPath);
167
- }
168
- }
169
- closeDocsDb(db);
157
+ const row = getDocByIdOrPath(idOrPath);
170
158
 
171
159
  if (!row) {
172
160
  throw Object.assign(new Error(`No doc found for "${idOrPath}"`), { code: 'not_found' });
@@ -1,8 +1,7 @@
1
1
  // CLI search against the docs SQLite database.
2
2
 
3
3
  import fs from 'node:fs';
4
- import Database from 'better-sqlite3';
5
- import { getDocsDbPath, hasEmbeddings } from './db.js';
4
+ import { getDocsDbPath, hasEmbeddings, openDocsDb, closeDocsDb, getMeta } from './db.js';
6
5
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
7
6
 
8
7
  function safeParse(s) {
@@ -13,6 +12,33 @@ function safeParse(s) {
13
12
  }
14
13
  }
15
14
 
15
+ /**
16
+ * Fetch one doc for `jsn docs show`: exact id when the argument is numeric,
17
+ * otherwise a path-substring match, falling back to a single best FTS match.
18
+ * Returns the docs row, or undefined when nothing matches. Owns its DB
19
+ * handle (pass opts.dbPath to target a non-default database — used by tests).
20
+ */
21
+ export function getDocByIdOrPath(idOrPath, opts = {}) {
22
+ const dbPath = opts.dbPath || getDocsDbPath();
23
+ const db = openDocsDb({ dbPath });
24
+ try {
25
+ let row;
26
+ if (/^\d+$/.test(idOrPath)) {
27
+ row = db.prepare('SELECT * FROM docs WHERE id = ?').get(parseInt(idOrPath, 10));
28
+ } else {
29
+ row = db.prepare('SELECT * FROM docs WHERE path LIKE ?').get(`%${idOrPath}%`);
30
+ if (!row) {
31
+ row = db.prepare(
32
+ `SELECT d.* FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH ? LIMIT 1`
33
+ ).get(idOrPath);
34
+ }
35
+ }
36
+ return row;
37
+ } finally {
38
+ closeDocsDb(db);
39
+ }
40
+ }
41
+
16
42
  export function searchDocs(opts = {}) {
17
43
  const dbPath = opts.dbPath || getDocsDbPath();
18
44
  const q = (opts.query || '').toString().trim();
@@ -24,7 +50,7 @@ export function searchDocs(opts = {}) {
24
50
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
25
51
  }
26
52
 
27
- const db = new Database(dbPath, { readonly: true });
53
+ const db = openDocsDb({ dbPath });
28
54
  const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
29
55
  const offset = parseInt(opts.offset, 10) || 0;
30
56
  const bundle = opts.bundle ? String(opts.bundle) : null;
@@ -86,12 +112,9 @@ export function searchDocs(opts = {}) {
86
112
  rows = [];
87
113
  } else {
88
114
  const hrrDim = (() => {
89
- try {
90
- const row = db.prepare("SELECT value FROM meta WHERE key='hrr_dim'").get();
91
- return row ? parseInt(row.value, 10) : DEFAULT_DIM;
92
- } catch {
93
- return DEFAULT_DIM;
94
- }
115
+ const value = getMeta(db, 'hrr_dim');
116
+ const n = parseInt(value, 10);
117
+ return Number.isInteger(n) ? n : DEFAULT_DIM;
95
118
  })();
96
119
  const queryVec = encodeText(q, hrrDim);
97
120
  const queryTokens = new Set(q.toLowerCase().split(/\s+/).filter(Boolean));
@@ -128,7 +151,7 @@ export function searchDocs(opts = {}) {
128
151
  }
129
152
  }
130
153
 
131
- db.close();
154
+ closeDocsDb(db);
132
155
 
133
156
  // Post-process canonical_url JSON strings that may contain escaped content.
134
157
  for (const row of rows) {