@jacebenson/jsn 4.3.7-dev.40 → 4.3.7-dev.43
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/package.json +1 -1
- package/src/app.js +8 -1
- package/src/cli-adapter.js +96 -4
- package/src/cli.js +101 -5
- package/src/commands/auth.js +21 -11
- package/src/commands/cmdb.js +18 -0
- package/src/commands/docs/docs.js +18 -16
- package/src/commands/groupmembers.js +19 -4
- package/src/commands/grouproles.js +15 -4
- package/src/commands/perf.js +30 -9
- package/src/commands/platform.js +17 -0
- package/src/commands/records.js +19 -15
- package/src/commands/skill.js +5 -2
- package/src/errors.js +69 -9
- package/src/flow-create.js +26 -4
- package/src/flow-inspection.js +15 -5
- package/src/flow-test.js +6 -6
- package/src/flow-triggers.js +53 -0
- package/src/help.js +93 -28
- package/src/helpers.js +109 -9
- package/src/output.js +7 -0
package/src/commands/platform.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { formatCommandList } from '../helpers.js';
|
|
2
3
|
|
|
3
4
|
function numberOrString(value) {
|
|
4
5
|
const n = Number(value);
|
|
@@ -270,6 +271,22 @@ export function platformCmd(wrap) {
|
|
|
270
271
|
return {
|
|
271
272
|
command: 'platform',
|
|
272
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
|
+
}),
|
|
273
290
|
builder: (y) => y.command({
|
|
274
291
|
command: 'health',
|
|
275
292
|
describe: 'Show cluster, queue, semaphore, and node health',
|
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 = [];
|
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,15 +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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
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) {
|
|
210
265
|
return { stream: 'stdout', text: null, exitCode: 2, appErr: true };
|
|
211
266
|
}
|
|
212
|
-
|
|
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 };
|
|
213
273
|
}
|
|
214
274
|
|
|
215
275
|
if (code === 'system_error') {
|
package/src/flow-create.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { assertSafeExactMatch } from './helpers.js';
|
|
3
3
|
import { createVerifiedFlowVersion } from './flow-versioning.js';
|
|
4
4
|
import {
|
|
5
|
+
RECORD_TRIGGER_FAMILY,
|
|
5
6
|
SUPPORTED_TRIGGER_TYPES,
|
|
6
7
|
TRIGGER_DEFINITION_TABLE,
|
|
7
8
|
buildTriggerInputs,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
readTriggerInputDefinitions,
|
|
12
13
|
tableInputFor,
|
|
13
14
|
flowDesignParameter,
|
|
15
|
+
triggerFamily,
|
|
14
16
|
} from './flow-triggers.js';
|
|
15
17
|
|
|
16
18
|
const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
@@ -264,7 +266,11 @@ export function buildTypedInputs(action, schema) {
|
|
|
264
266
|
throw new Error(`Action ${action.type} input "${field.name}" has a malformed parameter block; refusing to change it`);
|
|
265
267
|
}
|
|
266
268
|
const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
|
|
267
|
-
|
|
269
|
+
const parameter = field.parameter || {};
|
|
270
|
+
if ((suppliedValue === undefined || suppliedValue === null)
|
|
271
|
+
&& (field.mandatory === true || field.required === true || parameter.mandatory === true || parameter.required === true)) {
|
|
272
|
+
throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
|
|
273
|
+
}
|
|
268
274
|
const value = suppliedValue ?? null;
|
|
269
275
|
const displayValue = field.displayValue ?? field.display_value ?? value;
|
|
270
276
|
// global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
|
|
@@ -277,13 +283,29 @@ export function buildTypedInputs(action, schema) {
|
|
|
277
283
|
children: Array.isArray(field.children) ? field.children : [],
|
|
278
284
|
displayField: stringValue(field.displayField || field.display_field || ''),
|
|
279
285
|
scriptActive: false,
|
|
280
|
-
parameter:
|
|
286
|
+
parameter: actionInputParameter(field),
|
|
281
287
|
value: { schemaless: false, schemalessValue: '', value },
|
|
282
288
|
displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
|
|
283
289
|
};
|
|
284
290
|
});
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
// Providers hand back the input metadata in one of two shapes: nested inside a
|
|
294
|
+
// `parameter` block (the earlier contract) or FLAT on the input object (the real
|
|
295
|
+
// published Log action's action_types route). The nested block wins when it
|
|
296
|
+
// exists, flat keys fill in only what it leaves empty, and the result is always
|
|
297
|
+
// narrowed to the fields global_snFlowDesigner_FlowDesignParameter accepts.
|
|
298
|
+
export function actionInputParameter(field) {
|
|
299
|
+
if (field?.parameter === undefined) return flowDesignParameter(field);
|
|
300
|
+
const parameter = flowDesignParameter(field.parameter);
|
|
301
|
+
const flat = flowDesignParameter(field);
|
|
302
|
+
for (const key of Object.keys(flat)) {
|
|
303
|
+
if (flat[key] === undefined || flat[key] === '') continue;
|
|
304
|
+
if (parameter[key] === undefined || parameter[key] === '') parameter[key] = flat[key];
|
|
305
|
+
}
|
|
306
|
+
return parameter;
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
function parseSimpleCondition(condition) {
|
|
288
310
|
if (!condition) return null;
|
|
289
311
|
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_.]*)(STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)([^,^]*)$/);
|
|
@@ -552,8 +574,8 @@ function updatedTrigger(flow, manifest, descriptor) {
|
|
|
552
574
|
if (!existing) throw new Error('Saved flow has no trigger instance');
|
|
553
575
|
const existingMetadata = parseMaybeJson(existing.metadata, null);
|
|
554
576
|
if (existing.metadata && (!existingMetadata || typeof existingMetadata !== 'object' || Array.isArray(existingMetadata))) throw new Error('Saved record trigger metadata is unreadable; refusing to replace it');
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
577
|
+
const existingFamily = triggerFamily(existing);
|
|
578
|
+
if (existingFamily && existingFamily !== RECORD_TRIGGER_FAMILY) throw new Error(`Only ${RECORD_TRIGGER_FAMILY} trigger updates are supported; found ${existingFamily}`);
|
|
557
579
|
const inputs = clone(inputList(existing));
|
|
558
580
|
const tableInput = inputs.find(input => input.name === 'table');
|
|
559
581
|
const conditionInput = inputs.find(input => input.name === 'condition');
|
package/src/flow-inspection.js
CHANGED
|
@@ -298,11 +298,7 @@ function buildLabelCache(payload) {
|
|
|
298
298
|
if (!payload || typeof payload !== 'object') return cache;
|
|
299
299
|
let entries = payload.label_cache;
|
|
300
300
|
if (!Array.isArray(entries)) {
|
|
301
|
-
|
|
302
|
-
entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
|
|
303
|
-
} catch {
|
|
304
|
-
entries = [];
|
|
305
|
-
}
|
|
301
|
+
entries = parseLabelCacheJson(payload.labelCacheAsJsonString);
|
|
306
302
|
}
|
|
307
303
|
for (const e of entries) {
|
|
308
304
|
if (!e || typeof e !== 'object') continue;
|
|
@@ -313,6 +309,20 @@ function buildLabelCache(payload) {
|
|
|
313
309
|
return cache;
|
|
314
310
|
}
|
|
315
311
|
|
|
312
|
+
// labelCacheAsJsonString is a JSON document, and JSON `null` or `{}` parses
|
|
313
|
+
// successfully into something that is not iterable — that threw
|
|
314
|
+
// "entries is not iterable" and broke `flows show` on real drafts whose cache is
|
|
315
|
+
// empty. An empty cache simply has no labels.
|
|
316
|
+
function parseLabelCacheJson(value) {
|
|
317
|
+
if (typeof value !== 'string' || !value.trim()) return [];
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(value);
|
|
320
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
|
|
317
327
|
|
|
318
328
|
/**
|
package/src/flow-test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertSafeExactMatch } from './helpers.js';
|
|
2
|
+
import { RECORD_TRIGGER_FAMILY, triggerFamily } from './flow-triggers.js';
|
|
2
3
|
|
|
3
4
|
const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
4
5
|
|
|
@@ -30,15 +31,14 @@ function inputValue(trigger, name) {
|
|
|
30
31
|
return valueOf(input?.value ?? input?.displayValue);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function triggerType(trigger) {
|
|
34
|
-
return valueOf(trigger?.triggerType || trigger?.trigger_type || trigger?.type);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
export function deriveRecordTrigger(definition) {
|
|
38
35
|
const trigger = findTrigger(definition);
|
|
39
36
|
if (!trigger) throw new Error('Flow definition has no trigger instance');
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// `type` is the family; `triggerType` is only the picker category ("Record").
|
|
38
|
+
// triggerFamily keeps a deliberate legacy fallback for saved instances that
|
|
39
|
+
// predate the split.
|
|
40
|
+
const type = triggerFamily(trigger);
|
|
41
|
+
if (type !== RECORD_TRIGGER_FAMILY) throw new Error(`Flow test supports only ${RECORD_TRIGGER_FAMILY} triggers; found ${type || 'unknown'}`);
|
|
42
42
|
const table = inputValue(trigger, 'table');
|
|
43
43
|
if (!table) throw new Error('Flow definition record trigger has no table');
|
|
44
44
|
return { trigger, type, table };
|
package/src/flow-triggers.js
CHANGED
|
@@ -358,6 +358,59 @@ export function buildTriggerInstance({ flowId, definition, triggerType = '', inp
|
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
export const RECORD_TRIGGER_FAMILY = 'record_create_or_update';
|
|
362
|
+
|
|
363
|
+
// A trigger instance carries the FAMILY and the picker CATEGORY in two different
|
|
364
|
+
// fields: `type` is the family Flow Designer executes ("record_create_or_update",
|
|
365
|
+
// "daily", …) and `triggerType` is the category display name ("Record",
|
|
366
|
+
// "Scheduled", …). Creation writes the family to `type` (resolveTriggerDefinition
|
|
367
|
+
// reads it from sys_hub_trigger_definition), and the live ProcessFlow read-back
|
|
368
|
+
// returns exactly that shape, so `type` is authoritative.
|
|
369
|
+
//
|
|
370
|
+
// Instances and fixtures that predate that split stored the family in
|
|
371
|
+
// `triggerType` (and sometimes inside the raw metadata block), so those stay as
|
|
372
|
+
// deliberate fallbacks — otherwise a real saved trigger reads as the category
|
|
373
|
+
// "Record" and every consumer rejects it.
|
|
374
|
+
export function triggerFamily(source) {
|
|
375
|
+
const metadata = parsedObject(source?.metadata);
|
|
376
|
+
const candidates = [
|
|
377
|
+
source?.type,
|
|
378
|
+
source?.trigger_type,
|
|
379
|
+
metadata?.type,
|
|
380
|
+
metadata?.trigger_type,
|
|
381
|
+
source?.triggerType,
|
|
382
|
+
metadata?.triggerType,
|
|
383
|
+
];
|
|
384
|
+
for (const candidate of candidates) {
|
|
385
|
+
const family = familyValue(candidate);
|
|
386
|
+
if (family) return family;
|
|
387
|
+
}
|
|
388
|
+
return '';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function parsedObject(value) {
|
|
392
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
393
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(value);
|
|
396
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function familyValue(value) {
|
|
403
|
+
if (value === null || value === undefined) return '';
|
|
404
|
+
if (typeof value === 'object') {
|
|
405
|
+
for (const key of ['value', 'display_value', 'sys_id']) {
|
|
406
|
+
const nested = familyValue(value[key]);
|
|
407
|
+
if (nested) return nested;
|
|
408
|
+
}
|
|
409
|
+
return '';
|
|
410
|
+
}
|
|
411
|
+
return String(value).trim();
|
|
412
|
+
}
|
|
413
|
+
|
|
361
414
|
// The snFlowDesigner GraphQL input types take `attributes` as a String
|
|
362
415
|
// ("key=value,key=value") even though the ProcessFlow read path renders it as an
|
|
363
416
|
// object. Sending the object fails validation: "not a valid 'String'".
|
package/src/help.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
// Custom grouped help renderer
|
|
2
2
|
// Organized by ServiceNow domain model with subgroup dividers
|
|
3
|
+
//
|
|
4
|
+
// The command rows are generated from the LIVE command registry
|
|
5
|
+
// (`cli.commandRegistry()`): every registered root command appears, with the
|
|
6
|
+
// description its own registration declares. The tables below are placement
|
|
7
|
+
// hints only — group order, dividers, and a fallback description for a command
|
|
8
|
+
// that declares none. A command that is registered but not hinted lands in the
|
|
9
|
+
// trailing "MORE" group, so help can never silently drop a command again
|
|
10
|
+
// (`approvals`, `cmdb`, `docs`, `perf`, `platform`, … were missing from the
|
|
11
|
+
// hand-written list while being fully registered).
|
|
3
12
|
|
|
4
13
|
function renderGroup(name, commands) {
|
|
5
14
|
const lines = [`\n${name}`];
|
|
@@ -23,6 +32,8 @@ const CORE_COMMANDS = [
|
|
|
23
32
|
{ name: 'changes', alias: 'chg', desc: 'Manage change requests' },
|
|
24
33
|
{ name: 'requests', alias: 'req', desc: 'Manage service catalog requests' },
|
|
25
34
|
{ name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
|
|
35
|
+
{ name: 'tickets', desc: 'Query generic tickets' },
|
|
36
|
+
{ name: 'approvals', desc: 'Approval requests (list, approve, reject, submit, history)' },
|
|
26
37
|
];
|
|
27
38
|
|
|
28
39
|
const AUTOMATION_COMMANDS = [
|
|
@@ -96,6 +107,7 @@ const DATA_COMMANDS = [
|
|
|
96
107
|
{ name: 'tables', alias: 't', desc: 'Table definitions' },
|
|
97
108
|
{ name: 'columns', alias: 'col', desc: 'Column definitions' },
|
|
98
109
|
{ name: 'relationships', alias: 'rel', desc: 'Table relationships' },
|
|
110
|
+
{ name: 'cmdb', desc: 'CMDB: CI lookup and relationship traversal (read-only)' },
|
|
99
111
|
|
|
100
112
|
{ divider: '── Shared Code, Transforms, Logs ──' },
|
|
101
113
|
{ name: 'includes', alias: 'si', desc: 'Script includes' },
|
|
@@ -104,6 +116,8 @@ const DATA_COMMANDS = [
|
|
|
104
116
|
{ name: 'properties', alias: 'prop', desc: 'System properties' },
|
|
105
117
|
|
|
106
118
|
{ name: 'records', desc: 'Generic Table API (any table)' },
|
|
119
|
+
{ name: 'transactions', desc: 'Client transaction timings' },
|
|
120
|
+
{ name: 'snippets', desc: 'Saved query snippets' },
|
|
107
121
|
];
|
|
108
122
|
|
|
109
123
|
const CONFIG_COMMANDS = [
|
|
@@ -112,12 +126,20 @@ const CONFIG_COMMANDS = [
|
|
|
112
126
|
{ name: 'updatesets', desc: 'Update sets (set, create, export)' },
|
|
113
127
|
{ name: 'scopes', desc: 'Application scopes' },
|
|
114
128
|
{ name: 'domains', desc: 'Domains (domain separation)' },
|
|
129
|
+
{ name: 'midservers', desc: 'MID Servers and MID-run commands' },
|
|
130
|
+
{ name: 'platform', desc: 'Platform health and node statistics (read-only)' },
|
|
131
|
+
{ name: 'completion', desc: 'Shell completion script' },
|
|
115
132
|
];
|
|
116
133
|
|
|
117
134
|
const DEVELOPER_COMMANDS = [
|
|
118
135
|
{ name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
|
|
119
136
|
{ name: 'eval', desc: 'Run server-side JavaScript' },
|
|
120
137
|
{ name: 'rest', desc: 'Raw REST API calls' },
|
|
138
|
+
{ name: 'graphql', desc: 'GraphQL queries' },
|
|
139
|
+
{ name: 'codesearch', desc: 'Search code across ServiceNow artifacts' },
|
|
140
|
+
{ name: 'docs', desc: 'Local ServiceNow documentation search' },
|
|
141
|
+
{ name: 'diff', desc: 'Compare records across configured profiles' },
|
|
142
|
+
{ name: 'perf', desc: 'Read-only performance capture and comparison' },
|
|
121
143
|
{ name: 'skill', desc: 'AI agent skill file (show, fetch, install)' },
|
|
122
144
|
{ name: 'version', desc: 'Version info (--check for npm updates)' },
|
|
123
145
|
];
|
|
@@ -132,17 +154,50 @@ const COMMAND_GROUPS = {
|
|
|
132
154
|
'DEVELOPER': DEVELOPER_COMMANDS,
|
|
133
155
|
};
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Domain separation is an optional plugin — only advertise `domains` when the
|
|
159
|
+
* active profile's capability probe found it installed.
|
|
160
|
+
*/
|
|
161
|
+
function isCapabilityHidden(name, config) {
|
|
162
|
+
if (name !== 'domains') return false;
|
|
163
|
+
const activeName = config?.activeProfile || config?.defaultProfile;
|
|
164
|
+
return !(activeName && config?.profiles?.[activeName]?.domain_separation);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Group rows for one domain: registry first (alias/description from the
|
|
169
|
+
* command's own registration), hint text only as a fallback description.
|
|
170
|
+
*/
|
|
171
|
+
function rowsFor(hint, byName, used, config) {
|
|
172
|
+
const rows = [];
|
|
173
|
+
for (const entry of hint) {
|
|
174
|
+
if (entry.divider) { rows.push(entry); continue; }
|
|
175
|
+
const node = byName.get(entry.name);
|
|
176
|
+
if (!node || isCapabilityHidden(node.name, config)) continue;
|
|
177
|
+
used.add(node.name);
|
|
178
|
+
rows.push({
|
|
179
|
+
name: node.name,
|
|
180
|
+
alias: node.aliases[0] || entry.alias || '',
|
|
181
|
+
desc: node.description || entry.desc || '',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return rows;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderFlags(options) {
|
|
188
|
+
// Derived from the registered global options, so a new global flag cannot be
|
|
189
|
+
// missing from help (--get and --csv were, along with the csv format choice).
|
|
190
|
+
const lines = [];
|
|
191
|
+
for (const spec of options) {
|
|
192
|
+
if (spec.name === 'help') continue;
|
|
193
|
+
const alias = spec.alias.length ? `-${spec.alias.join(', -')}, ` : '';
|
|
194
|
+
const suffix = spec.name === 'format' && spec.choices.length
|
|
195
|
+
? ` [string: ${spec.choices.join(', ')}]`
|
|
196
|
+
: ` [${spec.type}]`;
|
|
197
|
+
lines.push(` ${`${alias}--${spec.name}`.padEnd(24)}${spec.description}${suffix}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push(` ${'--help'.padEnd(24)}Show help${' [boolean]'}`);
|
|
200
|
+
return `\nFLAGS\n${lines.join('\n')}`;
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
function renderTips() {
|
|
@@ -150,35 +205,45 @@ function renderTips() {
|
|
|
150
205
|
TIPS
|
|
151
206
|
--query is available on every list command (e.g. "incidents list --query priority=1")
|
|
152
207
|
Use "jsn <command> --help" for details, or "jsn <command> list --help" for list options
|
|
208
|
+
"jsn --json" prints the registered command surface as JSON for scripts and agents
|
|
153
209
|
|
|
154
210
|
LEARN MORE
|
|
155
211
|
Use "jsn <command> --help" for more information about a command.`;
|
|
156
212
|
}
|
|
157
213
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Render the grouped root help.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} config — profile config (capability-conditioned hiding)
|
|
218
|
+
* @param {{commands: Array, options: Array}} [registry] — from
|
|
219
|
+
* `cli.commandRegistry()`. Without it only the hint tables render, which is
|
|
220
|
+
* the fallback for callers that have no registry (unit tests).
|
|
221
|
+
*/
|
|
222
|
+
export function renderHelp(config, registry = null) {
|
|
223
|
+
const commands = Array.isArray(registry?.commands) ? registry.commands : [];
|
|
224
|
+
const byName = new Map(commands.map((command) => [command.name, command]));
|
|
225
|
+
const used = new Set();
|
|
226
|
+
|
|
227
|
+
const sections = Object.entries(COMMAND_GROUPS)
|
|
228
|
+
.map(([name, hint]) => [name, rowsFor(hint, byName, used, config)])
|
|
229
|
+
.filter(([, rows]) => rows.some((row) => !row.divider))
|
|
230
|
+
.map(([name, rows]) => renderGroup(name, rows));
|
|
231
|
+
|
|
232
|
+
// Anything registered that the hint tables don't place. This is the safety
|
|
233
|
+
// net that keeps help complete as commands are added at the root.
|
|
234
|
+
const unplaced = commands
|
|
235
|
+
.filter((command) => !used.has(command.name) && !isCapabilityHidden(command.name, config))
|
|
236
|
+
.map((command) => ({ name: command.name, alias: command.aliases[0] || '', desc: command.description || '' }));
|
|
237
|
+
if (unplaced.length) sections.push(renderGroup('MORE', unplaced));
|
|
173
238
|
|
|
174
239
|
return [
|
|
175
240
|
'Usage: jsn <command> [options]',
|
|
176
241
|
'',
|
|
177
|
-
|
|
242
|
+
'Command-line interface for ServiceNow',
|
|
178
243
|
'',
|
|
179
244
|
...sections,
|
|
180
245
|
'',
|
|
181
|
-
renderFlags(),
|
|
246
|
+
renderFlags(Array.isArray(registry?.options) ? registry.options : []),
|
|
182
247
|
renderTips(),
|
|
183
248
|
'',
|
|
184
249
|
].join('\n');
|