@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.40",
3
+ "version": "4.3.7-dev.43",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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 process.stdout.isTTY === true;
201
+ return canPrompt();
195
202
  }
196
203
 
197
204
  requireInstance() {
@@ -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.children.push(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
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
- const node = new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition);
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
- else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
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
- process.stdout.write(renderHelp(loadConfig()) + '\n');
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
- process.stderr.write(msg + '\n');
465
- process.exit(1);
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
@@ -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 none configured.
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.isInteractive()) return profileNames[0];
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 interactivity
285
- // so a piped/scripted call never blocks on prompts.
286
- const { canPrompt } = await import('../helpers.js');
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.isInteractive()) {
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 > 1 && app.isInteractive()) {
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
- name = profileNames[0];
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);
@@ -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
- process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
261
- process.stdout.write('Available subcommands:\n');
262
- process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
263
- process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
264
- process.stdout.write(' search Full-text + semantic search across the local docs index\n');
265
- process.stdout.write(' show Display a full documentation page by id or path\n');
266
- process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
267
- process.stdout.write(' community Sync ServiceNow Community articles (hubs + linked posts) into the index\n');
268
- process.stdout.write('\n');
269
- process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
270
- process.stdout.write('\n');
271
- const dbPath = getDocsDbPath();
272
- process.stdout.write(`Docs DB: ${dbPath} (run "jsn docs status" for details, or query directly with sqlite3)\n`);
273
- process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
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 "${groupName}" --user "${userName}"`, description: 'Remove this member' },
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 "${groupName}" --user "${userName}"`, description: 'Add user back to 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 "${groupName}" --role "${roleName}"`, description: 'Remove this role' },
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 "${groupName}" --role "${roleName}"`, description: 'Add role back to 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
  }),
@@ -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
- function pickerEnabled(app) {
7
- return Boolean(process.stdin.isTTY && process.stdout.isTTY && app.output?.effectiveFormat?.() !== 'json' && app.output?.effectiveFormat?.() !== 'quiet');
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
- const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app);
104
- if (!run) return;
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
- const baseline = await pickRun(app, { message: 'Select the baseline capture' });
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);