@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/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');
|
package/src/helpers.js
CHANGED
|
@@ -32,6 +32,93 @@ export function canPrompt() {
|
|
|
32
32
|
return isTTY(process.stdout) && isTTY(process.stdin);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* True when an interactive picker may be opened for THIS invocation.
|
|
37
|
+
*
|
|
38
|
+
* canPrompt() alone answers "is a human reachable" — it does not know that
|
|
39
|
+
* the caller asked for machine output (`--json`, `--quiet`, `--csv`, …) or
|
|
40
|
+
* a one-shot extraction (`--get`). Opening a picker in either case blocks a
|
|
41
|
+
* script and corrupts the requested output shape, so every picker gate
|
|
42
|
+
* routes through here.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} app — app instance (uses app.output when present)
|
|
45
|
+
* @param {object} [argv] — parsed args (checks argv.get)
|
|
46
|
+
* @returns {boolean} true when a picker may be shown
|
|
47
|
+
*/
|
|
48
|
+
export function canUsePicker(app, argv = {}) {
|
|
49
|
+
if (argv && argv.get) return false;
|
|
50
|
+
if (!canPrompt()) return false;
|
|
51
|
+
const writer = app?.output;
|
|
52
|
+
if (!writer) return true; // no output controller (unit-test app) — canPrompt decided
|
|
53
|
+
const format = typeof writer.getFormat === 'function' ? writer.getFormat() : undefined;
|
|
54
|
+
if (!format) return true;
|
|
55
|
+
return format === FormatAuto;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render a parent command's subcommand list for its default handler.
|
|
60
|
+
*
|
|
61
|
+
* Returned through the `_formatted` side channel so a terminal prints the list
|
|
62
|
+
* while `--json`/`--quiet` keep the structured envelope. A parent command that
|
|
63
|
+
* only sets a summary leaves terminal users with no way to discover children.
|
|
64
|
+
*/
|
|
65
|
+
export function formatCommandList({ title, commands, footer }) {
|
|
66
|
+
const width = Math.max(...commands.map(command => command.name.length));
|
|
67
|
+
const lines = [title, '', 'Available subcommands:'];
|
|
68
|
+
for (const command of commands) lines.push(` ${command.name.padEnd(width)} ${command.description}`);
|
|
69
|
+
if (footer) lines.push('', footer);
|
|
70
|
+
return `${lines.join('\n')}\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Quote one argv token so the emitted confirmation hint is copy-pasteable
|
|
75
|
+
* in a POSIX shell. Boring tokens stay bare; anything containing spaces,
|
|
76
|
+
* quotes, `$`, `;`, `&`, `*` … gets single-quoted with embedded quotes
|
|
77
|
+
* escaped. `^ = < > ~ ! ,` are ServiceNow query characters — common in
|
|
78
|
+
* encoded queries — and are shell-safe bare, so they stay bare.
|
|
79
|
+
*
|
|
80
|
+
* @param {*} value — token
|
|
81
|
+
* @returns {string} shell-safe token
|
|
82
|
+
*/
|
|
83
|
+
export function quoteShellArg(value) {
|
|
84
|
+
const s = value == null ? '' : String(value);
|
|
85
|
+
if (s === '') return "''";
|
|
86
|
+
if (/^[A-Za-z0-9_@%+=:,./^-]+$/.test(s)) return s;
|
|
87
|
+
return `'${s.split("'").join("'\\''")}'`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the exact, safely-quoted rerun command for a confirmation hint.
|
|
92
|
+
*
|
|
93
|
+
* `argv._` holds command PATH NAMES only — the local adapter stores
|
|
94
|
+
* positionals and flags in named fields — so a hint built from `argv._`
|
|
95
|
+
* alone loses the target (`jsn incidents delete` with no number). Callers
|
|
96
|
+
* therefore pass the complete target tokens.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} argv — parsed args (reads `_`, `profile`, `instance`)
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {string|string[]} [opts.rerun] — complete rerun command: a token
|
|
101
|
+
* array (quoted here) or a full command string (used verbatim)
|
|
102
|
+
* @returns {string} command without `--force`, or '' when unknowable
|
|
103
|
+
*/
|
|
104
|
+
export function buildRerunCommand(argv = {}, { rerun = null } = {}) {
|
|
105
|
+
let parts;
|
|
106
|
+
if (Array.isArray(rerun) && rerun.length > 0) {
|
|
107
|
+
parts = ['jsn', ...rerun].map(quoteShellArg);
|
|
108
|
+
} else if (typeof rerun === 'string' && rerun.trim()) {
|
|
109
|
+
parts = [rerun.trim()];
|
|
110
|
+
} else {
|
|
111
|
+
parts = ['jsn', ...(argv?._ || [])].map(quoteShellArg);
|
|
112
|
+
}
|
|
113
|
+
if (parts.length === 1) return ''; // no path known — nothing worth repeating
|
|
114
|
+
const joined = parts.join(' ');
|
|
115
|
+
// Re-pin the profile/instance the caller was operating on, so following
|
|
116
|
+
// the hint cannot silently retarget a different instance or profile.
|
|
117
|
+
if (argv?.profile && !joined.includes('--profile')) parts.push('--profile', quoteShellArg(argv.profile));
|
|
118
|
+
if (argv?.instance && !joined.includes('--instance')) parts.push('--instance', quoteShellArg(argv.instance));
|
|
119
|
+
return parts.join(' ');
|
|
120
|
+
}
|
|
121
|
+
|
|
35
122
|
/**
|
|
36
123
|
* Confirm a destructive action before executing.
|
|
37
124
|
*
|
|
@@ -44,10 +131,12 @@ export function canPrompt() {
|
|
|
44
131
|
* @param {object} app — app instance (reads active profile from app.config)
|
|
45
132
|
* @param {object} argv — parsed args (checks argv.force)
|
|
46
133
|
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
134
|
+
* @param {object} [options] — { rerun } complete rerun command or token
|
|
135
|
+
* array for the hint (see buildRerunCommand)
|
|
47
136
|
* @returns {Promise<boolean>} true when the action may proceed
|
|
48
137
|
* @throws when confirmation is required but denied/unavailable
|
|
49
138
|
*/
|
|
50
|
-
export async function confirmDelete(app, argv, question) {
|
|
139
|
+
export async function confirmDelete(app, argv, question, options = {}) {
|
|
51
140
|
const profile = getActiveProfile(app.config);
|
|
52
141
|
if (profile?.skip_confirmations === true) return true;
|
|
53
142
|
if (argv.force) return true;
|
|
@@ -65,8 +154,7 @@ export async function confirmDelete(app, argv, question) {
|
|
|
65
154
|
}
|
|
66
155
|
// No human available (pipes, scripts, AI agents). Fail fast with a
|
|
67
156
|
// structured error an agent can act on — never hang waiting for stdin.
|
|
68
|
-
|
|
69
|
-
throw errConfirmationRequired(question, cmd);
|
|
157
|
+
throw errConfirmationRequired(question, buildRerunCommand(argv, options));
|
|
70
158
|
}
|
|
71
159
|
|
|
72
160
|
|
|
@@ -283,14 +371,39 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
283
371
|
const pickerFields = pickerColumns.join(',');
|
|
284
372
|
const baseQuery = query ? query + '^' : '';
|
|
285
373
|
|
|
286
|
-
// Get total count
|
|
287
|
-
|
|
374
|
+
// Get total count. A FAILED count is unknown — never zero. Mapping the
|
|
375
|
+
// failure to 0 used to make callers report an invented "empty table"
|
|
376
|
+
// success (or silently return) when the real cause was a 403/network
|
|
377
|
+
// error. Only a measured 0 means empty; otherwise confirm emptiness with
|
|
378
|
+
// one bounded list request before showing the picker.
|
|
379
|
+
let totalCount = null;
|
|
380
|
+
let countError = null;
|
|
288
381
|
try {
|
|
289
382
|
totalCount = await app.sdk.aggregateCount(table, query || '');
|
|
290
|
-
} catch {
|
|
291
|
-
|
|
383
|
+
} catch (err) {
|
|
384
|
+
countError = err;
|
|
385
|
+
}
|
|
386
|
+
if (totalCount === 0) return null; // measured empty
|
|
387
|
+
|
|
388
|
+
if (countError) {
|
|
389
|
+
try {
|
|
390
|
+
const probeParams = new URLSearchParams();
|
|
391
|
+
probeParams.set('sysparm_limit', '1');
|
|
392
|
+
probeParams.set('sysparm_display_value', 'all');
|
|
393
|
+
probeParams.set('sysparm_fields', 'sys_id');
|
|
394
|
+
if (query) probeParams.set('sysparm_query', query);
|
|
395
|
+
const probe = await app.sdk.list(table, probeParams);
|
|
396
|
+
if (!Array.isArray(probe)) throw new Error('list returned a non-array result');
|
|
397
|
+
if (probe.length === 0) return null; // the list itself confirms empty
|
|
398
|
+
} catch (err) {
|
|
399
|
+
// Both the count and the list failed — surface the failure instead of
|
|
400
|
+
// reporting an empty result nobody measured.
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Could not list ${table}: ${err.message || err} (aggregate count also failed: ${countError.message || countError})`,
|
|
403
|
+
{ cause: err }
|
|
404
|
+
);
|
|
405
|
+
}
|
|
292
406
|
}
|
|
293
|
-
if (totalCount === 0) return null;
|
|
294
407
|
|
|
295
408
|
// Paginated source adapter: (term, offset, { signal }) => choices[]
|
|
296
409
|
async function serverSource(term, offset, { signal: _signal } = {}) {
|
|
@@ -403,7 +516,9 @@ export function parseDataArg(argv) {
|
|
|
403
516
|
try {
|
|
404
517
|
return JSON.parse(raw);
|
|
405
518
|
} catch (e) {
|
|
406
|
-
|
|
519
|
+
// Never echo the raw payload: real --data bodies carry personal data and
|
|
520
|
+
// secrets, and the parse position from e.message is enough to debug.
|
|
521
|
+
throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.`, { cause: e });
|
|
407
522
|
}
|
|
408
523
|
}
|
|
409
524
|
|