@jacebenson/jsn 4.3.7-dev.14 → 4.3.7-dev.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,10 @@ jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b p
81
81
  jsn eval "gs.info('Hello World')"
82
82
  cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
83
83
 
84
+ # Now GraphQL (documents may contain mutations; read-only profiles are blocked)
85
+ jsn graphql --query '{ GlideRecord_Query { incident(pagination: {limit: 2}) { _results { number { value } } } } }'
86
+ cat incident.graphql | jsn graphql --stdin --variables '{"limit":2}'
87
+
84
88
  # Saved query snippets (stored locally, run against the active profile)
85
89
  jsn snippets save open-inc --table incident --query "active=true"
86
90
  jsn snippets run open-inc
@@ -117,6 +121,8 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
117
121
 
118
122
  **Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `diff record` (cross-profile read-only comparison), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
119
123
 
124
+ **Platform** — `platform health` (cluster records) and `platform stats` (bounded, read-only `/xmlstats.do` summary; allowlisted metrics only)
125
+
120
126
  Every command supports `--json`, `--query`, and `--help`.
121
127
 
122
128
  ## Instances
@@ -169,6 +175,10 @@ jsn incidents list
169
175
 
170
176
  For the credential identity, auth-method, diagnostics, migration, and concurrency contract, see [`docs/AUTH_LIFECYCLE.md`](docs/AUTH_LIFECYCLE.md).
171
177
 
178
+ GraphQL uses ServiceNow's `/api/now/graphql` endpoint and sends `{query, variables}` through the authenticated SDK transport. The command preserves GraphQL `data` and `errors`; introspection and available namespaces depend on the target instance.
179
+
180
+ `jsn platform stats` uses a fixed `GET /xmlstats.do` request. It never accepts a processor path, query parameters, or request body, and it does not emit raw XML, cookies, credentials, or full node identifiers. Oversized, HTML/login, permission, unsupported, timeout, and malformed responses are reported with bounded collector statuses; missing metrics remain unavailable rather than being reported as zero.
181
+
172
182
  ## Local data
173
183
 
174
184
  JSN stores durable local data under `~/.jsn/`. Documentation lives in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.14",
3
+ "version": "4.3.7-dev.16",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -70,6 +70,7 @@ import { atfCmd } from './commands/atf.js';
70
70
  import { approvalsCmd } from './commands/approvals.js';
71
71
  import { perfCmd } from './commands/perf.js';
72
72
  import { diffCmd } from './commands/diff.js';
73
+ import { graphqlCmd } from './commands/graphql.js';
73
74
 
74
75
  function wrap(handler) {
75
76
  return async (argv) => {
@@ -373,6 +374,7 @@ export function buildCLI() {
373
374
  .command(approvalsCmd(wrap))
374
375
  .command(perfCmd(wrap))
375
376
  .command(diffCmd(wrap))
377
+ .command(graphqlCmd(wrap))
376
378
  .command(evalCmd(wrap))
377
379
  .command(restCmd(wrap))
378
380
  .command(skillCmd(wrap))
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import { declareCapabilities } from '../capabilities.js';
3
+
4
+ // GraphQL documents may contain mutations. Gate the whole command so normal
5
+ // instance/auth and read-only protections apply without attempting AST parsing.
6
+ declareCapabilities('graphql', { mutationSubcommands: [''] });
7
+
8
+ function readStdin() {
9
+ return new Promise((resolve, reject) => {
10
+ let data = '';
11
+ process.stdin.setEncoding('utf-8');
12
+ process.stdin.on('data', (chunk) => { data += chunk; });
13
+ process.stdin.on('end', () => resolve(data));
14
+ process.stdin.on('error', reject);
15
+ });
16
+ }
17
+
18
+ export async function resolveGraphQLInput(argv) {
19
+ const sources = [
20
+ argv.document !== undefined,
21
+ argv.query !== undefined,
22
+ argv.queryFile !== undefined,
23
+ argv.stdin === true,
24
+ ].filter(Boolean).length;
25
+ if (sources !== 1) {
26
+ throw new Error('Provide exactly one GraphQL document via positional document, --query, --query-file, or --stdin');
27
+ }
28
+
29
+ let query;
30
+ if (argv.document !== undefined) query = argv.document;
31
+ else if (argv.query !== undefined) query = argv.query;
32
+ else if (argv.queryFile !== undefined) query = fs.readFileSync(argv.queryFile, 'utf-8');
33
+ else query = await readStdin();
34
+
35
+ query = String(query || '').trim();
36
+ if (!query) throw new Error('GraphQL document must not be empty');
37
+ return query;
38
+ }
39
+
40
+ export function parseGraphQLVariables(value) {
41
+ if (value === undefined) return {};
42
+ let variables;
43
+ try {
44
+ variables = JSON.parse(value);
45
+ } catch {
46
+ throw new Error('--variables must be valid JSON');
47
+ }
48
+ if (!variables || typeof variables !== 'object' || Array.isArray(variables)) {
49
+ throw new Error('--variables must be a JSON object');
50
+ }
51
+ return variables;
52
+ }
53
+
54
+ export function graphqlCmd(wrap) {
55
+ return {
56
+ command: 'graphql [document]',
57
+ describe: 'Execute a GraphQL document against ServiceNow Now GraphQL',
58
+ builder: (yargs) => yargs
59
+ .positional('document', {
60
+ describe: 'GraphQL document string',
61
+ type: 'string',
62
+ })
63
+ .option('query', {
64
+ type: 'string',
65
+ describe: 'GraphQL document string (alternative to positional)',
66
+ })
67
+ .option('query-file', {
68
+ type: 'string',
69
+ describe: 'Read the GraphQL document from a file',
70
+ })
71
+ .option('variables', {
72
+ type: 'string',
73
+ describe: 'GraphQL variables as a JSON object',
74
+ })
75
+ .option('stdin', {
76
+ type: 'boolean',
77
+ describe: 'Read the GraphQL document from stdin',
78
+ }),
79
+ handler: wrap(async (argv, app) => {
80
+ const query = await resolveGraphQLInput(argv);
81
+ const variables = parseGraphQLVariables(argv.variables);
82
+ const endpoint = `${app.getEffectiveInstance()}/api/now/graphql`;
83
+ const result = await app.sdk.request(endpoint, {
84
+ method: 'POST',
85
+ body: JSON.stringify({ query, variables }),
86
+ });
87
+ const errorCount = Array.isArray(result?.errors) ? result.errors.length : 0;
88
+ app.ok(result, {
89
+ summary: errorCount
90
+ ? `GraphQL returned ${errorCount} error(s)`
91
+ : 'GraphQL request succeeded',
92
+ });
93
+ }),
94
+ };
95
+ }
@@ -1,3 +1,5 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+
1
3
  function numberOrString(value) {
2
4
  const n = Number(value);
3
5
  return value !== '' && Number.isFinite(n) ? n : value;
@@ -24,6 +26,179 @@ function statsForWindow(xml, parent, window) {
24
26
  return tagAttributes(parentMatch[1], window);
25
27
  }
26
28
 
29
+ export const XMLSTATS_MAX_BYTES = 4 * 1024 * 1024;
30
+
31
+ function metric(value) {
32
+ if (value === undefined || value === null || String(value).trim() === '') {
33
+ return { value: null, availability: 'unavailable' };
34
+ }
35
+ return { value: numberOrString(String(value).trim()), availability: 'available' };
36
+ }
37
+
38
+ function firstNestedText(xml, parent, child) {
39
+ const parentMatch = xml.match(new RegExp(`<${parent}\\b[^>]*>([\\s\\S]*?)</${parent}>`));
40
+ return parentMatch ? tagText(parentMatch[1], child) : undefined;
41
+ }
42
+
43
+ function allowlistedStats(xml) {
44
+ const unavailable = [];
45
+ const add = (path, value) => {
46
+ const result = metric(value);
47
+ if (result.availability === 'unavailable') unavailable.push(path);
48
+ return result;
49
+ };
50
+
51
+ const cluster = xml.match(/<cluster\.status\b[^>]*>([\s\S]*?)<\/cluster\.status>/);
52
+ const nodeXml = cluster?.[1]?.match(/<node\b[^>]*>([\s\S]*?)<\/node>/)?.[1] || '';
53
+ const daily = statsForWindow(xml, 'all_transactions', 'daily') || {};
54
+ const semaphores = [];
55
+ for (const match of xml.matchAll(/<semaphores([^>]*)\/>/g)) {
56
+ const attrs = {};
57
+ for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) attrs[key] = value;
58
+ if (!attrs.name && !attrs.available && !attrs.borrowed && !attrs.maximum_concurrency && !attrs.queue_depth) continue;
59
+ semaphores.push({
60
+ name: attrs.name || 'unnamed',
61
+ available: add(`semaphores[${attrs.name || 'unnamed'}].available`, attrs.available),
62
+ borrowed: add(`semaphores[${attrs.name || 'unnamed'}].borrowed`, attrs.borrowed),
63
+ maximum: add(`semaphores[${attrs.name || 'unnamed'}].maximum`, attrs.maximum_concurrency),
64
+ queue_depth: add(`semaphores[${attrs.name || 'unnamed'}].queue_depth`, attrs.queue_depth),
65
+ });
66
+ }
67
+ if (semaphores.length === 0) unavailable.push('semaphores');
68
+
69
+ return {
70
+ node: {
71
+ status: add('node.status', tagText(nodeXml, 'status')),
72
+ type: add('node.type', firstNestedText(nodeXml, 'node_type', 'name')),
73
+ },
74
+ queue: {
75
+ length: add('queue.length', tagText(xml, 'queue.length')),
76
+ age: add('queue.age', tagText(xml, 'queue.age')),
77
+ },
78
+ active_sessions: add('active_sessions', tagText(xml, 'servlet.active.sessions')),
79
+ memory_pressure: add('memory_pressure', tagText(xml, 'memory_pressure')),
80
+ semaphores,
81
+ transactions: {
82
+ daily: {
83
+ count: add('transactions.daily.count', daily.count),
84
+ mean: add('transactions.daily.mean', daily.mean),
85
+ median: add('transactions.daily.median', daily.median),
86
+ ninetypercent: add('transactions.daily.ninetypercent', daily.ninetypercent),
87
+ max: add('transactions.daily.max', daily.max),
88
+ },
89
+ },
90
+ unavailable,
91
+ };
92
+ }
93
+
94
+ export function parsePlatformStatsXml(xml) {
95
+ const source = String(xml || '');
96
+ if (Buffer.byteLength(source) > XMLSTATS_MAX_BYTES) throw new Error('xmlstats response exceeds the bounded size limit');
97
+ if (!/^\s*(?:<\?xml[\s\S]*?\?>\s*)?<xmlstats\b[^>]*>/i.test(source)
98
+ || !/<\/xmlstats>\s*$/i.test(source)) {
99
+ throw new Error('Malformed XML: expected an xmlstats document');
100
+ }
101
+ const stack = [];
102
+ for (const match of source.matchAll(/<\/?([A-Za-z][\w.:-]*)(?:\s[^>]*)?>/g)) {
103
+ const token = match[0];
104
+ const name = match[1];
105
+ if (/^<\//.test(token)) {
106
+ if (stack.pop() !== name) throw new Error('Malformed XML: mismatched tags');
107
+ } else if (!/\/\s*>$/.test(token)) {
108
+ stack.push(name);
109
+ }
110
+ }
111
+ if (stack.length !== 0) throw new Error('Malformed XML: unclosed tags');
112
+ return allowlistedStats(source);
113
+ }
114
+
115
+ async function readBoundedResponse(response, maxBytes = XMLSTATS_MAX_BYTES) {
116
+ if (!response.body?.getReader) {
117
+ const text = await response.text();
118
+ return Buffer.byteLength(text) > maxBytes ? { tooLarge: true, bytes: Buffer.byteLength(text) } : { text, bytes: Buffer.byteLength(text) };
119
+ }
120
+ const reader = response.body.getReader();
121
+ const chunks = [];
122
+ let bytes = 0;
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+ bytes += value.byteLength;
127
+ if (bytes > maxBytes) {
128
+ await reader.cancel();
129
+ return { tooLarge: true, bytes };
130
+ }
131
+ chunks.push(value);
132
+ }
133
+ const body = new Uint8Array(bytes);
134
+ let offset = 0;
135
+ for (const chunk of chunks) {
136
+ body.set(chunk, offset);
137
+ offset += chunk.byteLength;
138
+ }
139
+ return { text: new TextDecoder().decode(body), bytes };
140
+ }
141
+
142
+ function looksLikeLoginHtml(response, body) {
143
+ const contentType = response.headers?.get?.('content-type') || '';
144
+ return /text\/html/i.test(contentType) || /^\s*<(?:!doctype\s+html|html\b)/i.test(body);
145
+ }
146
+
147
+ export async function collectPlatformStats(sdk, instance) {
148
+ let response;
149
+ try {
150
+ response = await sdk.fetchResponse(`${instance}/xmlstats.do`, {
151
+ method: 'GET',
152
+ headers: { Accept: 'application/xml,text/xml' },
153
+ });
154
+ } catch (error) {
155
+ const message = String(error?.message || '').toLowerCase();
156
+ return { status: /timeout|timed out/.test(message) ? 'timeout' : 'unavailable', reason: /timeout|timed out/.test(message) ? 'request timed out' : 'request failed' };
157
+ }
158
+
159
+ if (response.status === 401 || response.status === 403) return { status: 'permission_denied', reason: `HTTP ${response.status}` };
160
+ if (response.status === 404 || response.status === 405) return { status: 'unsupported', reason: `HTTP ${response.status}` };
161
+ if (!response.ok) return { status: 'unavailable', reason: `HTTP ${response.status}` };
162
+
163
+ const body = await readBoundedResponse(response);
164
+ if (body.tooLarge) return { status: 'too_large', reason: `response exceeded ${XMLSTATS_MAX_BYTES} bytes`, bytes: body.bytes };
165
+ if (looksLikeLoginHtml(response, body.text)) return { status: 'login_redirect', reason: 'received HTML instead of xmlstats XML', bytes: body.bytes };
166
+
167
+ try {
168
+ const metrics = parsePlatformStatsXml(body.text);
169
+ const status = metrics.unavailable.length > 0 ? 'partial' : 'success';
170
+ return {
171
+ status,
172
+ reason: status === 'partial' ? 'some allowlisted metrics were unavailable' : null,
173
+ bytes: body.bytes,
174
+ metrics,
175
+ };
176
+ } catch {
177
+ return { status: 'malformed_xml', reason: 'response was not a valid xmlstats document', bytes: body.bytes };
178
+ }
179
+ }
180
+
181
+ function metricText(item) {
182
+ return item?.availability === 'available' ? String(item.value) : 'unavailable';
183
+ }
184
+
185
+ export function formatPlatformStats(result) {
186
+ const lines = [`STATUS: ${result.status}`];
187
+ if (result.reason) lines.push(`REASON: ${result.reason}`);
188
+ const m = result.metrics;
189
+ if (!m) return `${lines.join('\\n')}\\n`;
190
+ lines.push(`NODE: ${metricText(m.node.status)} / ${metricText(m.node.type)}`);
191
+ lines.push(`QUEUE: ${metricText(m.queue.length)} length, ${metricText(m.queue.age)} age`);
192
+ lines.push(`SESSIONS: ${metricText(m.active_sessions)}`);
193
+ lines.push(`MEMORY PRESSURE: ${metricText(m.memory_pressure)}`);
194
+ 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`);
195
+ if (m.semaphores.length === 0) lines.push('SEMAPHORES: unavailable');
196
+ else for (const semaphore of m.semaphores) {
197
+ lines.push(`SEMAPHORE ${semaphore.name}: ${metricText(semaphore.available)} available, ${metricText(semaphore.borrowed)} borrowed, ${metricText(semaphore.maximum)} maximum, ${metricText(semaphore.queue_depth)} queue`);
198
+ }
199
+ return `${lines.join('\\n')}\\n`;
200
+ }
201
+
27
202
  export function parseNodeStatsXml(xml) {
28
203
  const source = String(xml || '');
29
204
  const result = {};
@@ -91,6 +266,7 @@ function formatHealth(nodes) {
91
266
  }
92
267
 
93
268
  export function platformCmd(wrap) {
269
+ declareCapabilities('platform', { mutationSubcommands: [] });
94
270
  return {
95
271
  command: 'platform',
96
272
  describe: 'Inspect read-only platform health and node statistics',
@@ -122,6 +298,15 @@ export function platformCmd(wrap) {
122
298
  }
123
299
  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
300
  }),
301
+ }).command({
302
+ command: 'stats',
303
+ describe: 'Show bounded, read-only platform statistics from xmlstats.do',
304
+ handler: wrap(async (argv, app) => {
305
+ app.requireInstance();
306
+ const result = await collectPlatformStats(app.sdk, app.getEffectiveInstance());
307
+ result._formatted = formatPlatformStats(result);
308
+ app.ok(result, { summary: `Platform stats: ${result.status}` });
309
+ }),
125
310
  }),
126
311
  };
127
312
  }