@jacebenson/jsn 4.3.7 → 4.3.8
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 +246 -1
- package/package.json +1 -1
- 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/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',
|
package/src/errors.js
CHANGED
|
@@ -10,6 +10,7 @@ export const CodeAPI = 'api_error';
|
|
|
10
10
|
export const CodeAmbiguous = 'ambiguous';
|
|
11
11
|
export const CodeEmptyResult = 'empty_result';
|
|
12
12
|
export const CodeConfirmationRequired = 'confirmation_required';
|
|
13
|
+
export const CodeExtractionNotFound = 'extraction_not_found';
|
|
13
14
|
|
|
14
15
|
export class AppError extends Error {
|
|
15
16
|
constructor(code, message, hint = '', status = 0, cause = null) {
|
|
@@ -118,17 +119,48 @@ export function errAmbiguous(resource, matches) {
|
|
|
118
119
|
);
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Structured error for `--get <path>` resolving to nothing.
|
|
124
|
+
*
|
|
125
|
+
* An unresolvable path (typo, wrong envelope shape, out-of-range index) is a
|
|
126
|
+
* failure, not a value: printing `undefined` and exiting 0 made a misspelled
|
|
127
|
+
* extract look like a successful empty result until the next tool in the pipe
|
|
128
|
+
* tried to parse it. A path that resolves to a present null, false, 0, or ""
|
|
129
|
+
* is a real value and is returned unchanged — only an unresolved path errors.
|
|
130
|
+
*/
|
|
131
|
+
export function errExtractionNotFound(path) {
|
|
132
|
+
return new AppError(
|
|
133
|
+
CodeExtractionNotFound,
|
|
134
|
+
`No value at --get path "${path}".`,
|
|
135
|
+
'Check the path against the envelope (run the same command without --get, or with --json, to see its shape). '
|
|
136
|
+
+ 'Present null, false, 0, and "" values are returned as-is; only an unresolvable path fails.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
121
140
|
/**
|
|
122
141
|
* Structured error for a destructive action that requires confirmation
|
|
123
142
|
* but no human is available to answer (non-TTY, pipes, or AI agents).
|
|
143
|
+
*
|
|
124
144
|
* The hint is written to be machine-parseable: an agent can read the
|
|
125
|
-
* re-run command
|
|
145
|
+
* exact re-run command — target, table, pinned profile/instance — and
|
|
146
|
+
* present it to its user for approval before executing. It deliberately
|
|
147
|
+
* does NOT recommend permanently disabling confirmations: an approval for
|
|
148
|
+
* one destructive action is not consent to disarm the guard for every
|
|
149
|
+
* future action.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
152
|
+
* @param {string} [rerunCmd] — complete re-run command WITHOUT --force,
|
|
153
|
+
* already shell-quoted. Empty when the caller cannot reconstruct it.
|
|
126
154
|
*/
|
|
127
|
-
export function errConfirmationRequired(question, rerunCmd) {
|
|
155
|
+
export function errConfirmationRequired(question, rerunCmd = '') {
|
|
156
|
+
const cmd = String(rerunCmd || '').trim();
|
|
157
|
+
const hint = cmd
|
|
158
|
+
? `This is a destructive action. Ask your user to approve this exact command, then re-run:\n ${cmd} --force`
|
|
159
|
+
: 'This is a destructive action. Ask your user to approve it, then re-run the same command with --force.';
|
|
128
160
|
return new AppError(
|
|
129
161
|
CodeConfirmationRequired,
|
|
130
|
-
`${question} — confirmation required. Pass --force
|
|
131
|
-
|
|
162
|
+
`${question} — confirmation required. Pass --force only after your user approves this operation.`,
|
|
163
|
+
hint
|
|
132
164
|
);
|
|
133
165
|
}
|
|
134
166
|
|
|
@@ -152,12 +184,20 @@ export function isErrorCode(err, code) {
|
|
|
152
184
|
*
|
|
153
185
|
* Exit-code contract (observable behavior, pinned by tests):
|
|
154
186
|
* not_found → 1 stderr, "Error (not_found): …" + identifier hint
|
|
155
|
-
*
|
|
187
|
+
* extraction_not_found → 1 JSON envelope on stdout in JSON mode, else stderr
|
|
188
|
+
* usage / usage_error → 2 JSON envelope on stdout in JSON mode
|
|
189
|
+
* (machine consumers), else stderr "Error (usage): …"
|
|
156
190
|
* system_error → 3 stderr, "Error (system): …"
|
|
157
191
|
* confirmation_required → 1 JSON envelope on stdout in JSON mode
|
|
158
192
|
* (machine consumers), else stderr + hint
|
|
159
193
|
* anything else → 1 stderr, "Error: …"
|
|
160
194
|
*
|
|
195
|
+
* The JSON envelope is the SAME shape for every failure that a machine
|
|
196
|
+
* consumer can hit — guardError(), the mutation middleware, parse-time
|
|
197
|
+
* failures routed by cli.js, and handler-thrown AppErrors all end up here, so
|
|
198
|
+
* `--json` never yields human prose on stdout and never yields a
|
|
199
|
+
* success-shaped payload for a failed command.
|
|
200
|
+
*
|
|
161
201
|
* jsonMode resolution: prefer app.output.effectiveFormat() (respects
|
|
162
202
|
* --json/--quiet/etc. shortcuts AND piped-stdout auto-detection); fall
|
|
163
203
|
* back to argv.format for callers without an App (unit tests).
|
|
@@ -201,10 +241,35 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
|
|
|
201
241
|
return { stream: 'stderr', text, exitCode: 1 };
|
|
202
242
|
}
|
|
203
243
|
|
|
244
|
+
if (code === CodeExtractionNotFound) {
|
|
245
|
+
// Same contract as every other machine-consumable failure: a parseable
|
|
246
|
+
// envelope on stdout when the caller is in JSON mode, human text on
|
|
247
|
+
// stderr otherwise. Exit 1 — the command ran, the value was absent.
|
|
248
|
+
if (resolveJsonMode(app, argv) && app) {
|
|
249
|
+
return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
|
|
250
|
+
}
|
|
251
|
+
let text = `Error (${code}): ${err.message}\n`;
|
|
252
|
+
if (err.hint) text += `\nHint: ${err.hint}\n`;
|
|
253
|
+
return { stream: 'stderr', text, exitCode: 1 };
|
|
254
|
+
}
|
|
255
|
+
|
|
204
256
|
// 'usage' is the legacy literal; CodeUsage ('usage_error') is the
|
|
205
257
|
// exported constant. Both render as the usage class.
|
|
206
258
|
if (code === CodeUsage || code === 'usage') {
|
|
207
|
-
|
|
259
|
+
// Usage failures were the last split-brain case: a parse-time failure
|
|
260
|
+
// exited 1 with raw prose, a middleware guard exited 2 with a JSON
|
|
261
|
+
// envelope, and a handler-thrown usage error exited 2 with prose — all
|
|
262
|
+
// for the same class of mistake. One renderer, one exit code (2), and a
|
|
263
|
+
// structured envelope whenever the caller is in JSON mode.
|
|
264
|
+
if (resolveJsonMode(app, argv) && app) {
|
|
265
|
+
return { stream: 'stdout', text: null, exitCode: 2, appErr: true };
|
|
266
|
+
}
|
|
267
|
+
// Keep the actionable hint (the failing command's --help) in the human view
|
|
268
|
+
// too — it used to be dropped on this path while every other error class
|
|
269
|
+
// printed one.
|
|
270
|
+
let text = `Error (usage): ${err.message}\n`;
|
|
271
|
+
if (err.hint) text += `\nHint: ${err.hint}\n`;
|
|
272
|
+
return { stream: 'stderr', text, exitCode: 2 };
|
|
208
273
|
}
|
|
209
274
|
|
|
210
275
|
if (code === 'system_error') {
|