@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.
- package/README.md +18 -2
- package/package.json +4 -3
- package/src/app.js +48 -64
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -65
- package/src/commands/_ticket.js +37 -93
- package/src/commands/approvals.js +350 -0
- package/src/commands/atf.js +635 -0
- package/src/commands/auth.js +93 -13
- package/src/commands/catalog/enrich.js +139 -0
- package/src/commands/catalog.js +25 -81
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +63 -111
- package/src/commands/dev/_simple.js +16 -12
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/domains.js +4 -0
- package/src/commands/dev/eval.js +65 -7
- package/src/commands/dev/flows.js +3 -0
- package/src/commands/dev/logs.js +97 -0
- package/src/commands/dev/rest.js +6 -0
- package/src/commands/dev/restmessage.js +1 -0
- package/src/commands/dev/scopes.js +49 -25
- package/src/commands/dev/scrapi.js +5 -0
- package/src/commands/dev/soapmessages.js +1 -0
- package/src/commands/dev/updatesets.js +145 -15
- package/src/commands/dev.js +3 -2
- package/src/commands/docs/db.js +14 -0
- package/src/commands/docs/docs.js +9 -21
- package/src/commands/docs/search.js +33 -10
- package/src/commands/groups.js +3 -0
- package/src/commands/records.js +274 -12
- package/src/commands/setup.js +5 -0
- package/src/commands/skill.js +3 -0
- package/src/commands/snippets.js +155 -0
- package/src/commands/tickets.js +9 -29
- package/src/commands/users.js +9 -35
- package/src/commands/version.js +3 -0
- package/src/errors.js +107 -1
- package/src/help.js +1 -0
- package/src/helpers.js +131 -15
- package/src/mutations.js +36 -91
- package/src/output.js +195 -64
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
package/src/commands/users.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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}`,
|
package/src/commands/version.js
CHANGED
|
@@ -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?.
|
|
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 (
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
* @
|
|
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,
|
|
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 || !
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
102
|
-
if (pattern.length
|
|
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]) {
|