@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.
- package/README.md +18 -2
- package/package.json +4 -3
- package/src/app.js +48 -64
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -65
- package/src/commands/_ticket.js +37 -93
- package/src/commands/approvals.js +350 -0
- package/src/commands/atf.js +635 -0
- package/src/commands/auth.js +93 -13
- package/src/commands/catalog/enrich.js +139 -0
- package/src/commands/catalog.js +25 -81
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +63 -111
- package/src/commands/dev/_simple.js +16 -12
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/domains.js +4 -0
- package/src/commands/dev/eval.js +65 -7
- package/src/commands/dev/flows.js +3 -0
- package/src/commands/dev/logs.js +97 -0
- package/src/commands/dev/rest.js +6 -0
- package/src/commands/dev/restmessage.js +1 -0
- package/src/commands/dev/scopes.js +49 -25
- package/src/commands/dev/scrapi.js +5 -0
- package/src/commands/dev/soapmessages.js +1 -0
- package/src/commands/dev/updatesets.js +145 -15
- package/src/commands/dev.js +3 -2
- package/src/commands/docs/db.js +14 -0
- package/src/commands/docs/docs.js +9 -21
- package/src/commands/docs/search.js +33 -10
- package/src/commands/groups.js +3 -0
- package/src/commands/records.js +274 -12
- package/src/commands/setup.js +5 -0
- package/src/commands/skill.js +3 -0
- package/src/commands/snippets.js +155 -0
- package/src/commands/tickets.js +9 -29
- package/src/commands/users.js +9 -35
- package/src/commands/version.js +3 -0
- package/src/errors.js +107 -1
- package/src/help.js +1 -0
- package/src/helpers.js +131 -15
- package/src/mutations.js +36 -91
- package/src/output.js +195 -64
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
package/src/commands/dev/logs.js
CHANGED
|
@@ -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: () => {
|
package/src/commands/dev/rest.js
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
152
|
-
const
|
|
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 &&
|
|
58
|
-
});
|
|
65
|
+
return appId && appId === currentApp.appSysId;
|
|
66
|
+
}) : undefined;
|
|
59
67
|
if (inScope) return inScope;
|
|
60
|
-
|
|
61
|
-
//
|
|
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}
|
|
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
|
-
|
|
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(`
|
|
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');
|
package/src/commands/dev.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
actionsCmd, includesCmd, rulesCmd,
|
|
5
|
-
clientScriptsCmd, uiActionsCmd,
|
|
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(
|
|
37
|
+
.command(uipoliciesCmd(wrap))
|
|
37
38
|
.command(tablesCmd(wrap))
|
|
38
39
|
.command(columnsCmd(wrap))
|
|
39
40
|
.command(formsCmd(wrap))
|
package/src/commands/docs/db.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
61
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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) {
|