@jacebenson/jsn 4.3.7-dev.41 → 4.3.7-dev.44

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.41",
3
+ "version": "4.3.7-dev.44",
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() {
@@ -10,7 +10,11 @@ class CommandNode {
10
10
  constructor(definition) {
11
11
  this.definition = typeof definition === 'string' ? { command: definition } : definition;
12
12
  this.name = commandName(this.definition.command || '');
13
- this.aliases = names(this.definition.aliases || this.definition.alias);
13
+ // A command that lists its canonical name as its own alias publishes one
14
+ // token twice: the registry then has two entries for it and dispatch can
15
+ // resolve it through the alias table instead of the command table. An alias
16
+ // is an ALTERNATE name, so a self-alias is dropped at the source.
17
+ this.aliases = names(this.definition.aliases || this.definition.alias).filter((alias) => alias !== this.name);
14
18
  this.children = [];
15
19
  this.options = new Map();
16
20
  this.positionals = new Map();
@@ -37,16 +41,34 @@ class CommandNode {
37
41
  * Registration order is preserved (the slot is replaced in place) and the
38
42
  * replaced node's aliases are carried over so layered commands keep resolving
39
43
  * through the alias that the base registration published.
44
+ *
45
+ * A token must resolve to exactly ONE command. Two registrations can also
46
+ * collide without sharing a canonical name: an alias that another command
47
+ * already published (or already claims as its alias) is the same kind of
48
+ * claim, and registration order decides it — the later registration owns the
49
+ * token and the earlier one stops advertising it. The earlier command keeps
50
+ * its canonical name, so nothing becomes unreachable.
40
51
  */
41
52
  register(child) {
42
53
  const index = this.children.findIndex((existing) => existing.name === child.name
43
54
  || existing.aliases.includes(child.name)
44
55
  || child.aliases.includes(existing.name));
45
56
  if (index === -1) {
57
+ for (const sibling of this.children) {
58
+ sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
59
+ }
46
60
  this.children.push(child);
47
61
  return child;
48
62
  }
49
- child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])];
63
+ // Carry the replaced node's aliases over — minus any alias that is now the
64
+ // replacement's own canonical name, which would advertise the command as
65
+ // its own alias.
66
+ child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])]
67
+ .filter((alias) => alias !== child.name);
68
+ for (const sibling of this.children) {
69
+ if (sibling === this.children[index]) continue;
70
+ sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
71
+ }
50
72
  this.children[index] = child;
51
73
  return child;
52
74
  }
@@ -157,14 +179,22 @@ export class LocalCLI {
157
179
  return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
158
180
  || { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
159
181
  }
160
- _assign(argv, key, value, spec) {
182
+ /**
183
+ * Assign one parsed value to argv.
184
+ *
185
+ * `validate: false` (tolerant mode, used by parseGlobalOptions) skips the
186
+ * choice/number checks so a value that will be thrown away anyway cannot
187
+ * abort a parse whose only purpose is reading the global output flags.
188
+ */
189
+ _assign(argv, key, value, spec, opts = {}) {
190
+ const validate = opts.validate !== false;
161
191
  const actual = spec?.name || key;
162
192
  const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
163
- if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
193
+ if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
164
194
  this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
165
195
  return false;
166
196
  }
167
- if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
197
+ if (validate && spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
168
198
  this._fail(`Invalid number for --${actual}: ${value}`);
169
199
  return false;
170
200
  }
@@ -176,7 +206,7 @@ export class LocalCLI {
176
206
  argv[camel(actual)] = argv[actual];
177
207
  for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
178
208
  }
179
- _parseOptions(args, path) {
209
+ _parseOptions(args, path, opts = {}) {
180
210
  const options = this._optionConfig(path);
181
211
  const defs = {};
182
212
  for (const [key, spec] of options) defs[key] = {
@@ -199,11 +229,40 @@ export class LocalCLI {
199
229
  const argv = { _: path.map((n) => n.name) };
200
230
  for (const [key, value] of Object.entries(parsed.values)) {
201
231
  const spec = this._specFor(options, key, value);
202
- if (this._assign(argv, key, value, spec) === false) return null;
232
+ if (this._assign(argv, key, value, spec, opts) === false) return null;
203
233
  }
204
- for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
234
+ for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec, opts);
205
235
  return { values: argv, positionals: parsed.positionals };
206
236
  }
237
+
238
+ /**
239
+ * Resolve the global options from raw tokens WITHOUT dispatching a command and
240
+ * without failing.
241
+ *
242
+ * The format middleware never runs when parsing fails, so the parse-time
243
+ * failure renderer has to know the format the caller asked for. Rather than
244
+ * re-scanning argv with a second, approximate tokenizer (any `--json`
245
+ * anywhere, first `--format` wins, no option termination, no value
246
+ * consumption, no `--no-` negation), it asks the SAME parser the successful
247
+ * path uses. Tolerant mode keeps a value error (`--format xml`, the very
248
+ * failure being rendered) from aborting the resolution.
249
+ *
250
+ * Returns null when the tokens cannot be parsed at all — the caller then
251
+ * falls back to the pipe/TTY default, exactly as it did before.
252
+ */
253
+ parseGlobalOptions(args) {
254
+ const saved = this.failHandler;
255
+ this.failHandler = null; // errors here are not the failure being reported
256
+ try {
257
+ const { path, commandArgs } = this._findPath(args);
258
+ const parsed = this._parseOptions(commandArgs, path, { validate: false });
259
+ return parsed ? parsed.values : null;
260
+ } catch {
261
+ return null;
262
+ } finally {
263
+ this.failHandler = saved;
264
+ }
265
+ }
207
266
  _fail(message, error) {
208
267
  if (this.failHandler) return this.failHandler(message, error);
209
268
  throw error || new Error(message);
package/src/cli.js CHANGED
@@ -75,43 +75,28 @@ import { diffCmd } from './commands/diff.js';
75
75
  import { graphqlCmd } from './commands/graphql.js';
76
76
 
77
77
  /**
78
- * Did the caller explicitly ask for machine output?
78
+ * The output format the caller asked for, resolved from PARSED argv.
79
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.
80
+ * One policy, two callers: the format middleware applies it once a command
81
+ * parses, and the parse-time failure handler applies it when parsing fails.
82
+ * They used to disagree — the failure path re-scanned the raw tokens (any
83
+ * `--json` anywhere, first `--format` match, no option termination, no values
84
+ * consumed by string options, no `--no-` negation), so the stream and the shape
85
+ * of a failure depended on which layer rejected the command instead of on the
86
+ * command line the caller typed.
97
87
  *
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.
88
+ * Precedence is the middleware's, unchanged: the shortcut flags beat `--format`,
89
+ * and among the shortcuts the one applied last wins
90
+ * (csv > markdown > styled > quiet > json).
102
91
  */
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 '';
92
+ export function resolveRequestedFormat(argv) {
93
+ if (!argv) return '';
94
+ if (argv.csv) return 'csv';
95
+ if (argv.markdown) return 'markdown';
96
+ if (argv.styled) return 'styled';
97
+ if (argv.quiet) return 'quiet';
98
+ if (argv.json) return 'json';
99
+ return typeof argv.format === 'string' ? argv.format : '';
115
100
  }
116
101
 
117
102
  /**
@@ -199,15 +184,14 @@ export function buildCLI() {
199
184
  },
200
185
  })
201
186
  .middleware([
202
- // Apply format option overrides
187
+ // Apply format option overrides. The resolution lives in
188
+ // resolveRequestedFormat so the parse-time failure path applies the
189
+ // identical policy instead of approximating it from raw tokens.
203
190
  (argv) => {
204
- if (argv.json) argv.format = 'json';
205
- if (argv.quiet) argv.format = 'quiet';
206
- if (argv.styled) argv.format = 'styled';
207
- if (argv.markdown) argv.format = 'markdown';
208
- if (argv.csv) argv.format = 'csv';
209
- if (argv.format) {
210
- app.output.setFormat(argv.format);
191
+ const requested = resolveRequestedFormat(argv);
192
+ if (requested) {
193
+ argv.format = requested;
194
+ app.output.setFormat(requested);
211
195
  }
212
196
  if (argv.get) {
213
197
  app.output.setJqFilter(argv.get);
@@ -485,22 +469,30 @@ export function buildCLI() {
485
469
  })
486
470
  .onParentDiscovery((argv, { path, subcommands }) => {
487
471
  // 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.
472
+ // its own (records attachments, docs community, and any future parent) —
473
+ // the adapter used to fall through and exit 0 having printed nothing.
474
+ // Children come from the registry, description included, so this list
475
+ // cannot drift from the registrations the way a hand-written one did.
492
476
  if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
493
477
  const canonical = path.join(' ');
494
478
  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 });
479
+ // Same error boundary as every registered handler (AGENTS.md: handlers go
480
+ // through wrap()). Discovery runs outside any command handler, and the
481
+ // shared extractor throws on a `--get` path that does not resolve — an
482
+ // unwrapped callback rejected parse() into an entry point with no
483
+ // rejection handler, so a missing path printed a Node stack instead of the
484
+ // extraction_not_found envelope.
485
+ return wrap(async () => {
486
+ (argv.app || app).ok({
487
+ command: canonical,
488
+ subcommands,
489
+ _formatted: formatCommandList({
490
+ title: `jsn ${canonical}`,
491
+ commands: subcommands,
492
+ footer: `Run "jsn ${canonical} <command> --help" for details.`,
493
+ }),
494
+ }, { summary });
495
+ })(argv);
504
496
  })
505
497
  .help('help', 'Show help')
506
498
  .version(false)
@@ -509,6 +501,13 @@ export function buildCLI() {
509
501
  .fail((msg, err) => {
510
502
  if (err) throw err;
511
503
  const raw = process.argv.slice(2);
504
+ // A parse-time failure never reaches the format middleware, so resolve the
505
+ // requested format with the adapter's OWN tokenizer (same option specs,
506
+ // option termination, consumed values, repeated options, `--no-`
507
+ // negation) instead of re-scanning raw argv — one policy, one answer for
508
+ // this command line no matter which layer rejects it.
509
+ const formatArgv = cliInstance.parseGlobalOptions(raw);
510
+ const requestedFormat = resolveRequestedFormat(formatArgv);
512
511
  if (msg === 'You must specify a command') {
513
512
  const hasLoneProfile = raw.some((a, i, arr) => {
514
513
  if (a !== '--profile' && a !== '-p') return false;
@@ -529,12 +528,23 @@ export function buildCLI() {
529
528
  // stays exactly as it was (exit 0 + grouped help), and an explicit
530
529
  // --json gets the same surface as machine-readable data instead of
531
530
  // prose that a parser cannot read.
532
- if (explicitJsonRequest(raw)) {
531
+ if (requestedFormat === 'json') {
533
532
  app.output.setFormat('json');
533
+ // `--get` is jsn's jq substitute for every JSON shape (AGENTS.md), so
534
+ // the root discovery envelope honours it too: extract when the path
535
+ // resolves, and fail with the structured envelope when it does not,
536
+ // instead of always dumping the whole envelope.
537
+ if (typeof formatArgv?.get === 'string' && formatArgv.get) {
538
+ app.output.setJqFilter(formatArgv.get);
539
+ }
534
540
  const { commands } = cliInstance.commandRegistry();
535
- app.ok({ commands }, {
536
- summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
537
- });
541
+ try {
542
+ app.ok({ commands }, {
543
+ summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
544
+ });
545
+ } catch (discoveryError) {
546
+ exitWithError(discoveryError, { app });
547
+ }
538
548
  process.exit(0);
539
549
  }
540
550
  process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
@@ -546,11 +556,7 @@ export function buildCLI() {
546
556
  // error envelope on stdout when the caller is in JSON mode. Previously
547
557
  // this wrote raw prose to stderr and exited 1, so the exit code and the
548
558
  // 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
- }
559
+ if (requestedFormat) app.output.setFormat(requestedFormat);
554
560
  let hint;
555
561
  try {
556
562
  const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
@@ -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);
@@ -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);
@@ -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 (process.stdin.isTTY) {
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
@@ -140,14 +140,27 @@ export function errExtractionNotFound(path) {
140
140
  /**
141
141
  * Structured error for a destructive action that requires confirmation
142
142
  * but no human is available to answer (non-TTY, pipes, or AI agents).
143
+ *
143
144
  * The hint is written to be machine-parseable: an agent can read the
144
- * re-run command and either present it to its user or execute it.
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.
145
154
  */
146
- 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.';
147
160
  return new AppError(
148
161
  CodeConfirmationRequired,
149
- `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`,
150
- `This is a destructive action. Ask your user to confirm, then re-run:\n ${rerunCmd} --force\nOr set skip_confirmations on the profile to disable prompts:\n jsn auth login <instance> --skip-confirmations`
162
+ `${question} — confirmation required. Pass --force only after your user approves this operation.`,
163
+ hint
151
164
  );
152
165
  }
153
166
 
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { assertSafeExactMatch } from './helpers.js';
3
3
  import { createVerifiedFlowVersion } from './flow-versioning.js';
4
4
  import {
5
+ RECORD_TRIGGER_FAMILY,
5
6
  SUPPORTED_TRIGGER_TYPES,
6
7
  TRIGGER_DEFINITION_TABLE,
7
8
  buildTriggerInputs,
@@ -11,6 +12,7 @@ import {
11
12
  readTriggerInputDefinitions,
12
13
  tableInputFor,
13
14
  flowDesignParameter,
15
+ triggerFamily,
14
16
  } from './flow-triggers.js';
15
17
 
16
18
  const FLOW_ENDPOINT = '/api/now/processflow/flow';
@@ -264,7 +266,11 @@ export function buildTypedInputs(action, schema) {
264
266
  throw new Error(`Action ${action.type} input "${field.name}" has a malformed parameter block; refusing to change it`);
265
267
  }
266
268
  const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
267
- if ((suppliedValue === undefined || suppliedValue === null) && (field.mandatory === true || field.required === true)) throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
269
+ const parameter = field.parameter || {};
270
+ if ((suppliedValue === undefined || suppliedValue === null)
271
+ && (field.mandatory === true || field.required === true || parameter.mandatory === true || parameter.required === true)) {
272
+ throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
273
+ }
268
274
  const value = suppliedValue ?? null;
269
275
  const displayValue = field.displayValue ?? field.display_value ?? value;
270
276
  // global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
@@ -277,13 +283,29 @@ export function buildTypedInputs(action, schema) {
277
283
  children: Array.isArray(field.children) ? field.children : [],
278
284
  displayField: stringValue(field.displayField || field.display_field || ''),
279
285
  scriptActive: false,
280
- parameter: flowDesignParameter(field),
286
+ parameter: actionInputParameter(field),
281
287
  value: { schemaless: false, schemalessValue: '', value },
282
288
  displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
283
289
  };
284
290
  });
285
291
  }
286
292
 
293
+ // Providers hand back the input metadata in one of two shapes: nested inside a
294
+ // `parameter` block (the earlier contract) or FLAT on the input object (the real
295
+ // published Log action's action_types route). The nested block wins when it
296
+ // exists, flat keys fill in only what it leaves empty, and the result is always
297
+ // narrowed to the fields global_snFlowDesigner_FlowDesignParameter accepts.
298
+ export function actionInputParameter(field) {
299
+ if (field?.parameter === undefined) return flowDesignParameter(field);
300
+ const parameter = flowDesignParameter(field.parameter);
301
+ const flat = flowDesignParameter(field);
302
+ for (const key of Object.keys(flat)) {
303
+ if (flat[key] === undefined || flat[key] === '') continue;
304
+ if (parameter[key] === undefined || parameter[key] === '') parameter[key] = flat[key];
305
+ }
306
+ return parameter;
307
+ }
308
+
287
309
  function parseSimpleCondition(condition) {
288
310
  if (!condition) return null;
289
311
  const match = condition.match(/^([A-Za-z_][A-Za-z0-9_.]*)(STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)([^,^]*)$/);
@@ -552,8 +574,8 @@ function updatedTrigger(flow, manifest, descriptor) {
552
574
  if (!existing) throw new Error('Saved flow has no trigger instance');
553
575
  const existingMetadata = parseMaybeJson(existing.metadata, null);
554
576
  if (existing.metadata && (!existingMetadata || typeof existingMetadata !== 'object' || Array.isArray(existingMetadata))) throw new Error('Saved record trigger metadata is unreadable; refusing to replace it');
555
- const existingType = stringValue(existing.triggerType || existing.trigger_type || existingMetadata?.triggerType || existingMetadata?.trigger_type);
556
- if (existingType && existingType !== 'record_create_or_update') throw new Error(`Only record_create_or_update trigger updates are supported; found ${existingType}`);
577
+ const existingFamily = triggerFamily(existing);
578
+ if (existingFamily && existingFamily !== RECORD_TRIGGER_FAMILY) throw new Error(`Only ${RECORD_TRIGGER_FAMILY} trigger updates are supported; found ${existingFamily}`);
557
579
  const inputs = clone(inputList(existing));
558
580
  const tableInput = inputs.find(input => input.name === 'table');
559
581
  const conditionInput = inputs.find(input => input.name === 'condition');
@@ -298,11 +298,7 @@ function buildLabelCache(payload) {
298
298
  if (!payload || typeof payload !== 'object') return cache;
299
299
  let entries = payload.label_cache;
300
300
  if (!Array.isArray(entries)) {
301
- try {
302
- entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
303
- } catch {
304
- entries = [];
305
- }
301
+ entries = parseLabelCacheJson(payload.labelCacheAsJsonString);
306
302
  }
307
303
  for (const e of entries) {
308
304
  if (!e || typeof e !== 'object') continue;
@@ -313,6 +309,20 @@ function buildLabelCache(payload) {
313
309
  return cache;
314
310
  }
315
311
 
312
+ // labelCacheAsJsonString is a JSON document, and JSON `null` or `{}` parses
313
+ // successfully into something that is not iterable — that threw
314
+ // "entries is not iterable" and broke `flows show` on real drafts whose cache is
315
+ // empty. An empty cache simply has no labels.
316
+ function parseLabelCacheJson(value) {
317
+ if (typeof value !== 'string' || !value.trim()) return [];
318
+ try {
319
+ const parsed = JSON.parse(value);
320
+ return Array.isArray(parsed) ? parsed : [];
321
+ } catch {
322
+ return [];
323
+ }
324
+ }
325
+
316
326
  const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
317
327
 
318
328
  /**
package/src/flow-test.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { assertSafeExactMatch } from './helpers.js';
2
+ import { RECORD_TRIGGER_FAMILY, triggerFamily } from './flow-triggers.js';
2
3
 
3
4
  const FLOW_ENDPOINT = '/api/now/processflow/flow';
4
5
 
@@ -30,15 +31,14 @@ function inputValue(trigger, name) {
30
31
  return valueOf(input?.value ?? input?.displayValue);
31
32
  }
32
33
 
33
- function triggerType(trigger) {
34
- return valueOf(trigger?.triggerType || trigger?.trigger_type || trigger?.type);
35
- }
36
-
37
34
  export function deriveRecordTrigger(definition) {
38
35
  const trigger = findTrigger(definition);
39
36
  if (!trigger) throw new Error('Flow definition has no trigger instance');
40
- const type = triggerType(trigger);
41
- if (type !== 'record_create_or_update') throw new Error(`Flow test supports only record_create_or_update triggers; found ${type || 'unknown'}`);
37
+ // `type` is the family; `triggerType` is only the picker category ("Record").
38
+ // triggerFamily keeps a deliberate legacy fallback for saved instances that
39
+ // predate the split.
40
+ const type = triggerFamily(trigger);
41
+ if (type !== RECORD_TRIGGER_FAMILY) throw new Error(`Flow test supports only ${RECORD_TRIGGER_FAMILY} triggers; found ${type || 'unknown'}`);
42
42
  const table = inputValue(trigger, 'table');
43
43
  if (!table) throw new Error('Flow definition record trigger has no table');
44
44
  return { trigger, type, table };
@@ -358,6 +358,59 @@ export function buildTriggerInstance({ flowId, definition, triggerType = '', inp
358
358
  };
359
359
  }
360
360
 
361
+ export const RECORD_TRIGGER_FAMILY = 'record_create_or_update';
362
+
363
+ // A trigger instance carries the FAMILY and the picker CATEGORY in two different
364
+ // fields: `type` is the family Flow Designer executes ("record_create_or_update",
365
+ // "daily", …) and `triggerType` is the category display name ("Record",
366
+ // "Scheduled", …). Creation writes the family to `type` (resolveTriggerDefinition
367
+ // reads it from sys_hub_trigger_definition), and the live ProcessFlow read-back
368
+ // returns exactly that shape, so `type` is authoritative.
369
+ //
370
+ // Instances and fixtures that predate that split stored the family in
371
+ // `triggerType` (and sometimes inside the raw metadata block), so those stay as
372
+ // deliberate fallbacks — otherwise a real saved trigger reads as the category
373
+ // "Record" and every consumer rejects it.
374
+ export function triggerFamily(source) {
375
+ const metadata = parsedObject(source?.metadata);
376
+ const candidates = [
377
+ source?.type,
378
+ source?.trigger_type,
379
+ metadata?.type,
380
+ metadata?.trigger_type,
381
+ source?.triggerType,
382
+ metadata?.triggerType,
383
+ ];
384
+ for (const candidate of candidates) {
385
+ const family = familyValue(candidate);
386
+ if (family) return family;
387
+ }
388
+ return '';
389
+ }
390
+
391
+ function parsedObject(value) {
392
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
393
+ if (typeof value !== 'string' || !value.trim()) return null;
394
+ try {
395
+ const parsed = JSON.parse(value);
396
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ function familyValue(value) {
403
+ if (value === null || value === undefined) return '';
404
+ if (typeof value === 'object') {
405
+ for (const key of ['value', 'display_value', 'sys_id']) {
406
+ const nested = familyValue(value[key]);
407
+ if (nested) return nested;
408
+ }
409
+ return '';
410
+ }
411
+ return String(value).trim();
412
+ }
413
+
361
414
  // The snFlowDesigner GraphQL input types take `attributes` as a String
362
415
  // ("key=value,key=value") even though the ProcessFlow read path renders it as an
363
416
  // object. Sending the object fails validation: "not a valid 'String'".
package/src/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
- const cmd = ['jsn', ...(argv._ || [])].join(' ');
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
- let totalCount;
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
- totalCount = 0;
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
- throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
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