@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/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
 
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
  }