@jacebenson/jsn 4.3.7 → 4.3.8-dev.46
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 +266 -1
- package/package.json +1 -1
- package/skills/servicenow/SKILL.md +16 -0
- package/src/app.js +8 -1
- package/src/auth.js +14 -12
- package/src/cli-adapter.js +162 -11
- package/src/cli.js +121 -13
- package/src/commands/_generic.js +3 -2
- package/src/commands/_simple.js +1 -1
- package/src/commands/actions.js +156 -0
- package/src/commands/auth.js +21 -11
- package/src/commands/cmdb.js +18 -0
- package/src/commands/diff.js +205 -0
- package/src/commands/docs/docs.js +18 -16
- package/src/commands/flows.js +149 -47
- package/src/commands/graphql.js +95 -0
- package/src/commands/groupmembers.js +19 -4
- package/src/commands/grouproles.js +15 -4
- package/src/commands/midservers.js +708 -0
- package/src/commands/perf.js +36 -9
- package/src/commands/platform.js +202 -0
- package/src/commands/records.js +19 -15
- package/src/commands/skill.js +5 -2
- package/src/commands/transactions.js +78 -0
- package/src/commands/updatesets.js +77 -11
- package/src/errors.js +71 -6
- package/src/flow-create.js +884 -0
- package/src/flow-designer-interactive.js +417 -0
- package/src/flow-inspection.js +15 -5
- package/src/flow-publish.js +32 -0
- package/src/flow-test.js +122 -0
- package/src/flow-triggers.js +448 -0
- package/src/flow-versioning.js +53 -0
- package/src/help.js +93 -28
- package/src/helpers.js +124 -9
- package/src/midserver-direct.js +312 -0
- package/src/midserver-install.js +170 -0
- package/src/midserver-zip.js +107 -0
- package/src/output.js +7 -0
- package/src/processflow-actions.js +366 -0
- package/src/sdk.js +8 -0
package/src/commands/perf.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { errUsageHint, errUsage } from '../errors.js';
|
|
2
2
|
import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
|
|
3
|
+
import { topTransactionsCmd } from './transactions.js';
|
|
3
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
5
|
+
import { canUsePicker } from '../helpers.js';
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A picker is only safe when a human is reachable AND the invocation did
|
|
9
|
+
* not ask for machine output (`--json`/`--quiet`) or a one-shot extraction
|
|
10
|
+
* (`--get`). canUsePicker() owns that rule so CI/agent markers and
|
|
11
|
+
* JSN_NO_PROMPTS are honored too.
|
|
12
|
+
*/
|
|
13
|
+
function pickerEnabled(app, argv = {}) {
|
|
14
|
+
return canUsePicker(app, argv);
|
|
7
15
|
}
|
|
8
16
|
|
|
9
|
-
async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture' } = {}) {
|
|
17
|
+
async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture', argv = {} } = {}) {
|
|
10
18
|
const runs = listRuns({ limit: 1000 }).filter(run => !exclude.has(run.run_id));
|
|
11
|
-
if (!pickerEnabled(app) || runs.length === 0) return null;
|
|
19
|
+
if (!pickerEnabled(app, argv) || runs.length === 0) return null;
|
|
12
20
|
const selected = await paginatedSearch({
|
|
13
21
|
message,
|
|
14
22
|
totalCount: runs.length,
|
|
@@ -77,7 +85,7 @@ export function perfCmd(wrap) {
|
|
|
77
85
|
builder: (b) => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum runs to show' }),
|
|
78
86
|
handler: wrap(async (argv, app) => {
|
|
79
87
|
const runs = listRuns({ limit: argv.limit });
|
|
80
|
-
const picked = await pickRun(app, { message: 'Select a performance capture' });
|
|
88
|
+
const picked = await pickRun(app, { message: 'Select a performance capture', argv });
|
|
81
89
|
if (picked) {
|
|
82
90
|
const run = getRun(picked.run_id);
|
|
83
91
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
@@ -99,22 +107,41 @@ export function perfCmd(wrap) {
|
|
|
99
107
|
command: 'show [run_id]',
|
|
100
108
|
describe: 'Show one saved performance capture',
|
|
101
109
|
handler: wrap(async (argv, app) => {
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
// No run ID and no human to pick one: fail with a usable error
|
|
111
|
+
// instead of exiting 0 with no output at all.
|
|
112
|
+
if (!argv.run_id && !pickerEnabled(app, argv)) {
|
|
113
|
+
throw errUsageHint(
|
|
114
|
+
'Missing run ID: `jsn perf show` needs a run ID when prompts are unavailable.',
|
|
115
|
+
'Find a capture with `jsn perf list`, then run `jsn perf show <run_id>`.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app, { argv });
|
|
119
|
+
if (!run) return; // cancelled at the picker
|
|
104
120
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
105
121
|
summary: `Performance capture ${run.run_id}`,
|
|
106
122
|
breadcrumbs: [listHint(), compareHint(run.run_id)],
|
|
107
123
|
});
|
|
108
124
|
}),
|
|
109
125
|
})
|
|
126
|
+
.command({
|
|
127
|
+
command: 'transactions [subcommand]',
|
|
128
|
+
describe: 'Inspect Client Transaction Timings',
|
|
129
|
+
builder: (b) => b.command(topTransactionsCmd(wrap)),
|
|
130
|
+
})
|
|
110
131
|
.command({
|
|
111
132
|
command: 'compare [baseline] [new]',
|
|
112
133
|
describe: 'Compare exactly two saved captures',
|
|
113
134
|
handler: wrap(async (argv, app) => {
|
|
114
135
|
if (!argv.baseline && !argv.new) {
|
|
115
|
-
|
|
136
|
+
if (!pickerEnabled(app, argv)) {
|
|
137
|
+
throw errUsageHint(
|
|
138
|
+
'Compare requires exactly two run IDs when prompts are unavailable.',
|
|
139
|
+
'Run `jsn perf compare <baseline> <new>` with two run IDs from `jsn perf list`.'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const baseline = await pickRun(app, { message: 'Select the baseline capture', argv });
|
|
116
143
|
if (!baseline) return;
|
|
117
|
-
const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture' });
|
|
144
|
+
const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture', argv });
|
|
118
145
|
if (!newer) return;
|
|
119
146
|
const baselineRun = getRun(baseline.run_id);
|
|
120
147
|
const newerRun = getRun(newer.run_id);
|
package/src/commands/platform.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { formatCommandList } from '../helpers.js';
|
|
3
|
+
|
|
1
4
|
function numberOrString(value) {
|
|
2
5
|
const n = Number(value);
|
|
3
6
|
return value !== '' && Number.isFinite(n) ? n : value;
|
|
@@ -24,6 +27,179 @@ function statsForWindow(xml, parent, window) {
|
|
|
24
27
|
return tagAttributes(parentMatch[1], window);
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
export const XMLSTATS_MAX_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
function metric(value) {
|
|
33
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
34
|
+
return { value: null, availability: 'unavailable' };
|
|
35
|
+
}
|
|
36
|
+
return { value: numberOrString(String(value).trim()), availability: 'available' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firstNestedText(xml, parent, child) {
|
|
40
|
+
const parentMatch = xml.match(new RegExp(`<${parent}\\b[^>]*>([\\s\\S]*?)</${parent}>`));
|
|
41
|
+
return parentMatch ? tagText(parentMatch[1], child) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function allowlistedStats(xml) {
|
|
45
|
+
const unavailable = [];
|
|
46
|
+
const add = (path, value) => {
|
|
47
|
+
const result = metric(value);
|
|
48
|
+
if (result.availability === 'unavailable') unavailable.push(path);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cluster = xml.match(/<cluster\.status\b[^>]*>([\s\S]*?)<\/cluster\.status>/);
|
|
53
|
+
const nodeXml = cluster?.[1]?.match(/<node\b[^>]*>([\s\S]*?)<\/node>/)?.[1] || '';
|
|
54
|
+
const daily = statsForWindow(xml, 'all_transactions', 'daily') || {};
|
|
55
|
+
const semaphores = [];
|
|
56
|
+
for (const match of xml.matchAll(/<semaphores([^>]*)\/>/g)) {
|
|
57
|
+
const attrs = {};
|
|
58
|
+
for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) attrs[key] = value;
|
|
59
|
+
if (!attrs.name && !attrs.available && !attrs.borrowed && !attrs.maximum_concurrency && !attrs.queue_depth) continue;
|
|
60
|
+
semaphores.push({
|
|
61
|
+
name: attrs.name || 'unnamed',
|
|
62
|
+
available: add(`semaphores[${attrs.name || 'unnamed'}].available`, attrs.available),
|
|
63
|
+
borrowed: add(`semaphores[${attrs.name || 'unnamed'}].borrowed`, attrs.borrowed),
|
|
64
|
+
maximum: add(`semaphores[${attrs.name || 'unnamed'}].maximum`, attrs.maximum_concurrency),
|
|
65
|
+
queue_depth: add(`semaphores[${attrs.name || 'unnamed'}].queue_depth`, attrs.queue_depth),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (semaphores.length === 0) unavailable.push('semaphores');
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
node: {
|
|
72
|
+
status: add('node.status', tagText(nodeXml, 'status')),
|
|
73
|
+
type: add('node.type', firstNestedText(nodeXml, 'node_type', 'name')),
|
|
74
|
+
},
|
|
75
|
+
queue: {
|
|
76
|
+
length: add('queue.length', tagText(xml, 'queue.length')),
|
|
77
|
+
age: add('queue.age', tagText(xml, 'queue.age')),
|
|
78
|
+
},
|
|
79
|
+
active_sessions: add('active_sessions', tagText(xml, 'servlet.active.sessions')),
|
|
80
|
+
memory_pressure: add('memory_pressure', tagText(xml, 'memory_pressure')),
|
|
81
|
+
semaphores,
|
|
82
|
+
transactions: {
|
|
83
|
+
daily: {
|
|
84
|
+
count: add('transactions.daily.count', daily.count),
|
|
85
|
+
mean: add('transactions.daily.mean', daily.mean),
|
|
86
|
+
median: add('transactions.daily.median', daily.median),
|
|
87
|
+
ninetypercent: add('transactions.daily.ninetypercent', daily.ninetypercent),
|
|
88
|
+
max: add('transactions.daily.max', daily.max),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
unavailable,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parsePlatformStatsXml(xml) {
|
|
96
|
+
const source = String(xml || '');
|
|
97
|
+
if (Buffer.byteLength(source) > XMLSTATS_MAX_BYTES) throw new Error('xmlstats response exceeds the bounded size limit');
|
|
98
|
+
if (!/^\s*(?:<\?xml[\s\S]*?\?>\s*)?<xmlstats\b[^>]*>/i.test(source)
|
|
99
|
+
|| !/<\/xmlstats>\s*$/i.test(source)) {
|
|
100
|
+
throw new Error('Malformed XML: expected an xmlstats document');
|
|
101
|
+
}
|
|
102
|
+
const stack = [];
|
|
103
|
+
for (const match of source.matchAll(/<\/?([A-Za-z][\w.:-]*)(?:\s[^>]*)?>/g)) {
|
|
104
|
+
const token = match[0];
|
|
105
|
+
const name = match[1];
|
|
106
|
+
if (/^<\//.test(token)) {
|
|
107
|
+
if (stack.pop() !== name) throw new Error('Malformed XML: mismatched tags');
|
|
108
|
+
} else if (!/\/\s*>$/.test(token)) {
|
|
109
|
+
stack.push(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (stack.length !== 0) throw new Error('Malformed XML: unclosed tags');
|
|
113
|
+
return allowlistedStats(source);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readBoundedResponse(response, maxBytes = XMLSTATS_MAX_BYTES) {
|
|
117
|
+
if (!response.body?.getReader) {
|
|
118
|
+
const text = await response.text();
|
|
119
|
+
return Buffer.byteLength(text) > maxBytes ? { tooLarge: true, bytes: Buffer.byteLength(text) } : { text, bytes: Buffer.byteLength(text) };
|
|
120
|
+
}
|
|
121
|
+
const reader = response.body.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let bytes = 0;
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done) break;
|
|
127
|
+
bytes += value.byteLength;
|
|
128
|
+
if (bytes > maxBytes) {
|
|
129
|
+
await reader.cancel();
|
|
130
|
+
return { tooLarge: true, bytes };
|
|
131
|
+
}
|
|
132
|
+
chunks.push(value);
|
|
133
|
+
}
|
|
134
|
+
const body = new Uint8Array(bytes);
|
|
135
|
+
let offset = 0;
|
|
136
|
+
for (const chunk of chunks) {
|
|
137
|
+
body.set(chunk, offset);
|
|
138
|
+
offset += chunk.byteLength;
|
|
139
|
+
}
|
|
140
|
+
return { text: new TextDecoder().decode(body), bytes };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function looksLikeLoginHtml(response, body) {
|
|
144
|
+
const contentType = response.headers?.get?.('content-type') || '';
|
|
145
|
+
return /text\/html/i.test(contentType) || /^\s*<(?:!doctype\s+html|html\b)/i.test(body);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function collectPlatformStats(sdk, instance) {
|
|
149
|
+
let response;
|
|
150
|
+
try {
|
|
151
|
+
response = await sdk.fetchResponse(`${instance}/xmlstats.do`, {
|
|
152
|
+
method: 'GET',
|
|
153
|
+
headers: { Accept: 'application/xml,text/xml' },
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message = String(error?.message || '').toLowerCase();
|
|
157
|
+
return { status: /timeout|timed out/.test(message) ? 'timeout' : 'unavailable', reason: /timeout|timed out/.test(message) ? 'request timed out' : 'request failed' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (response.status === 401 || response.status === 403) return { status: 'permission_denied', reason: `HTTP ${response.status}` };
|
|
161
|
+
if (response.status === 404 || response.status === 405) return { status: 'unsupported', reason: `HTTP ${response.status}` };
|
|
162
|
+
if (!response.ok) return { status: 'unavailable', reason: `HTTP ${response.status}` };
|
|
163
|
+
|
|
164
|
+
const body = await readBoundedResponse(response);
|
|
165
|
+
if (body.tooLarge) return { status: 'too_large', reason: `response exceeded ${XMLSTATS_MAX_BYTES} bytes`, bytes: body.bytes };
|
|
166
|
+
if (looksLikeLoginHtml(response, body.text)) return { status: 'login_redirect', reason: 'received HTML instead of xmlstats XML', bytes: body.bytes };
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const metrics = parsePlatformStatsXml(body.text);
|
|
170
|
+
const status = metrics.unavailable.length > 0 ? 'partial' : 'success';
|
|
171
|
+
return {
|
|
172
|
+
status,
|
|
173
|
+
reason: status === 'partial' ? 'some allowlisted metrics were unavailable' : null,
|
|
174
|
+
bytes: body.bytes,
|
|
175
|
+
metrics,
|
|
176
|
+
};
|
|
177
|
+
} catch {
|
|
178
|
+
return { status: 'malformed_xml', reason: 'response was not a valid xmlstats document', bytes: body.bytes };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function metricText(item) {
|
|
183
|
+
return item?.availability === 'available' ? String(item.value) : 'unavailable';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function formatPlatformStats(result) {
|
|
187
|
+
const lines = [`STATUS: ${result.status}`];
|
|
188
|
+
if (result.reason) lines.push(`REASON: ${result.reason}`);
|
|
189
|
+
const m = result.metrics;
|
|
190
|
+
if (!m) return `${lines.join('\\n')}\\n`;
|
|
191
|
+
lines.push(`NODE: ${metricText(m.node.status)} / ${metricText(m.node.type)}`);
|
|
192
|
+
lines.push(`QUEUE: ${metricText(m.queue.length)} length, ${metricText(m.queue.age)} age`);
|
|
193
|
+
lines.push(`SESSIONS: ${metricText(m.active_sessions)}`);
|
|
194
|
+
lines.push(`MEMORY PRESSURE: ${metricText(m.memory_pressure)}`);
|
|
195
|
+
lines.push(`TRANSACTIONS DAILY: ${metricText(m.transactions.daily.count)} count, ${metricText(m.transactions.daily.mean)} mean, ${metricText(m.transactions.daily.ninetypercent)} p90, ${metricText(m.transactions.daily.max)} max`);
|
|
196
|
+
if (m.semaphores.length === 0) lines.push('SEMAPHORES: unavailable');
|
|
197
|
+
else for (const semaphore of m.semaphores) {
|
|
198
|
+
lines.push(`SEMAPHORE ${semaphore.name}: ${metricText(semaphore.available)} available, ${metricText(semaphore.borrowed)} borrowed, ${metricText(semaphore.maximum)} maximum, ${metricText(semaphore.queue_depth)} queue`);
|
|
199
|
+
}
|
|
200
|
+
return `${lines.join('\\n')}\\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
27
203
|
export function parseNodeStatsXml(xml) {
|
|
28
204
|
const source = String(xml || '');
|
|
29
205
|
const result = {};
|
|
@@ -91,9 +267,26 @@ function formatHealth(nodes) {
|
|
|
91
267
|
}
|
|
92
268
|
|
|
93
269
|
export function platformCmd(wrap) {
|
|
270
|
+
declareCapabilities('platform', { mutationSubcommands: [] });
|
|
94
271
|
return {
|
|
95
272
|
command: 'platform',
|
|
96
273
|
describe: 'Inspect read-only platform health and node statistics',
|
|
274
|
+
// Parent discovery. This parent had no handler, so `jsn platform` and
|
|
275
|
+
// `jsn platform --json` exited 0 with no output at all — silent success.
|
|
276
|
+
// Children come from the registry, not a second hand-written list.
|
|
277
|
+
handler: wrap(async (argv, app) => {
|
|
278
|
+
const subcommands = argv.subcommands || [];
|
|
279
|
+
const summary = 'Inspect read-only platform health and node statistics.';
|
|
280
|
+
app.ok({
|
|
281
|
+
command: 'platform',
|
|
282
|
+
subcommands,
|
|
283
|
+
_formatted: formatCommandList({
|
|
284
|
+
title: summary,
|
|
285
|
+
commands: subcommands,
|
|
286
|
+
footer: 'Run "jsn platform <command> --help" for details.',
|
|
287
|
+
}),
|
|
288
|
+
}, { summary });
|
|
289
|
+
}),
|
|
97
290
|
builder: (y) => y.command({
|
|
98
291
|
command: 'health',
|
|
99
292
|
describe: 'Show cluster, queue, semaphore, and node health',
|
|
@@ -122,6 +315,15 @@ export function platformCmd(wrap) {
|
|
|
122
315
|
}
|
|
123
316
|
app.ok({ table: 'sys_cluster_state', node_count: nodes.length, nodes, _formatted: formatHealth(nodes), security: { raw_xml: 'excluded', sensitive_fields: 'excluded' }, context: { instance_url: app.getEffectiveInstance() } }, { summary: `Platform health: ${nodes.length} node(s)` });
|
|
124
317
|
}),
|
|
318
|
+
}).command({
|
|
319
|
+
command: 'stats',
|
|
320
|
+
describe: 'Show bounded, read-only platform statistics from xmlstats.do',
|
|
321
|
+
handler: wrap(async (argv, app) => {
|
|
322
|
+
app.requireInstance();
|
|
323
|
+
const result = await collectPlatformStats(app.sdk, app.getEffectiveInstance());
|
|
324
|
+
result._formatted = formatPlatformStats(result);
|
|
325
|
+
app.ok(result, { summary: `Platform stats: ${result.status}` });
|
|
326
|
+
}),
|
|
125
327
|
}),
|
|
126
328
|
};
|
|
127
329
|
}
|
package/src/commands/records.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
|
|
3
|
+
import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
|
|
4
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
5
5
|
|
|
6
6
|
declareCapabilities('records', {
|
|
@@ -514,20 +514,24 @@ export function recordsCmd(wrap) {
|
|
|
514
514
|
})
|
|
515
515
|
|
|
516
516
|
},
|
|
517
|
-
|
|
517
|
+
// Parent discovery through the output controller: the subcommand list is
|
|
518
|
+
// registry-derived (argv.subcommands), so `jsn records --json` returns a
|
|
519
|
+
// structured envelope instead of prose, and the descriptions can no longer
|
|
520
|
+
// describe a different surface than the registrations (the previous
|
|
521
|
+
// hand-written list advertised `inspect` as table schema/statistics).
|
|
522
|
+
handler: wrap(async (argv, app) => {
|
|
518
523
|
if (argv._[1]) return; // a subcommand ran — its own handler handled it
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
},
|
|
524
|
+
const subcommands = argv.subcommands || [];
|
|
525
|
+
const summary = 'Query and manage records in any ServiceNow table.';
|
|
526
|
+
app.ok({
|
|
527
|
+
command: 'records',
|
|
528
|
+
subcommands,
|
|
529
|
+
_formatted: formatCommandList({
|
|
530
|
+
title: summary,
|
|
531
|
+
commands: subcommands,
|
|
532
|
+
footer: 'Run "jsn records <command> --help" for details.',
|
|
533
|
+
}),
|
|
534
|
+
}, { summary });
|
|
535
|
+
}),
|
|
532
536
|
};
|
|
533
537
|
}
|
package/src/commands/skill.js
CHANGED
|
@@ -8,6 +8,7 @@ import { execSync } from 'node:child_process';
|
|
|
8
8
|
|
|
9
9
|
import { globalConfigPath } from '../config.js';
|
|
10
10
|
import { declareCapabilities } from '../capabilities.js';
|
|
11
|
+
import { canUsePicker } from '../helpers.js';
|
|
11
12
|
|
|
12
13
|
declareCapabilities('skill', { noInstance: true, skipDailyChecks: true });
|
|
13
14
|
|
|
@@ -305,8 +306,10 @@ export function skillCmd(wrap) {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
}
|
|
308
|
-
} else if (
|
|
309
|
-
// Interactive: show multi-select picker
|
|
309
|
+
} else if (canUsePicker(app, argv)) {
|
|
310
|
+
// Interactive: show multi-select picker. canUsePicker() (not a
|
|
311
|
+
// bare stdin TTY check) keeps a PTY allocated for an agent,
|
|
312
|
+
// CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
|
|
310
313
|
const fetchedVersion = extractVersion(content);
|
|
311
314
|
// Check if any existing installed copies are outdated
|
|
312
315
|
const outdated = [];
|
|
@@ -39,6 +39,7 @@ export function classifyTransactionType(type, url = '') {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function numericValue(value) {
|
|
42
|
+
if (value == null || String(value).trim() === '') return null;
|
|
42
43
|
const n = Number(String(value ?? '').replaceAll(',', ''));
|
|
43
44
|
return Number.isFinite(n) ? n : null;
|
|
44
45
|
}
|
|
@@ -65,6 +66,83 @@ function summarizeGroup(group) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function topQuery(argv = {}) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
if (argv.query) parts.push(String(argv.query));
|
|
72
|
+
for (const field of ['type', 'table', 'view']) if (argv[field]) parts.push(`${field}=${argv[field]}`);
|
|
73
|
+
if (argv.window != null && String(argv.window) !== '') {
|
|
74
|
+
const days = Math.max(1, Math.min(30, Number(argv.window) || 1));
|
|
75
|
+
parts.push(`sys_created_on>=javascript:gs.daysAgoStart(${days})`);
|
|
76
|
+
}
|
|
77
|
+
if (argv.since) parts.push(`sys_created_on>=${argv.since}`);
|
|
78
|
+
if (argv.until) parts.push(`sys_created_on<=${argv.until}`);
|
|
79
|
+
return normalizeTransactionQuery(parts.join('^'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function topGroup(group) {
|
|
83
|
+
const stats = group?.stats || {};
|
|
84
|
+
return {
|
|
85
|
+
type: groupValue(group, 'type') || 'unavailable',
|
|
86
|
+
table: groupValue(group, 'table') || 'unavailable',
|
|
87
|
+
view: groupValue(group, 'view') || 'unavailable',
|
|
88
|
+
count: numericValue(stats.count) ?? 0,
|
|
89
|
+
avg_response_time_ms: metricValue(stats, 'avg', 'response_time'),
|
|
90
|
+
min_response_time_ms: metricValue(stats, 'min', 'response_time'),
|
|
91
|
+
max_response_time_ms: metricValue(stats, 'max', 'response_time'),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatTopTransactions(groups) {
|
|
96
|
+
const value = metric => metric == null ? 'unavailable' : `${Math.round(metric * 100) / 100} ms`;
|
|
97
|
+
const lines = [
|
|
98
|
+
'TYPE TABLE VIEW COUNT AVG MIN MAX',
|
|
99
|
+
'----------- ----------------- --------------- ----- ----------- ----------- -----------',
|
|
100
|
+
];
|
|
101
|
+
for (const row of groups) lines.push(`${String(row.type).slice(0, 11).padEnd(11)} ${String(row.table).slice(0, 17).padEnd(17)} ${String(row.view).slice(0, 15).padEnd(15)} ${String(row.count).padStart(5)} ${value(row.avg_response_time_ms).padEnd(11)} ${value(row.min_response_time_ms).padEnd(11)} ${value(row.max_response_time_ms)}`);
|
|
102
|
+
if (groups.length === 0) lines.push('(no transaction groups)');
|
|
103
|
+
return `${lines.join('\\n')}\\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function topTransactionsCmd(wrap) {
|
|
107
|
+
return {
|
|
108
|
+
command: 'top',
|
|
109
|
+
describe: 'Rank Client Transaction Timings by type, table, and view',
|
|
110
|
+
builder: (y) => y
|
|
111
|
+
.option('type', { type: 'string', describe: 'Filter transaction type' })
|
|
112
|
+
.option('table', { type: 'string', describe: 'Filter transaction table' })
|
|
113
|
+
.option('view', { type: 'string', describe: 'Filter transaction view' })
|
|
114
|
+
.option('query', { type: 'string', describe: 'Additional encoded query' })
|
|
115
|
+
.option('window', { type: 'number', default: 1, describe: 'Look back this many days (max 30)' })
|
|
116
|
+
.option('since', { type: 'string', describe: 'Only records on or after this timestamp' })
|
|
117
|
+
.option('until', { type: 'string', describe: 'Only records on or before this timestamp' })
|
|
118
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Maximum groups to return' }),
|
|
119
|
+
handler: wrap(async (argv, app) => {
|
|
120
|
+
app.requireInstance();
|
|
121
|
+
const query = topQuery(argv);
|
|
122
|
+
const limit = Math.max(1, Math.min(100, Number(argv.limit) || 20));
|
|
123
|
+
const result = await app.sdk.aggregate('syslog_transaction', {
|
|
124
|
+
query,
|
|
125
|
+
groupBy: ['type', 'table', 'view'],
|
|
126
|
+
count: true,
|
|
127
|
+
averageFields: ['response_time'],
|
|
128
|
+
minimumFields: ['response_time'],
|
|
129
|
+
maximumFields: ['response_time'],
|
|
130
|
+
orderBy: 'countDESC',
|
|
131
|
+
});
|
|
132
|
+
const groups = (Array.isArray(result.groups) ? result.groups : []).map(topGroup).slice(0, limit);
|
|
133
|
+
app.ok({
|
|
134
|
+
table: 'syslog_transaction',
|
|
135
|
+
query,
|
|
136
|
+
group_by: ['type', 'table', 'view'],
|
|
137
|
+
response_time_unit: 'ms',
|
|
138
|
+
groups,
|
|
139
|
+
_formatted: formatTopTransactions(groups),
|
|
140
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
141
|
+
}, { summary: `Top transaction groups: ${groups.length}` });
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
68
146
|
export function transactionsCmd(wrap) {
|
|
69
147
|
return {
|
|
70
148
|
command: 'transactions',
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { editor, confirm } from '@inquirer/prompts';
|
|
3
|
+
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
|
|
4
|
+
import { getActiveProfile } from '../config.js';
|
|
5
|
+
import { FormatAuto } from '../output.js';
|
|
3
6
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
4
|
-
import { errAPI, errUsage } from '../errors.js';
|
|
7
|
+
import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
5
8
|
import { declareCapabilities } from '../capabilities.js';
|
|
6
9
|
|
|
7
10
|
declareCapabilities('updatesets', {
|
|
@@ -133,6 +136,28 @@ async function fetchUpdateSetById(app, sysID) {
|
|
|
133
136
|
return Array.isArray(records) && records.length > 0 ? records[0] : null;
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
/** Completion refuses duplicate names rather than guessing a mutation target. */
|
|
140
|
+
async function resolveCompletionTarget(app, name) {
|
|
141
|
+
if (name !== undefined) {
|
|
142
|
+
if (!String(name).trim()) throw errUsage('Update set name must not be empty');
|
|
143
|
+
assertSafeExactMatch(name);
|
|
144
|
+
const params = new URLSearchParams({
|
|
145
|
+
sysparm_query: `name=${name}`, sysparm_limit: '2',
|
|
146
|
+
sysparm_display_value: 'all', sysparm_fields: 'sys_id,name',
|
|
147
|
+
});
|
|
148
|
+
const records = await app.sdk.list('sys_update_set', params);
|
|
149
|
+
if (!records.length) throw errUsage(`Update set not found: ${name}`);
|
|
150
|
+
if (records.length !== 1) {
|
|
151
|
+
throw errUsage(`Multiple update sets named "${name}". Select the intended set with "jsn updatesets set", then run "jsn updatesets complete" without a name.`);
|
|
152
|
+
}
|
|
153
|
+
return { sys_id: rawReferenceId(records[0].sys_id), name: getStringField(records[0], 'name') };
|
|
154
|
+
}
|
|
155
|
+
const userSysID = await requireCurrentUserSysId(app.sdk);
|
|
156
|
+
const current = await getCurrentUpdateSet(app.sdk, userSysID);
|
|
157
|
+
if (!current?.sys_id) throw errUsage('No current update set. Set one first:\n jsn updatesets set');
|
|
158
|
+
return current;
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
/** Build a safe, flat preorder hierarchy with ancestors before the selected set. */
|
|
137
162
|
export async function buildUpdateSetHierarchy(app, selectedRecord) {
|
|
138
163
|
const selectedID = getStringField(selectedRecord, 'sys_id');
|
|
@@ -631,16 +656,57 @@ export function updateSetsCmd(wrap) {
|
|
|
631
656
|
}),
|
|
632
657
|
})
|
|
633
658
|
.command({
|
|
634
|
-
command: 'complete',
|
|
635
|
-
describe: '
|
|
659
|
+
command: 'complete [name]',
|
|
660
|
+
describe: 'Complete a named or current update set with completion notes',
|
|
661
|
+
builder: (y) => y
|
|
662
|
+
.positional('name', { type: 'string', describe: 'Unique update set name, defaults to the current set' })
|
|
663
|
+
.option('notes', { type: 'string', describe: 'Completion notes, one bullet per line; opens an editor when omitted interactively' }),
|
|
636
664
|
handler: wrap(async (argv, app) => {
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
if (!current
|
|
640
|
-
|
|
665
|
+
const current = await resolveCompletionTarget(app, argv.name);
|
|
666
|
+
const record = await fetchUpdateSetById(app, current.sys_id);
|
|
667
|
+
if (!record) throw errUsage(`Update set not found or unreadable: ${current.sys_id}`);
|
|
668
|
+
if (!Object.hasOwn(record, 'description')
|
|
669
|
+
|| (record.description && typeof record.description === 'object' && !Object.hasOwn(record.description, 'value'))) {
|
|
670
|
+
throw errUsage(`Description is unreadable for update set "${current.name}" (${current.sys_id}); refusing to replace unknown text.`);
|
|
671
|
+
}
|
|
672
|
+
const existing = String(record.description && typeof record.description === 'object'
|
|
673
|
+
? record.description.value ?? '' : record.description ?? '');
|
|
674
|
+
const interactive = !argv.json && !argv.get && (!argv.format || argv.format === FormatAuto)
|
|
675
|
+
&& app.output?.getFormat() === FormatAuto && canPrompt();
|
|
676
|
+
let notes = argv.notes;
|
|
677
|
+
let description;
|
|
678
|
+
try {
|
|
679
|
+
if (notes === undefined) {
|
|
680
|
+
if (!interactive) throw errUsage('Provide nonempty completion notes with --notes in noninteractive mode');
|
|
681
|
+
notes = await editor({ message: `Completion notes for "${current.name}". Write concise bullets, one per line` });
|
|
682
|
+
}
|
|
683
|
+
const bullets = String(notes).split(/\r?\n/)
|
|
684
|
+
.map(line => line.trim().replace(/^[-*•]\s*/, '').trim()).filter(Boolean)
|
|
685
|
+
.map(line => `- ${line}`).join('\n');
|
|
686
|
+
if (!bullets) throw errUsage('Provide nonempty completion notes with --notes');
|
|
687
|
+
description = existing ? `${existing}\n\n${bullets}` : bullets;
|
|
688
|
+
if (existing && !argv.force && getActiveProfile(app.config)?.skip_confirmations !== true) {
|
|
689
|
+
const question = `Append notes and complete "${current.name}"?\nExisting Description:\n${existing}\n\nNew notes:\n${bullets}`;
|
|
690
|
+
if (!interactive) throw errConfirmationRequired(question, buildRerunCommand(argv, {
|
|
691
|
+
rerun: ['updatesets', 'complete', ...(argv.name !== undefined ? [argv.name] : []), '--notes', String(notes)],
|
|
692
|
+
}));
|
|
693
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
694
|
+
}
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (['ExitPromptError', 'AbortPromptError'].includes(error.name)) return;
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
const data = { state: 'complete', description };
|
|
700
|
+
try {
|
|
701
|
+
await app.sdk.update('sys_update_set', current.sys_id, data);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
throw errAPI(error.status || 0, `Could not complete update set "${current.name}" (${current.sys_id}): ${error.message}. Check the record before retrying.`);
|
|
704
|
+
}
|
|
705
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', current.sys_id, data);
|
|
706
|
+
if (mismatches.length) {
|
|
707
|
+
throw errAPI(200, `Could not verify completion of update set "${current.name}" (${current.sys_id}): ${mismatches.map(item => item.field).join(', ')} did not persist or could not be read. The write may have partially succeeded; inspect the set before retrying.`);
|
|
641
708
|
}
|
|
642
|
-
|
|
643
|
-
app.ok({ update_set: current.name, state: 'complete' }, { summary: `Update set marked complete: ${current.name}` });
|
|
709
|
+
app.ok({ update_set: current.name, sys_id: current.sys_id, state: 'complete', description }, { summary: `Update set marked complete: ${current.name}` });
|
|
644
710
|
}),
|
|
645
711
|
})
|
|
646
712
|
.command({
|
|
@@ -667,7 +733,7 @@ export function updateSetsCmd(wrap) {
|
|
|
667
733
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
668
734
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
669
735
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
670
|
-
console.log(' complete
|
|
736
|
+
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|
|
671
737
|
console.log(' ignore Ignore the current update set');
|
|
672
738
|
console.log(' parent <name> Set the parent of an update set');
|
|
673
739
|
console.log('\nRun "jsn updatesets <command> --help" for details.');
|