@jacebenson/jsn 4.0.4 → 4.2.0

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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -1,4 +1,8 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
2
+ import { resolveRecord, resolveSysId } from '../resolve-record.js';
3
+ import { declareCapabilities } from '../capabilities.js';
4
+
5
+ declareCapabilities('users', { mutationSubcommands: ['create', 'update', 'delete'] });
2
6
 
3
7
  export function usersCmd(wrap) {
4
8
  return {
@@ -57,17 +61,8 @@ export function usersCmd(wrap) {
57
61
  describe: 'Show a user by user_name or sys_id',
58
62
  handler: wrap(async (argv, app) => {
59
63
  const id = argv.identifier;
60
- assertSafeExactMatch(id);
61
- const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
62
- const params = new URLSearchParams();
63
- params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
64
- params.set('sysparm_limit', '1');
65
- params.set('sysparm_display_value', 'all');
66
- const records = await app.sdk.list('sys_user', params);
67
- if (records.length === 0) {
68
- throw new Error(`User not found: ${id}`);
69
- }
70
- app.ok(records[0], { summary: `User ${id}` });
64
+ const record = await resolveRecord(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
65
+ app.ok(record, { summary: `User ${id}` });
71
66
  }),
72
67
  })
73
68
  .command({
@@ -105,19 +100,8 @@ export function usersCmd(wrap) {
105
100
  .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
106
101
  handler: wrap(async (argv, app) => {
107
102
  const id = argv.identifier;
108
- assertSafeExactMatch(id);
109
103
  const recordData = JSON.parse(argv.data);
110
- const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
111
- const params = new URLSearchParams();
112
- params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
113
- params.set('sysparm_limit', '1');
114
- params.set('sysparm_display_value', 'all');
115
- params.set('sysparm_fields', 'sys_id');
116
- const records = await app.sdk.list('sys_user', params);
117
- if (records.length === 0) {
118
- throw new Error(`User not found: ${id}`);
119
- }
120
- const sysID = getStringField(records[0], 'sys_id');
104
+ const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
121
105
  const updated = await app.sdk.update('sys_user', sysID, recordData);
122
106
  app.ok(updated, {
123
107
  summary: `Updated user ${id}`,
@@ -132,17 +116,7 @@ export function usersCmd(wrap) {
132
116
  describe: 'Delete a user by user_name or sys_id',
133
117
  handler: wrap(async (argv, app) => {
134
118
  const id = argv.identifier;
135
- assertSafeExactMatch(id);
136
- const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
137
- const params = new URLSearchParams();
138
- params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
139
- params.set('sysparm_limit', '1');
140
- params.set('sysparm_fields', 'sys_id');
141
- const records = await app.sdk.list('sys_user', params);
142
- if (records.length === 0) {
143
- throw new Error(`User not found: ${id}`);
144
- }
145
- const sysID = getStringField(records[0], 'sys_id');
119
+ const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
146
120
  await app.sdk.delete('sys_user', sysID);
147
121
  app.ok({ identifier: id, message: 'User deleted' }, {
148
122
  summary: `Deleted user ${id}`,
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { declareCapabilities } from '../capabilities.js';
5
+
6
+ declareCapabilities('version', { noInstance: true, skipDailyChecks: true });
4
7
 
5
8
  const NPM_PKG = '@jacebenson/jsn';
6
9
  const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
package/src/errors.js CHANGED
@@ -9,6 +9,7 @@ export const CodeNetwork = 'network_error';
9
9
  export const CodeAPI = 'api_error';
10
10
  export const CodeAmbiguous = 'ambiguous';
11
11
  export const CodeEmptyResult = 'empty_result';
12
+ export const CodeConfirmationRequired = 'confirmation_required';
12
13
 
13
14
  export class AppError extends Error {
14
15
  constructor(code, message, hint = '', status = 0, cause = null) {
@@ -117,6 +118,20 @@ export function errAmbiguous(resource, matches) {
117
118
  );
118
119
  }
119
120
 
121
+ /**
122
+ * Structured error for a destructive action that requires confirmation
123
+ * but no human is available to answer (non-TTY, pipes, or AI agents).
124
+ * The hint is written to be machine-parseable: an agent can read the
125
+ * re-run command and either present it to its user or execute it.
126
+ */
127
+ export function errConfirmationRequired(question, rerunCmd) {
128
+ return new AppError(
129
+ CodeConfirmationRequired,
130
+ `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`,
131
+ `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`
132
+ );
133
+ }
134
+
120
135
  export function asError(err) {
121
136
  if (!err) return null;
122
137
  if (err instanceof AppError) return err;
@@ -127,15 +142,106 @@ export function isErrorCode(err, code) {
127
142
  return err instanceof AppError && err.code === code;
128
143
  }
129
144
 
145
+ /**
146
+ * ── Unified error renderer ─────────────────────────────────────────────
147
+ * ONE place owns AppError → (stream, text, exit code). wrap()'s catch
148
+ * block, the middleware guard (guardError/guardExit), and any future
149
+ * entry point all route through renderAppError so the rendering can't
150
+ * diverge again (it had: wrap checked app.output.effectiveFormat() while
151
+ * guardError checked argv.format; 'usage' vs 'usage_error' disagreed).
152
+ *
153
+ * Exit-code contract (observable behavior, pinned by tests):
154
+ * not_found → 1 stderr, "Error (not_found): …" + identifier hint
155
+ * usage / usage_error → 2 stderr, "Error (usage): …"
156
+ * system_error → 3 stderr, "Error (system): …"
157
+ * confirmation_required → 1 JSON envelope on stdout in JSON mode
158
+ * (machine consumers), else stderr + hint
159
+ * anything else → 1 stderr, "Error: …"
160
+ *
161
+ * jsonMode resolution: prefer app.output.effectiveFormat() (respects
162
+ * --json/--quiet/etc. shortcuts AND piped-stdout auto-detection); fall
163
+ * back to argv.format for callers without an App (unit tests).
164
+ */
165
+ function resolveJsonMode(app, argv) {
166
+ if (app?.output) return app.output.effectiveFormat() === 'json';
167
+ return argv?.format === 'json';
168
+ }
169
+
170
+ /**
171
+ * Render an error for the terminal/pipe.
172
+ *
173
+ * @param {object} err — Error with optional .code and .hint
174
+ * @param {object} [opts]
175
+ * @param {object} [opts.app] — App instance (format resolution, JSON envelope)
176
+ * @param {object} [opts.argv] — yargs argv (fallback format resolution)
177
+ * @param {string} [opts.identifier] — for not_found: the id that missed
178
+ * @returns {{ stream: 'stdout'|'stderr', text: string|null, exitCode: number, appErr?: boolean }}
179
+ * text is null when the error was rendered through app.err (envelope).
180
+ */
181
+ export function renderAppError(err, { app = null, argv = null, identifier = '' } = {}) {
182
+ const code = err.code || 'unknown';
183
+
184
+ if (code === CodeConfirmationRequired) {
185
+ // Structured error for AI agents / piped consumers: the JSON envelope
186
+ // goes to stdout (where tooling reads) so the agent can surface the
187
+ // decision to its user or re-run with --force.
188
+ if (resolveJsonMode(app, argv) && app) {
189
+ return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
190
+ }
191
+ let text = `Error: ${err.message}\n`;
192
+ if (err.hint) text += `\n${err.hint}\n`;
193
+ return { stream: 'stderr', text, exitCode: 1 };
194
+ }
195
+
196
+ if (code === CodeNotFound) {
197
+ let text = `Error (${code}): ${err.message}\n`;
198
+ if (identifier) {
199
+ text += `\nHint: The identifier "${identifier}" was not found. Check the name or sys_id.\n`;
200
+ }
201
+ return { stream: 'stderr', text, exitCode: 1 };
202
+ }
203
+
204
+ // 'usage' is the legacy literal; CodeUsage ('usage_error') is the
205
+ // exported constant. Both render as the usage class.
206
+ if (code === CodeUsage || code === 'usage') {
207
+ return { stream: 'stderr', text: `Error (usage): ${err.message}\n`, exitCode: 2 };
208
+ }
209
+
210
+ if (code === 'system_error') {
211
+ return { stream: 'stderr', text: `Error (system): ${err.message}\n`, exitCode: 3 };
212
+ }
213
+
214
+ return { stream: 'stderr', text: `Error: ${err.message}\n`, exitCode: 1 };
215
+ }
216
+
217
+ /**
218
+ * Write + exit for a rendered error. Shared by wrap() and guardExit().
219
+ */
220
+ export function exitWithError(err, opts = {}) {
221
+ const { app = null } = opts;
222
+ const r = renderAppError(err, opts);
223
+ if (r.appErr && app) {
224
+ app.err(err);
225
+ } else if (r.stream === 'stdout') {
226
+ process.stdout.write(r.text);
227
+ } else {
228
+ process.stderr.write(r.text);
229
+ }
230
+ process.exit(r.exitCode);
231
+ }
232
+
130
233
  /**
131
234
  * Format a safety-guard failure (mutation guard middleware in cli.js).
132
235
  * In JSON mode writes the documented error envelope to stdout so piped
133
236
  * consumers (`jsn ... --json | jq`) get a structured error instead of a
134
237
  * raw stderr message. Otherwise writes human text to stderr.
135
238
  * Always exits non-zero via guardExit.
239
+ *
240
+ * Routes through the same JSON-mode resolution as renderAppError
241
+ * (app.output.effectiveFormat() when an App is on argv, else argv.format).
136
242
  */
137
243
  export function guardError(argv, { code, message, hint = '' }) {
138
- if (argv?.format === 'json') {
244
+ if (resolveJsonMode(argv?.app, argv)) {
139
245
  return {
140
246
  stream: 'stdout',
141
247
  text: JSON.stringify({ ok: false, error: message, code, hint }) + '\n',
package/src/help.js CHANGED
@@ -115,6 +115,7 @@ const CONFIG_COMMANDS = [
115
115
  ];
116
116
 
117
117
  const DEVELOPER_COMMANDS = [
118
+ { name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
118
119
  { name: 'eval', desc: 'Run server-side JavaScript' },
119
120
  { name: 'rest', desc: 'Raw REST API calls' },
120
121
  { name: 'skill', desc: 'AI agent skill file (show, fetch, install)' },
package/src/helpers.js CHANGED
@@ -5,6 +5,32 @@ import readline from 'node:readline';
5
5
  import { paginatedSearch } from './paginated-search.js';
6
6
  import { isTTY, FormatAuto } from './output.js';
7
7
  import { getActiveProfile } from './config.js';
8
+ import { errConfirmationRequired } from './errors.js';
9
+
10
+ /**
11
+ * Detect whether a human is actually available to answer interactive
12
+ * prompts. Agent tooling (VSCode Copilot, Claude Code, Cursor, etc.)
13
+ * allocates a PTY for subprocesses, so isTTY() returns true even though
14
+ * no one will ever type a response. Those environments set signals that
15
+ * let us fall back to non-interactive behavior instead of hanging:
16
+ *
17
+ * - JSN_NO_PROMPTS=1 (explicit opt-out, documented for agents)
18
+ * - CI / NONINTERACTIVE / GITHUB_ACTIONS
19
+ * - CLAUDE_CODE / COPILOT_* / CURSOR_* agent markers
20
+ * - TERM=dumb
21
+ *
22
+ * @returns {boolean} true when a human can answer a prompt
23
+ */
24
+ export function canPrompt() {
25
+ if (process.env.JSN_NO_PROMPTS === '1') return false;
26
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
27
+ if (process.env.NONINTERACTIVE === 'true' || process.env.NONINTERACTIVE === '1') return false;
28
+ if (process.env.GITHUB_ACTIONS === 'true') return false;
29
+ if (process.env.TERM === 'dumb') return false;
30
+ const agentMarkers = ['CLAUDE_CODE', 'COPILOT_AGENT', 'VSCODE_AGENT', 'CURSOR_AGENT', 'CODEX_AGENT', 'GITHUB_COPILOT_AGENT'];
31
+ if (agentMarkers.some((m) => process.env[m] === 'true' || process.env[m] === '1')) return false;
32
+ return isTTY(process.stdout) && isTTY(process.stdin);
33
+ }
8
34
 
9
35
  /**
10
36
  * Confirm a destructive action before executing.
@@ -25,7 +51,7 @@ export async function confirmDelete(app, argv, question) {
25
51
  const profile = getActiveProfile(app.config);
26
52
  if (profile?.skip_confirmations === true) return true;
27
53
  if (argv.force) return true;
28
- if (isTTY(process.stdout) && isTTY(process.stdin)) {
54
+ if (canPrompt()) {
29
55
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
56
  const answer = await new Promise((resolve) => {
31
57
  rl.question(`${question} (y/N): `, resolve);
@@ -37,9 +63,10 @@ export async function confirmDelete(app, argv, question) {
37
63
  }
38
64
  return true;
39
65
  }
40
- throw new Error(
41
- `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`
42
- );
66
+ // No human available (pipes, scripts, AI agents). Fail fast with a
67
+ // structured error an agent can act on — never hang waiting for stdin.
68
+ const cmd = ['jsn', ...(argv._ || [])].join(' ');
69
+ throw errConfirmationRequired(question, cmd);
43
70
  }
44
71
 
45
72
 
@@ -55,6 +82,84 @@ export function getStringField(record, field) {
55
82
  return String(val);
56
83
  }
57
84
 
85
+ /**
86
+ * Extract the raw value from a field that may be a string or a
87
+ * {display_value, value} object returned by the Table API.
88
+ * Prefers .value (the raw stored value) over .display_value.
89
+ */
90
+ function getRawValue(val) {
91
+ if (val == null) return '';
92
+ if (typeof val === 'object') {
93
+ if (val.value != null) return String(val.value);
94
+ if (val.display_value != null) return String(val.display_value);
95
+ return '';
96
+ }
97
+ return String(val);
98
+ }
99
+
100
+ /**
101
+ * Normalize a value the user supplied in --data for comparison against
102
+ * what the API persisted. Objects like {"value": "<sys_id>"} are unwrapped.
103
+ */
104
+ function normalizeSupplied(val) {
105
+ if (val == null) return '';
106
+ if (typeof val === 'object') {
107
+ if (val.value != null) return String(val.value);
108
+ return '';
109
+ }
110
+ return String(val);
111
+ }
112
+
113
+ /**
114
+ * Write-back verification for records create/update.
115
+ *
116
+ * After a mutation, re-GET the record and compare each field supplied in
117
+ * --data against what actually persisted. Surfaces silent server-side
118
+ * drops (inherited references, ACL-restricted fields, IO: prefixed strings,
119
+ * platform quirks) that ServiceNow ignores without erroring.
120
+ *
121
+ * Skips sys_* fields (system-managed, already warned by checkDerivedFields).
122
+ *
123
+ * @returns {Promise<Array<{field: string, sent: string, got: string}>>}
124
+ */
125
+ export async function verifyWriteBack(app, table, sysID, sentData) {
126
+ const mismatches = [];
127
+ if (!sysID || !sentData || typeof sentData !== 'object') return mismatches;
128
+
129
+ const fields = Object.keys(sentData).filter(f => f !== 'sys_id' && !f.startsWith('sys_'));
130
+ if (fields.length === 0) return mismatches;
131
+
132
+ let persisted;
133
+ try {
134
+ const params = new URLSearchParams();
135
+ params.set('sysparm_query', `sys_id=${sysID}`);
136
+ params.set('sysparm_limit', '1');
137
+ params.set('sysparm_display_value', 'all');
138
+ params.set('sysparm_fields', ['sys_id', ...fields].join(','));
139
+ const records = await app.sdk.list(table, params);
140
+ persisted = records[0] || null;
141
+ } catch {
142
+ // Verification GET failed — don't fail the mutation, just report it.
143
+ mismatches.push({ field: '(verify)', sent: '', got: 'write-back check failed (GET error)' });
144
+ return mismatches;
145
+ }
146
+
147
+ if (!persisted) {
148
+ mismatches.push({ field: '(verify)', sent: '', got: 'record not found after write' });
149
+ return mismatches;
150
+ }
151
+
152
+ for (const field of fields) {
153
+ const sent = normalizeSupplied(sentData[field]);
154
+ const got = getRawValue(persisted[field]);
155
+ if (sent !== got) {
156
+ mismatches.push({ field, sent, got });
157
+ }
158
+ }
159
+
160
+ return mismatches;
161
+ }
162
+
58
163
  export function formatRecordForDisplay(record, columns) {
59
164
  const result = {};
60
165
 
@@ -146,25 +251,36 @@ export function assertSafeExactMatch(value) {
146
251
  * @param {string} opts.singular — e.g. "script include", "log entry"
147
252
  * @param {string[]} opts.columns — default display columns
148
253
  * @param {number} opts.limit — max records (default 20)
149
- * @param {string} opts.query — optional encoded query
254
+ * @param {string} opts.query — optional encoded query, folded into every
255
+ * request (browse pages, search-as-you-type, and the aggregate count)
150
256
  * @param {Function} opts.formatLabel — (record) => string for the picker display
151
257
  * @param {string} opts.labelField — field used to match selection (default: 'name')
152
- * @returns {Promise<void>|null} null if no selection made or non-interactive
258
+ * @param {string} [opts.message] — picker prompt message
259
+ * (default: "Select a <singular>")
260
+ * @param {Function} [opts.formatValue] — (record) => value for the choice;
261
+ * defaults to returning the full record on selection
262
+ * @param {Function} [opts.promptFn] — test seam: replaces paginatedSearch;
263
+ * injecting it also bypasses the TTY/canPrompt gate (the test drives the
264
+ * interactive path explicitly)
265
+ * @returns {Promise<*|null|undefined>} the selected record (or formatValue
266
+ * result), undefined when the user cancelled, null when non-interactive
153
267
  */
154
- export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
268
+ export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name', message, formatValue, promptFn }) {
269
+ const prompt = promptFn || paginatedSearch;
155
270
  app.requireInstance();
156
271
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
157
- if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin)) {
272
+ if (!promptFn && (effectiveFormat !== FormatAuto || !canPrompt())) {
158
273
  return null; // not interactive — caller should fall back to text/table
159
274
  }
160
275
 
161
276
  const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
162
277
  const pickerFields = pickerColumns.join(',');
278
+ const baseQuery = query ? query + '^' : '';
163
279
 
164
280
  // Get total count
165
281
  let totalCount;
166
282
  try {
167
- totalCount = await app.sdk.aggregateCount(table, '');
283
+ totalCount = await app.sdk.aggregateCount(table, query || '');
168
284
  } catch {
169
285
  totalCount = 0;
170
286
  }
@@ -178,9 +294,9 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
178
294
  params.set('sysparm_display_value', 'all');
179
295
  if (pickerFields) params.set('sysparm_fields', pickerFields);
180
296
  if (term) {
181
- params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
297
+ params.set('sysparm_query', `${baseQuery}${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
182
298
  } else {
183
- params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
299
+ params.set('sysparm_query', baseQuery + 'ORDERBYDESCsys_updated_on');
184
300
  params.set('sysparm_offset', String(offset));
185
301
  }
186
302
  const records = await app.sdk.list(table, params);
@@ -190,7 +306,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
190
306
  }
191
307
  return records.map(r => ({
192
308
  name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
193
- value: r,
309
+ value: formatValue ? formatValue(r) : r,
194
310
  }));
195
311
  } catch (err) {
196
312
  console.error('serverSource error:', err.message || err);
@@ -198,14 +314,14 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
198
314
  }
199
315
  }
200
316
 
201
- const selected = await paginatedSearch({
202
- message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
317
+ const selected = await prompt({
318
+ message: message || `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
203
319
  pageSize: 10,
204
320
  totalCount,
205
321
  source: serverSource,
206
322
  });
207
323
 
208
- return selected?.value; // unwrap {name, value} → raw record, undefined on cancel
324
+ return selected?.value; // unwrap {name, value}, undefined on cancel
209
325
  }
210
326
 
211
327
  /**
package/src/mutations.js CHANGED
@@ -1,105 +1,50 @@
1
- // Centralized mutation command registry
2
- // Used by the read-only profile middleware to block mutation commands.
3
- // Each entry is a command path array matching argv._ tokens.
1
+ // Mutation guard — the public face is isMutationCommand(argv), used by the
2
+ // read-only-profile middleware in cli.js. The command-path data is DERIVED
3
+ // from the capability registry (src/capabilities.js) at call time — command
4
+ // modules declare their mutating subcommands once, at the definition site.
5
+ //
6
+ // The registry used to be a hand-maintained list here, and it rotted: it
7
+ // gated `catalog create-item` while the real command is `catalogitems
8
+ // create` (read-only bypass), and listed `restmethods`, a command that was
9
+ // never registered. Derived data can't drift that way.
4
10
 
5
- // Dev commands that expose create/update/delete via buildDevCmd.
6
- // Each generates the root form (`jsn <name> create`) and the legacy dev form
7
- // (`jsn dev <name> create`), so read-only profiles block both spellings.
8
- const DEV_CRUD_COMMANDS = [
9
- 'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
10
- 'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
11
- 'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
12
- 'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
13
- 'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
14
- 'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
15
- ];
11
+ import { mutationPaths } from './capabilities.js';
16
12
 
17
- function devCrudPaths(name) {
18
- return [
19
- [name, 'create'],
20
- [name, 'update'],
21
- [name, 'delete'],
22
- ['dev', name, 'create'],
23
- ['dev', name, 'update'],
24
- ['dev', name, 'delete'],
25
- ];
13
+ /**
14
+ * Back-compat export: the derived mutation path list, refreshed by
15
+ * refreshMutationCommands() — cli.js calls it at the end of buildCLI(),
16
+ * so the data is generated at CLI build time, not hand-maintained.
17
+ * Prefer isMutationCommand().
18
+ */
19
+ export const MUTATION_COMMANDS = [];
20
+
21
+ export function refreshMutationCommands() {
22
+ MUTATION_COMMANDS.length = 0;
23
+ MUTATION_COMMANDS.push(...mutationPaths());
24
+ return MUTATION_COMMANDS;
26
25
  }
27
26
 
28
- export const MUTATION_COMMANDS = [
29
- // Incidents
30
- ['incidents', 'create'],
31
- ['incidents', 'update'],
32
- ['incidents', 'delete'],
33
- // Changes
34
- ['changes', 'create'],
35
- ['changes', 'update'],
36
- ['changes', 'delete'],
37
- // Records
38
- ['records', 'create'],
39
- ['records', 'update'],
40
- ['records', 'delete'],
41
- // Requests
42
- ['requests', 'create'],
43
- ['requests', 'update'],
44
- ['requests', 'delete'],
45
- // Tasks
46
- ['tasks', 'create'],
47
- ['tasks', 'update'],
48
- ['tasks', 'delete'],
49
- // Tickets
50
- ['tickets', 'create'],
51
- ['tickets', 'update'],
52
- ['tickets', 'delete'],
53
- // Users
54
- ['users', 'create'],
55
- ['users', 'update'],
56
- ['users', 'delete'],
57
- // Groups
58
- ['groups', 'create'],
59
- ['groups', 'update'],
60
- ['groups', 'delete'],
61
- // Catalog
62
- ['catalog', 'create-item'],
63
- // Dev CRUD commands (root + `jsn dev` forms)
64
- ...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
65
- // Dev eval (no sub-action) — both spellings must be gated:
66
- // `jsn eval` (root) and legacy `jsn dev eval`
67
- ['eval'],
68
- ['dev', 'eval'],
69
- // Raw REST passthrough — can hit any endpoint with any method,
70
- // so the whole command is a mutation surface on read-only profiles
71
- ['rest'],
72
- ['dev', 'rest'],
73
- // Scopes: create + set (root + dev forms)
74
- ['scopes', 'create'],
75
- ['scopes', 'set'],
76
- ['dev', 'scopes', 'create'],
77
- ['dev', 'scopes', 'set'],
78
- // Domains: set writes the profile config (root + dev forms)
79
- ['domains', 'set'],
80
- ['dev', 'domains', 'set'],
81
- // Update sets: create, set, complete, ignore, parent (root + dev forms)
82
- ['updatesets', 'create'],
83
- ['updatesets', 'set'],
84
- ['updatesets', 'complete'],
85
- ['updatesets', 'ignore'],
86
- ['updatesets', 'parent'],
87
- ['dev', 'updatesets', 'create'],
88
- ['dev', 'updatesets', 'set'],
89
- ['dev', 'updatesets', 'complete'],
90
- ['dev', 'updatesets', 'ignore'],
91
- ['dev', 'updatesets', 'parent'],
92
- ];
27
+ function currentPaths(paths) {
28
+ return paths || refreshMutationCommands();
29
+ }
93
30
 
94
31
  /**
95
32
  * Check if the parsed argv matches any mutation command pattern.
33
+ * Prefix match: a command whose argv._ path begins with a mutation pattern
34
+ * IS that mutation (trailing positionals like a sys_id or file path don't
35
+ * change the intent). This lets positional mutation subcommands (e.g.
36
+ * `records attachments <id> add <file>`) be gated on read-only profiles.
37
+ *
96
38
  * @param {object} argv — yargs parsed argv with `_` array
39
+ * @param {Array<string[]>} [paths] — mutation path patterns; defaults to
40
+ * the paths derived from the capability registry. (Tests pass explicit
41
+ * paths; production callers should let it derive.)
97
42
  * @returns {boolean}
98
43
  */
99
- export function isMutationCommand(argv) {
44
+ export function isMutationCommand(argv, paths) {
100
45
  const cmd = argv._ || [];
101
- for (const pattern of MUTATION_COMMANDS) {
102
- if (pattern.length !== cmd.length) continue;
46
+ for (const pattern of currentPaths(paths)) {
47
+ if (pattern.length > cmd.length) continue;
103
48
  let match = true;
104
49
  for (let i = 0; i < pattern.length; i++) {
105
50
  if (pattern[i] !== cmd[i]) {