@jacebenson/jsn 4.3.7-dev.40 → 4.3.7-dev.42
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/help.js +93 -28
- package/src/helpers.js +109 -9
- package/src/output.js +7 -0
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { saveConfig } from './config.js';
|
|
|
14
14
|
import { resolveSession, applySession, contextFromSession, requireSessionInstance } from './session.js';
|
|
15
15
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
16
16
|
import { errUsage, errAuth } from './errors.js';
|
|
17
|
+
import { canPrompt } from './helpers.js';
|
|
17
18
|
import { isMutationCommand } from './mutations.js';
|
|
18
19
|
import process from 'node:process';
|
|
19
20
|
|
|
@@ -190,8 +191,14 @@ export class App {
|
|
|
190
191
|
this.output.err(error);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* True only when a human can actually answer a prompt. Delegates to
|
|
196
|
+
* canPrompt() so the check is "both stdio descriptors are TTYs AND no
|
|
197
|
+
* agent/CI/opt-out marker is set" — a PTY allocated for an agent
|
|
198
|
+
* (stdout.isTTY true, JSN_NO_PROMPTS/CI set) is NOT interactive.
|
|
199
|
+
*/
|
|
193
200
|
isInteractive() {
|
|
194
|
-
return
|
|
201
|
+
return canPrompt();
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
requireInstance() {
|
package/src/cli-adapter.js
CHANGED
|
@@ -24,11 +24,38 @@ class CommandNode {
|
|
|
24
24
|
if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Register a child, replacing any existing registration that claims the same
|
|
29
|
+
* canonical name (or that the new child claims as an alias).
|
|
30
|
+
*
|
|
31
|
+
* yargs semantics are last-registration-wins: a command module re-registers a
|
|
32
|
+
* subcommand to layer a specialized handler over the base one (see
|
|
33
|
+
* `requests show`). Appending instead of replacing left the base registration
|
|
34
|
+
* first in the list and reachable by dispatch/help while the duplicate was
|
|
35
|
+
* invisible to the registry (child counts, inventory, completion).
|
|
36
|
+
*
|
|
37
|
+
* Registration order is preserved (the slot is replaced in place) and the
|
|
38
|
+
* replaced node's aliases are carried over so layered commands keep resolving
|
|
39
|
+
* through the alias that the base registration published.
|
|
40
|
+
*/
|
|
41
|
+
register(child) {
|
|
42
|
+
const index = this.children.findIndex((existing) => existing.name === child.name
|
|
43
|
+
|| existing.aliases.includes(child.name)
|
|
44
|
+
|| child.aliases.includes(existing.name));
|
|
45
|
+
if (index === -1) {
|
|
46
|
+
this.children.push(child);
|
|
47
|
+
return child;
|
|
48
|
+
}
|
|
49
|
+
child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])];
|
|
50
|
+
this.children[index] = child;
|
|
51
|
+
return child;
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
api() {
|
|
28
55
|
const node = this;
|
|
29
56
|
return {
|
|
30
57
|
command(definition, builder, handler) {
|
|
31
|
-
node.
|
|
58
|
+
node.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
|
|
32
59
|
return this;
|
|
33
60
|
},
|
|
34
61
|
option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
|
|
@@ -63,10 +90,16 @@ export class LocalCLI {
|
|
|
63
90
|
option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
|
|
64
91
|
options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
|
|
65
92
|
command(definition, builder, handler) {
|
|
66
|
-
|
|
67
|
-
this.root.children.push(node);
|
|
93
|
+
this.root.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
|
|
68
94
|
return this;
|
|
69
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Register the fallback used when a parent command (a node with children) is
|
|
98
|
+
* invoked with no subcommand and declares no handler of its own. Without it
|
|
99
|
+
* those parents exited 0 having printed nothing, which reads as success to a
|
|
100
|
+
* script and as a hang to a human.
|
|
101
|
+
*/
|
|
102
|
+
onParentDiscovery(fn) { this.parentDiscovery = fn; return this; }
|
|
70
103
|
demandCommand(count, message) { this.root.demand = { count, message }; return this; }
|
|
71
104
|
completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
|
|
72
105
|
help() { this.helpEnabled = true; return this; }
|
|
@@ -175,6 +208,54 @@ export class LocalCLI {
|
|
|
175
208
|
if (this.failHandler) return this.failHandler(message, error);
|
|
176
209
|
throw error || new Error(message);
|
|
177
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Describe the registered command surface: root commands (canonical name,
|
|
213
|
+
* aliases, describe, child names) and the global option declarations.
|
|
214
|
+
*
|
|
215
|
+
* This is the single source both the grouped root help and the machine
|
|
216
|
+
* discovery output are generated from, so neither can drift away from the
|
|
217
|
+
* commands that actually exist. Builders run here (idempotently) because a
|
|
218
|
+
* parent's children are only registered when its builder is invoked.
|
|
219
|
+
*/
|
|
220
|
+
commandRegistry() {
|
|
221
|
+
const visible = (node) => node.name !== this.completionCommand;
|
|
222
|
+
const serialize = (node) => {
|
|
223
|
+
node.build();
|
|
224
|
+
return {
|
|
225
|
+
name: node.name,
|
|
226
|
+
command: node.definition.command || node.name,
|
|
227
|
+
aliases: [...node.aliases],
|
|
228
|
+
description: node.definition.describe || '',
|
|
229
|
+
subcommands: node.children.filter(visible).map((child) => child.name),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
commands: this.root.children.filter(visible).map(serialize),
|
|
234
|
+
options: [...this.root.options.values()].map((spec) => ({
|
|
235
|
+
name: spec.name,
|
|
236
|
+
alias: names(spec.alias),
|
|
237
|
+
description: spec.description || spec.describe || '',
|
|
238
|
+
choices: spec.choices || [],
|
|
239
|
+
type: spec.type || 'boolean',
|
|
240
|
+
})),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Structured child signatures for a parent node, built on demand. */
|
|
245
|
+
subcommandsOf(node) {
|
|
246
|
+
return node.children
|
|
247
|
+
.filter((child) => child.name !== this.completionCommand)
|
|
248
|
+
.map((child) => {
|
|
249
|
+
child.build();
|
|
250
|
+
return {
|
|
251
|
+
name: child.name,
|
|
252
|
+
command: child.definition.command || child.name,
|
|
253
|
+
aliases: [...child.aliases],
|
|
254
|
+
description: child.definition.describe || '',
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
178
259
|
_help(node, path = []) {
|
|
179
260
|
const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
|
|
180
261
|
if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
|
|
@@ -232,6 +313,11 @@ export class LocalCLI {
|
|
|
232
313
|
const parsed = this._parseOptions(commandArgs, path);
|
|
233
314
|
if (!parsed) return;
|
|
234
315
|
const argv = parsed.values;
|
|
316
|
+
// A parent command's own children are registry-derived, so a default
|
|
317
|
+
// handler can list exactly what is registered instead of repeating a
|
|
318
|
+
// hand-written list that drifts (see the records parent, which advertised
|
|
319
|
+
// an `inspect` description the registration disagreed with).
|
|
320
|
+
if (node.children.length) argv.subcommands = this.subcommandsOf(node);
|
|
235
321
|
const specs = [...this._optionConfig(path).values()];
|
|
236
322
|
const positions = positionSpec(node.definition.command || '').map((position) => ({
|
|
237
323
|
...position, ...node.positionals.get(position.name), name: position.name,
|
|
@@ -242,7 +328,13 @@ export class LocalCLI {
|
|
|
242
328
|
for (const middleware of this.middlewares) await middleware(argv);
|
|
243
329
|
for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
|
|
244
330
|
if (node.definition.handler) await node.definition.handler(argv);
|
|
245
|
-
|
|
331
|
+
// A parent with no handler of its own used to fall through and exit 0
|
|
332
|
+
// with no output — indistinguishable from a command that ran and found
|
|
333
|
+
// nothing. The registered discovery hook (cli.js) prints the child
|
|
334
|
+
// surface instead: a JSON envelope in machine mode, a list for humans.
|
|
335
|
+
else if (path.length && argv.subcommands?.length && this.parentDiscovery) {
|
|
336
|
+
await this.parentDiscovery(argv, { path: path.map((n) => n.name), subcommands: argv.subcommands });
|
|
337
|
+
} else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
|
|
246
338
|
}
|
|
247
339
|
}
|
|
248
340
|
|
package/src/cli.js
CHANGED
|
@@ -10,7 +10,8 @@ import { resolveSession, applySession } from './session.js';
|
|
|
10
10
|
import { renderHelp } from './help.js';
|
|
11
11
|
import { isMutationCommand, refreshMutationCommands } from './mutations.js';
|
|
12
12
|
import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
|
|
13
|
-
import { guardExit, exitWithError } from './errors.js';
|
|
13
|
+
import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
|
|
14
|
+
import { formatCommandList } from './helpers.js';
|
|
14
15
|
|
|
15
16
|
// Command modules
|
|
16
17
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -73,6 +74,53 @@ import { midserversCmd } from './commands/midservers.js';
|
|
|
73
74
|
import { diffCmd } from './commands/diff.js';
|
|
74
75
|
import { graphqlCmd } from './commands/graphql.js';
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Did the caller explicitly ask for machine output?
|
|
79
|
+
*
|
|
80
|
+
* Read from the raw argv because parse-time failures happen BEFORE the format
|
|
81
|
+
* middleware can run, so `--json`/`--format=json` had not reached the output
|
|
82
|
+
* controller yet — which is exactly why the structured failure envelope was
|
|
83
|
+
* unreachable for the mistakes scripts and agents hit most (a missing required
|
|
84
|
+
* flag, a bad choice, an unknown command).
|
|
85
|
+
*/
|
|
86
|
+
function explicitJsonRequest(args) {
|
|
87
|
+
return args.some((arg, i) => {
|
|
88
|
+
if (arg === '--json') return true;
|
|
89
|
+
if (arg.startsWith('--json=')) return arg !== '--json=false';
|
|
90
|
+
if (arg === '--format=json') return true;
|
|
91
|
+
return arg === '--format' && args[i + 1] === 'json';
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The output format the caller explicitly asked for, read from the raw argv.
|
|
97
|
+
*
|
|
98
|
+
* Same reason as explicitJsonRequest: the format middleware never runs for a
|
|
99
|
+
* parse-time failure, so an explicit `--format=styled` (human) or
|
|
100
|
+
* `--format=json` (machine) was silently ignored and the failure was rendered
|
|
101
|
+
* by the pipe/TTY default instead of what was asked for.
|
|
102
|
+
*/
|
|
103
|
+
function explicitFormat(args) {
|
|
104
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
105
|
+
const arg = args[i];
|
|
106
|
+
if (arg === '--json') return 'json';
|
|
107
|
+
if (arg === '--quiet' || arg === '-q') return 'quiet';
|
|
108
|
+
if (arg === '--styled') return 'styled';
|
|
109
|
+
if (arg === '--markdown') return 'markdown';
|
|
110
|
+
if (arg === '--csv') return 'csv';
|
|
111
|
+
if (arg.startsWith('--format=')) return arg.slice('--format='.length);
|
|
112
|
+
if (arg === '--format' && args[i + 1]) return args[i + 1];
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parent commands owned by another change. The adapter's discovery fallback
|
|
119
|
+
* (which gives a handler-less parent its child list instead of a silent exit 0)
|
|
120
|
+
* is skipped for these families so their own fix owns the behavior.
|
|
121
|
+
*/
|
|
122
|
+
const DISCOVERY_OWNED_ELSEWHERE = new Set(['auth', 'perf', 'skill', 'groupmembers', 'grouproles']);
|
|
123
|
+
|
|
76
124
|
function wrap(handler) {
|
|
77
125
|
return async (argv) => {
|
|
78
126
|
try {
|
|
@@ -435,14 +483,33 @@ export function buildCLI() {
|
|
|
435
483
|
cliInstance.showCompletionScript();
|
|
436
484
|
},
|
|
437
485
|
})
|
|
486
|
+
.onParentDiscovery((argv, { path, subcommands }) => {
|
|
487
|
+
// Reached only for a parent command that declares no default handler of
|
|
488
|
+
// its own (cmdb, platform, and any future parent) — the adapter used to
|
|
489
|
+
// fall through and exit 0 having printed nothing. Children come from the
|
|
490
|
+
// registry, description included, so this list cannot drift from the
|
|
491
|
+
// registrations the way a hand-written one did.
|
|
492
|
+
if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
|
|
493
|
+
const canonical = path.join(' ');
|
|
494
|
+
const summary = `jsn ${canonical} — available subcommands.`;
|
|
495
|
+
(argv.app || app).ok({
|
|
496
|
+
command: canonical,
|
|
497
|
+
subcommands,
|
|
498
|
+
_formatted: formatCommandList({
|
|
499
|
+
title: `jsn ${canonical}`,
|
|
500
|
+
commands: subcommands,
|
|
501
|
+
footer: `Run "jsn ${canonical} <command> --help" for details.`,
|
|
502
|
+
}),
|
|
503
|
+
}, { summary });
|
|
504
|
+
})
|
|
438
505
|
.help('help', 'Show help')
|
|
439
506
|
.version(false)
|
|
440
507
|
.strictCommands()
|
|
441
508
|
.strictOptions(false)
|
|
442
509
|
.fail((msg, err) => {
|
|
443
510
|
if (err) throw err;
|
|
511
|
+
const raw = process.argv.slice(2);
|
|
444
512
|
if (msg === 'You must specify a command') {
|
|
445
|
-
const raw = process.argv.slice(2);
|
|
446
513
|
const hasLoneProfile = raw.some((a, i, arr) => {
|
|
447
514
|
if (a !== '--profile' && a !== '-p') return false;
|
|
448
515
|
const next = arr[i + 1];
|
|
@@ -458,11 +525,40 @@ export function buildCLI() {
|
|
|
458
525
|
}
|
|
459
526
|
process.exit(0);
|
|
460
527
|
}
|
|
461
|
-
|
|
528
|
+
// No command is a discovery request, not a failure: the human view
|
|
529
|
+
// stays exactly as it was (exit 0 + grouped help), and an explicit
|
|
530
|
+
// --json gets the same surface as machine-readable data instead of
|
|
531
|
+
// prose that a parser cannot read.
|
|
532
|
+
if (explicitJsonRequest(raw)) {
|
|
533
|
+
app.output.setFormat('json');
|
|
534
|
+
const { commands } = cliInstance.commandRegistry();
|
|
535
|
+
app.ok({ commands }, {
|
|
536
|
+
summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
|
|
537
|
+
});
|
|
538
|
+
process.exit(0);
|
|
539
|
+
}
|
|
540
|
+
process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
|
|
462
541
|
process.exit(0);
|
|
463
542
|
}
|
|
464
|
-
|
|
465
|
-
|
|
543
|
+
// Every other failure here (unknown command, missing required argument,
|
|
544
|
+
// invalid choice/number) is a usage failure. Route it through the same
|
|
545
|
+
// renderer as handler and guard failures: exit 2, and the documented
|
|
546
|
+
// error envelope on stdout when the caller is in JSON mode. Previously
|
|
547
|
+
// this wrote raw prose to stderr and exited 1, so the exit code and the
|
|
548
|
+
// output shape depended on which layer rejected the command.
|
|
549
|
+
if (explicitJsonRequest(raw)) app.output.setFormat('json');
|
|
550
|
+
else {
|
|
551
|
+
const requestedFormat = explicitFormat(raw);
|
|
552
|
+
if (requestedFormat) app.output.setFormat(requestedFormat);
|
|
553
|
+
}
|
|
554
|
+
let hint;
|
|
555
|
+
try {
|
|
556
|
+
const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
|
|
557
|
+
hint = `Run "jsn ${path ? `${path} ` : ''}--help" for usage.`;
|
|
558
|
+
} catch {
|
|
559
|
+
hint = 'Run "jsn --help" for the command list.';
|
|
560
|
+
}
|
|
561
|
+
exitWithError(new AppError(CodeUsage, msg, hint), { app });
|
|
466
562
|
});
|
|
467
563
|
|
|
468
564
|
// Snapshot the root command names (plus aliases) for the completion
|
package/src/commands/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
|
|
2
|
-
import { errUsage } from '../errors.js';
|
|
2
|
+
import { errUsage, errUsageHint } from '../errors.js';
|
|
3
|
+
import { canUsePicker } from '../helpers.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
import process from 'node:process';
|
|
5
6
|
|
|
@@ -128,14 +129,16 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
|
-
* Pick a profile by name — interactive search picker when ambiguous
|
|
132
|
-
* auto-selects when there's exactly one, throws when
|
|
132
|
+
* Pick a profile by name — interactive search picker when ambiguous and a
|
|
133
|
+
* human is available, auto-selects when there's exactly one, throws when
|
|
134
|
+
* none configured. A non-interactive caller (agent, pipe, --json/--get)
|
|
135
|
+
* never opens a picker; it falls back to the single/default profile.
|
|
133
136
|
*/
|
|
134
137
|
export async function pickProfile(app, message) {
|
|
135
138
|
const { search } = await import('@inquirer/prompts');
|
|
136
139
|
const profileNames = Object.keys(app.config.profiles || {});
|
|
137
140
|
if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
|
|
138
|
-
if (profileNames.length === 1 || !app
|
|
141
|
+
if (profileNames.length === 1 || !canUsePicker(app, {})) return profileNames[0];
|
|
139
142
|
const choices = profileNames.map(name => ({
|
|
140
143
|
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
141
144
|
value: name,
|
|
@@ -281,10 +284,9 @@ export async function loginWizard(app, argv = {}) {
|
|
|
281
284
|
app.config.activeProfile = profileName;
|
|
282
285
|
app.config.defaultProfile = profileName;
|
|
283
286
|
|
|
284
|
-
// Present all connection-level options during setup. Gated on
|
|
285
|
-
// so a piped/scripted call never blocks on prompts.
|
|
286
|
-
|
|
287
|
-
if (canPrompt()) {
|
|
287
|
+
// Present all connection-level options during setup. Gated on
|
|
288
|
+
// interactivity so a piped/scripted call never blocks on prompts.
|
|
289
|
+
if (canUsePicker(app, argv)) {
|
|
288
290
|
// @inquirer/prompts owns stdin while these confirmations run. Close the
|
|
289
291
|
// older readline interface before handing stdin to Inquirer. This matters
|
|
290
292
|
// on Windows Git Bash: leaving both readers attached can make the later
|
|
@@ -604,7 +606,7 @@ export function authCmd(wrap) {
|
|
|
604
606
|
instanceURL = normalizeInstanceURL(namedProfile.instance_url);
|
|
605
607
|
} else if (argv.instance) {
|
|
606
608
|
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
607
|
-
} else if (app
|
|
609
|
+
} else if (canUsePicker(app, argv)) {
|
|
608
610
|
// gh-faithful: interactive terminals always get a picker —
|
|
609
611
|
// existing profiles plus "+ Add a new instance". Zero profiles
|
|
610
612
|
// goes straight to the setup wizard.
|
|
@@ -987,7 +989,9 @@ Examples:
|
|
|
987
989
|
if (profileNames.length === 0) {
|
|
988
990
|
throw errUsage('No profiles configured. Run "jsn auth login" first.');
|
|
989
991
|
}
|
|
990
|
-
if (profileNames.length
|
|
992
|
+
if (profileNames.length === 1) {
|
|
993
|
+
name = profileNames[0];
|
|
994
|
+
} else if (canUsePicker(app, argv)) {
|
|
991
995
|
const { search } = await import('@inquirer/prompts');
|
|
992
996
|
const choices = profileNames.map(profileName => ({
|
|
993
997
|
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
@@ -1002,7 +1006,13 @@ Examples:
|
|
|
1002
1006
|
});
|
|
1003
1007
|
name = selected;
|
|
1004
1008
|
} else {
|
|
1005
|
-
|
|
1009
|
+
// Never silently switch the active profile for a
|
|
1010
|
+
// non-interactive caller: picking one for them is a
|
|
1011
|
+
// config write they did not ask for.
|
|
1012
|
+
throw errUsageHint(
|
|
1013
|
+
`Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`,
|
|
1014
|
+
'Pass the profile name: jsn auth switch <name>'
|
|
1015
|
+
);
|
|
1006
1016
|
}
|
|
1007
1017
|
}
|
|
1008
1018
|
await setActiveProfile(app.config, name);
|
package/src/commands/cmdb.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
assertSafeExactMatch,
|
|
10
10
|
buildQuerySuffix,
|
|
11
|
+
formatCommandList,
|
|
11
12
|
formatRecordForDisplay,
|
|
12
13
|
getStringField,
|
|
13
14
|
interactiveList,
|
|
@@ -351,6 +352,23 @@ export function cmdbCmd(wrap) {
|
|
|
351
352
|
return {
|
|
352
353
|
command: 'cmdb [subcommand]',
|
|
353
354
|
describe: 'Query the Configuration Management Database (CMDB) — CIs and relationship traversal (read-only)',
|
|
355
|
+
// Parent discovery. Without a handler `jsn cmdb` exited 0 having printed
|
|
356
|
+
// nothing: a script reads that as success and a human as a hang. The child
|
|
357
|
+
// list comes from the registry (argv.subcommands), so it cannot drift from
|
|
358
|
+
// what is registered; the styled view is a side channel that JSON ignores.
|
|
359
|
+
handler: wrap(async (argv, app) => {
|
|
360
|
+
const subcommands = argv.subcommands || [];
|
|
361
|
+
const summary = 'Query the Configuration Management Database (CMDB): CI lookup and relationship traversal. Read-only.';
|
|
362
|
+
app.ok({
|
|
363
|
+
command: 'cmdb',
|
|
364
|
+
subcommands,
|
|
365
|
+
_formatted: formatCommandList({
|
|
366
|
+
title: summary,
|
|
367
|
+
commands: subcommands,
|
|
368
|
+
footer: 'Run "jsn cmdb <command> --help" for details.',
|
|
369
|
+
}),
|
|
370
|
+
}, { summary });
|
|
371
|
+
}),
|
|
354
372
|
builder: (yargs) => {
|
|
355
373
|
return yargs
|
|
356
374
|
.command({
|
|
@@ -15,6 +15,7 @@ import { searchDocs, getDocByIdOrPath } from './search.js';
|
|
|
15
15
|
import { serveDocs } from './serve.js';
|
|
16
16
|
import { syncCommunity, listCommunityDocs } from './community.js';
|
|
17
17
|
import { declareCapabilities } from '../../capabilities.js';
|
|
18
|
+
import { formatCommandList } from '../../helpers.js';
|
|
18
19
|
|
|
19
20
|
declareCapabilities('docs', { noInstance: true, skipDailyChecks: true });
|
|
20
21
|
|
|
@@ -256,21 +257,22 @@ export function docsCmd(wrap) {
|
|
|
256
257
|
.demandCommand(1, 'Specify an action: sync or list'),
|
|
257
258
|
});
|
|
258
259
|
},
|
|
259
|
-
handler: () => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
260
|
+
handler: wrap(async (argv, app) => {
|
|
261
|
+
const subcommands = argv.subcommands || [];
|
|
262
|
+
const summary = 'jsn docs — ServiceNow documentation search (sync, status, search, show, serve).';
|
|
263
|
+
app.ok({
|
|
264
|
+
command: 'docs',
|
|
265
|
+
subcommands,
|
|
266
|
+
_formatted: formatCommandList({
|
|
267
|
+
title: summary,
|
|
268
|
+
commands: subcommands,
|
|
269
|
+
footer: [
|
|
270
|
+
'Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.',
|
|
271
|
+
`Docs DB: ${getDocsDbPath()} (run "jsn docs status" for details, or query directly with sqlite3)`,
|
|
272
|
+
'Run "jsn docs <command> --help" for details.',
|
|
273
|
+
].join('\n'),
|
|
274
|
+
}),
|
|
275
|
+
}, { summary });
|
|
276
|
+
}),
|
|
275
277
|
};
|
|
276
278
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
|
|
4
|
+
// add/remove write sys_user_grmember; the mutation guard (instance +
|
|
5
|
+
// read-only profile) and the delete confirmation both key off this.
|
|
6
|
+
declareCapabilities('groupmembers', { mutationSubcommands: ['add', 'remove'] });
|
|
2
7
|
|
|
3
8
|
export function groupMembersCmd(wrap) {
|
|
4
9
|
return {
|
|
@@ -42,6 +47,8 @@ export function groupMembersCmd(wrap) {
|
|
|
42
47
|
handler: wrap(async (argv, app) => {
|
|
43
48
|
const groupName = argv.group;
|
|
44
49
|
const userName = argv.user;
|
|
50
|
+
assertSafeExactMatch(groupName);
|
|
51
|
+
assertSafeExactMatch(userName);
|
|
45
52
|
|
|
46
53
|
// Resolve group sys_id
|
|
47
54
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -86,7 +93,7 @@ export function groupMembersCmd(wrap) {
|
|
|
86
93
|
summary: `Added ${userName} to ${groupName}`,
|
|
87
94
|
breadcrumbs: [
|
|
88
95
|
{ action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
|
|
89
|
-
{ action: 'remove', cmd: `jsn groupmembers remove --group
|
|
96
|
+
{ action: 'remove', cmd: `jsn groupmembers remove --group ${quoteShellArg(groupName)} --user ${quoteShellArg(userName)}`, description: 'Remove this member' },
|
|
90
97
|
],
|
|
91
98
|
});
|
|
92
99
|
}),
|
|
@@ -96,10 +103,18 @@ export function groupMembersCmd(wrap) {
|
|
|
96
103
|
describe: 'Remove a user from a group',
|
|
97
104
|
builder: (y) => y
|
|
98
105
|
.option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
|
|
99
|
-
.option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' })
|
|
106
|
+
.option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' })
|
|
107
|
+
.option('force', { type: 'boolean', describe: 'Skip the confirmation prompt (removal)' }),
|
|
100
108
|
handler: wrap(async (argv, app) => {
|
|
101
109
|
const groupName = argv.group;
|
|
102
110
|
const userName = argv.user;
|
|
111
|
+
assertSafeExactMatch(groupName);
|
|
112
|
+
assertSafeExactMatch(userName);
|
|
113
|
+
// Confirm before any lookup: a refused confirmation must not
|
|
114
|
+
// touch the instance at all.
|
|
115
|
+
await confirmDelete(app, argv, `Remove ${userName} from group ${groupName}`, {
|
|
116
|
+
rerun: ['groupmembers', 'remove', '--group', groupName, '--user', userName],
|
|
117
|
+
});
|
|
103
118
|
|
|
104
119
|
// Resolve group sys_id
|
|
105
120
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -142,7 +157,7 @@ export function groupMembersCmd(wrap) {
|
|
|
142
157
|
summary: `Removed ${userName} from ${groupName}`,
|
|
143
158
|
breadcrumbs: [
|
|
144
159
|
{ action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
|
|
145
|
-
{ action: 'add', cmd: `jsn groupmembers add --group
|
|
160
|
+
{ action: 'add', cmd: `jsn groupmembers add --group ${quoteShellArg(groupName)} --user ${quoteShellArg(userName)}`, description: 'Add user back to group' },
|
|
146
161
|
],
|
|
147
162
|
});
|
|
148
163
|
}),
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
|
|
4
|
+
// add/remove write sys_group_has_role; the mutation guard (instance +
|
|
5
|
+
// read-only profile) and the delete confirmation both key off this.
|
|
6
|
+
declareCapabilities('grouproles', { mutationSubcommands: ['add', 'remove'] });
|
|
2
7
|
|
|
3
8
|
export function groupRolesCmd(wrap) {
|
|
4
9
|
return {
|
|
@@ -88,7 +93,7 @@ export function groupRolesCmd(wrap) {
|
|
|
88
93
|
summary: `Added role ${roleName} to ${groupName}`,
|
|
89
94
|
breadcrumbs: [
|
|
90
95
|
{ action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
|
|
91
|
-
{ action: 'remove', cmd: `jsn grouproles remove --group
|
|
96
|
+
{ action: 'remove', cmd: `jsn grouproles remove --group ${quoteShellArg(groupName)} --role ${quoteShellArg(roleName)}`, description: 'Remove this role' },
|
|
92
97
|
],
|
|
93
98
|
});
|
|
94
99
|
}),
|
|
@@ -98,12 +103,18 @@ export function groupRolesCmd(wrap) {
|
|
|
98
103
|
describe: 'Remove a role from a group',
|
|
99
104
|
builder: (y) => y
|
|
100
105
|
.option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
|
|
101
|
-
.option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' })
|
|
106
|
+
.option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' })
|
|
107
|
+
.option('force', { type: 'boolean', describe: 'Skip the confirmation prompt (removal)' }),
|
|
102
108
|
handler: wrap(async (argv, app) => {
|
|
103
109
|
const groupName = argv.group;
|
|
104
110
|
const roleName = argv.role;
|
|
105
111
|
assertSafeExactMatch(groupName);
|
|
106
112
|
assertSafeExactMatch(roleName);
|
|
113
|
+
// Confirm before any lookup: a refused confirmation must not
|
|
114
|
+
// touch the instance at all.
|
|
115
|
+
await confirmDelete(app, argv, `Remove role ${roleName} from group ${groupName}`, {
|
|
116
|
+
rerun: ['grouproles', 'remove', '--group', groupName, '--role', roleName],
|
|
117
|
+
});
|
|
107
118
|
|
|
108
119
|
// Resolve group sys_id
|
|
109
120
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -146,7 +157,7 @@ export function groupRolesCmd(wrap) {
|
|
|
146
157
|
summary: `Removed role ${roleName} from ${groupName}`,
|
|
147
158
|
breadcrumbs: [
|
|
148
159
|
{ action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
|
|
149
|
-
{ action: 'add', cmd: `jsn grouproles add --group
|
|
160
|
+
{ action: 'add', cmd: `jsn grouproles add --group ${quoteShellArg(groupName)} --role ${quoteShellArg(roleName)}`, description: 'Add role back to group' },
|
|
150
161
|
],
|
|
151
162
|
});
|
|
152
163
|
}),
|
package/src/commands/perf.js
CHANGED
|
@@ -2,14 +2,21 @@ import { errUsageHint, errUsage } from '../errors.js';
|
|
|
2
2
|
import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
|
|
3
3
|
import { topTransactionsCmd } from './transactions.js';
|
|
4
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
5
|
+
import { canUsePicker } from '../helpers.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
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);
|
|
8
15
|
}
|
|
9
16
|
|
|
10
|
-
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 = {} } = {}) {
|
|
11
18
|
const runs = listRuns({ limit: 1000 }).filter(run => !exclude.has(run.run_id));
|
|
12
|
-
if (!pickerEnabled(app) || runs.length === 0) return null;
|
|
19
|
+
if (!pickerEnabled(app, argv) || runs.length === 0) return null;
|
|
13
20
|
const selected = await paginatedSearch({
|
|
14
21
|
message,
|
|
15
22
|
totalCount: runs.length,
|
|
@@ -78,7 +85,7 @@ export function perfCmd(wrap) {
|
|
|
78
85
|
builder: (b) => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum runs to show' }),
|
|
79
86
|
handler: wrap(async (argv, app) => {
|
|
80
87
|
const runs = listRuns({ limit: argv.limit });
|
|
81
|
-
const picked = await pickRun(app, { message: 'Select a performance capture' });
|
|
88
|
+
const picked = await pickRun(app, { message: 'Select a performance capture', argv });
|
|
82
89
|
if (picked) {
|
|
83
90
|
const run = getRun(picked.run_id);
|
|
84
91
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
@@ -100,8 +107,16 @@ export function perfCmd(wrap) {
|
|
|
100
107
|
command: 'show [run_id]',
|
|
101
108
|
describe: 'Show one saved performance capture',
|
|
102
109
|
handler: wrap(async (argv, app) => {
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
120
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
106
121
|
summary: `Performance capture ${run.run_id}`,
|
|
107
122
|
breadcrumbs: [listHint(), compareHint(run.run_id)],
|
|
@@ -118,9 +133,15 @@ export function perfCmd(wrap) {
|
|
|
118
133
|
describe: 'Compare exactly two saved captures',
|
|
119
134
|
handler: wrap(async (argv, app) => {
|
|
120
135
|
if (!argv.baseline && !argv.new) {
|
|
121
|
-
|
|
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 });
|
|
122
143
|
if (!baseline) return;
|
|
123
|
-
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 });
|
|
124
145
|
if (!newer) return;
|
|
125
146
|
const baselineRun = getRun(baseline.run_id);
|
|
126
147
|
const newerRun = getRun(newer.run_id);
|
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/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,29 @@ 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
|
+
|
|
35
58
|
/**
|
|
36
59
|
* Render a parent command's subcommand list for its default handler.
|
|
37
60
|
*
|
|
@@ -47,6 +70,55 @@ export function formatCommandList({ title, commands, footer }) {
|
|
|
47
70
|
return `${lines.join('\n')}\n`;
|
|
48
71
|
}
|
|
49
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
|
+
|
|
50
122
|
/**
|
|
51
123
|
* Confirm a destructive action before executing.
|
|
52
124
|
*
|
|
@@ -59,10 +131,12 @@ export function formatCommandList({ title, commands, footer }) {
|
|
|
59
131
|
* @param {object} app — app instance (reads active profile from app.config)
|
|
60
132
|
* @param {object} argv — parsed args (checks argv.force)
|
|
61
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)
|
|
62
136
|
* @returns {Promise<boolean>} true when the action may proceed
|
|
63
137
|
* @throws when confirmation is required but denied/unavailable
|
|
64
138
|
*/
|
|
65
|
-
export async function confirmDelete(app, argv, question) {
|
|
139
|
+
export async function confirmDelete(app, argv, question, options = {}) {
|
|
66
140
|
const profile = getActiveProfile(app.config);
|
|
67
141
|
if (profile?.skip_confirmations === true) return true;
|
|
68
142
|
if (argv.force) return true;
|
|
@@ -80,8 +154,7 @@ export async function confirmDelete(app, argv, question) {
|
|
|
80
154
|
}
|
|
81
155
|
// No human available (pipes, scripts, AI agents). Fail fast with a
|
|
82
156
|
// structured error an agent can act on — never hang waiting for stdin.
|
|
83
|
-
|
|
84
|
-
throw errConfirmationRequired(question, cmd);
|
|
157
|
+
throw errConfirmationRequired(question, buildRerunCommand(argv, options));
|
|
85
158
|
}
|
|
86
159
|
|
|
87
160
|
|
|
@@ -298,14 +371,39 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
298
371
|
const pickerFields = pickerColumns.join(',');
|
|
299
372
|
const baseQuery = query ? query + '^' : '';
|
|
300
373
|
|
|
301
|
-
// Get total count
|
|
302
|
-
|
|
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;
|
|
303
381
|
try {
|
|
304
382
|
totalCount = await app.sdk.aggregateCount(table, query || '');
|
|
305
|
-
} catch {
|
|
306
|
-
|
|
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
|
+
}
|
|
307
406
|
}
|
|
308
|
-
if (totalCount === 0) return null;
|
|
309
407
|
|
|
310
408
|
// Paginated source adapter: (term, offset, { signal }) => choices[]
|
|
311
409
|
async function serverSource(term, offset, { signal: _signal } = {}) {
|
|
@@ -418,7 +516,9 @@ export function parseDataArg(argv) {
|
|
|
418
516
|
try {
|
|
419
517
|
return JSON.parse(raw);
|
|
420
518
|
} catch (e) {
|
|
421
|
-
|
|
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 });
|
|
422
522
|
}
|
|
423
523
|
}
|
|
424
524
|
|
package/src/output.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Output formatting: JSON, Markdown, Styled, Quiet
|
|
2
2
|
|
|
3
3
|
import process from 'node:process';
|
|
4
|
+
import { errExtractionNotFound } from './errors.js';
|
|
4
5
|
|
|
5
6
|
export const FormatAuto = 'auto';
|
|
6
7
|
export const FormatJSON = 'json';
|
|
@@ -125,9 +126,15 @@ export class OutputWriter {
|
|
|
125
126
|
// --get extractor: resolve against the JSON envelope and print just that
|
|
126
127
|
// value as JSON. Takes precedence over styling/table formats — a path
|
|
127
128
|
// extract is inherently scriptable, not presentational.
|
|
129
|
+
//
|
|
130
|
+
// A path that does not resolve is an error, not a value. Stringifying
|
|
131
|
+
// `undefined` printed the literal token `undefined` and exited 0, so a
|
|
132
|
+
// typo looked like a successful empty result until the next tool in the
|
|
133
|
+
// pipeline failed to parse it. Present null/false/0/"" still print as-is.
|
|
128
134
|
if (this.jqFilter) {
|
|
129
135
|
const envelope = { ok: true, data, summary: opts.summary || '', breadcrumbs: opts.breadcrumbs || [] };
|
|
130
136
|
const value = resolvePath(envelope, this.jqFilter);
|
|
137
|
+
if (value === undefined) throw errExtractionNotFound(this.jqFilter);
|
|
131
138
|
this.writer.write(JSON.stringify(value, null, 2) + '\n');
|
|
132
139
|
return;
|
|
133
140
|
}
|