@jacebenson/jsn 4.1.0 → 4.2.1
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 -1
- package/src/app.js +49 -65
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -79
- package/src/commands/_ticket.js +36 -92
- 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 +15 -133
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +62 -110
- 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 +40 -4
- 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 +254 -9
- 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 +92 -1
- package/src/help.js +1 -0
- package/src/helpers.js +22 -11
- package/src/mutations.js +36 -91
- package/src/output.js +187 -61
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
package/src/errors.js
CHANGED
|
@@ -142,15 +142,106 @@ export function isErrorCode(err, code) {
|
|
|
142
142
|
return err instanceof AppError && err.code === code;
|
|
143
143
|
}
|
|
144
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
|
+
|
|
145
233
|
/**
|
|
146
234
|
* Format a safety-guard failure (mutation guard middleware in cli.js).
|
|
147
235
|
* In JSON mode writes the documented error envelope to stdout so piped
|
|
148
236
|
* consumers (`jsn ... --json | jq`) get a structured error instead of a
|
|
149
237
|
* raw stderr message. Otherwise writes human text to stderr.
|
|
150
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).
|
|
151
242
|
*/
|
|
152
243
|
export function guardError(argv, { code, message, hint = '' }) {
|
|
153
|
-
if (argv?.
|
|
244
|
+
if (resolveJsonMode(argv?.app, argv)) {
|
|
154
245
|
return {
|
|
155
246
|
stream: 'stdout',
|
|
156
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
|
@@ -251,25 +251,36 @@ export function assertSafeExactMatch(value) {
|
|
|
251
251
|
* @param {string} opts.singular — e.g. "script include", "log entry"
|
|
252
252
|
* @param {string[]} opts.columns — default display columns
|
|
253
253
|
* @param {number} opts.limit — max records (default 20)
|
|
254
|
-
* @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)
|
|
255
256
|
* @param {Function} opts.formatLabel — (record) => string for the picker display
|
|
256
257
|
* @param {string} opts.labelField — field used to match selection (default: 'name')
|
|
257
|
-
* @
|
|
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
|
|
258
267
|
*/
|
|
259
|
-
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;
|
|
260
270
|
app.requireInstance();
|
|
261
271
|
const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
|
|
262
|
-
if (effectiveFormat !== FormatAuto || !canPrompt()) {
|
|
272
|
+
if (!promptFn && (effectiveFormat !== FormatAuto || !canPrompt())) {
|
|
263
273
|
return null; // not interactive — caller should fall back to text/table
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
|
|
267
277
|
const pickerFields = pickerColumns.join(',');
|
|
278
|
+
const baseQuery = query ? query + '^' : '';
|
|
268
279
|
|
|
269
280
|
// Get total count
|
|
270
281
|
let totalCount;
|
|
271
282
|
try {
|
|
272
|
-
totalCount = await app.sdk.aggregateCount(table, '');
|
|
283
|
+
totalCount = await app.sdk.aggregateCount(table, query || '');
|
|
273
284
|
} catch {
|
|
274
285
|
totalCount = 0;
|
|
275
286
|
}
|
|
@@ -283,9 +294,9 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
283
294
|
params.set('sysparm_display_value', 'all');
|
|
284
295
|
if (pickerFields) params.set('sysparm_fields', pickerFields);
|
|
285
296
|
if (term) {
|
|
286
|
-
params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
|
|
297
|
+
params.set('sysparm_query', `${baseQuery}${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
|
|
287
298
|
} else {
|
|
288
|
-
params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
|
|
299
|
+
params.set('sysparm_query', baseQuery + 'ORDERBYDESCsys_updated_on');
|
|
289
300
|
params.set('sysparm_offset', String(offset));
|
|
290
301
|
}
|
|
291
302
|
const records = await app.sdk.list(table, params);
|
|
@@ -295,7 +306,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
295
306
|
}
|
|
296
307
|
return records.map(r => ({
|
|
297
308
|
name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
|
|
298
|
-
value: r,
|
|
309
|
+
value: formatValue ? formatValue(r) : r,
|
|
299
310
|
}));
|
|
300
311
|
} catch (err) {
|
|
301
312
|
console.error('serverSource error:', err.message || err);
|
|
@@ -303,14 +314,14 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
303
314
|
}
|
|
304
315
|
}
|
|
305
316
|
|
|
306
|
-
const selected = await
|
|
307
|
-
message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
|
|
317
|
+
const selected = await prompt({
|
|
318
|
+
message: message || `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
|
|
308
319
|
pageSize: 10,
|
|
309
320
|
totalCount,
|
|
310
321
|
source: serverSource,
|
|
311
322
|
});
|
|
312
323
|
|
|
313
|
-
return selected?.value; // unwrap {name, value}
|
|
324
|
+
return selected?.value; // unwrap {name, value}, undefined on cancel
|
|
314
325
|
}
|
|
315
326
|
|
|
316
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]) {
|
package/src/output.js
CHANGED
|
@@ -7,6 +7,36 @@ export const FormatJSON = 'json';
|
|
|
7
7
|
export const FormatMarkdown = 'markdown';
|
|
8
8
|
export const FormatStyled = 'styled';
|
|
9
9
|
export const FormatQuiet = 'quiet';
|
|
10
|
+
export const FormatCSV = 'csv';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a dotpath (`.data[0].number`, `data.records.0.sys_id`) against an
|
|
14
|
+
* object. Supports dotted keys and `[N]` array indexes. Returns undefined
|
|
15
|
+
* when the path doesn't resolve. Dependency-free — not a jq replacement.
|
|
16
|
+
*/
|
|
17
|
+
export function resolvePath(obj, path) {
|
|
18
|
+
const s = String(path || '').trim().replace(/^\./, '');
|
|
19
|
+
if (!s) return undefined;
|
|
20
|
+
// Tokenize on dots, '[' and ']'. Pure-integer segments become array indexes.
|
|
21
|
+
const tokens = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < s.length) {
|
|
24
|
+
const ch = s[i];
|
|
25
|
+
if (ch === '.' || ch === '[' || ch === ']') { i += 1; continue; }
|
|
26
|
+
let j = i;
|
|
27
|
+
while (j < s.length && s[j] !== '.' && s[j] !== '[' && s[j] !== ']') j += 1;
|
|
28
|
+
const part = s.slice(i, j);
|
|
29
|
+
const n = Number(part);
|
|
30
|
+
tokens.push(Number.isInteger(n) && String(n) === part ? n : part);
|
|
31
|
+
i = j;
|
|
32
|
+
}
|
|
33
|
+
let cur = obj;
|
|
34
|
+
for (const tok of tokens) {
|
|
35
|
+
if (cur == null) return undefined;
|
|
36
|
+
cur = cur[tok];
|
|
37
|
+
}
|
|
38
|
+
return cur;
|
|
39
|
+
}
|
|
10
40
|
|
|
11
41
|
export function hyperlink(text, url) {
|
|
12
42
|
if (!url) return text;
|
|
@@ -27,6 +57,31 @@ export function isTTY(writer) {
|
|
|
27
57
|
return writer.isTTY === true;
|
|
28
58
|
}
|
|
29
59
|
|
|
60
|
+
// Default styled-table column widths for well-known ServiceNow fields.
|
|
61
|
+
// Pure presentation defaults — commands can override per envelope via
|
|
62
|
+
// `data.columnWidths` (see writeRecordsTable).
|
|
63
|
+
const DEFAULT_COLUMN_WIDTHS = {
|
|
64
|
+
number: 14,
|
|
65
|
+
short_description: 48,
|
|
66
|
+
priority: 14,
|
|
67
|
+
state: 10,
|
|
68
|
+
assigned_to: 15,
|
|
69
|
+
risk: 10,
|
|
70
|
+
name: 30,
|
|
71
|
+
user_name: 15,
|
|
72
|
+
email: 30,
|
|
73
|
+
sys_id: 32,
|
|
74
|
+
sys_updated_on: 22,
|
|
75
|
+
sys_created_on: 22,
|
|
76
|
+
opened_at: 22,
|
|
77
|
+
closed_at: 22,
|
|
78
|
+
sys_updated_by: 20,
|
|
79
|
+
sys_created_by: 20,
|
|
80
|
+
opened_by: 20,
|
|
81
|
+
u_category: 20,
|
|
82
|
+
u_subcategory: 20,
|
|
83
|
+
};
|
|
84
|
+
|
|
30
85
|
// Minimal ANSI colors — replaces chalk (only used for error/hint styling,
|
|
31
86
|
// which is already TTY-gated by callers).
|
|
32
87
|
const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
|
|
@@ -49,6 +104,16 @@ export class OutputWriter {
|
|
|
49
104
|
return this;
|
|
50
105
|
}
|
|
51
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Set a dotpath extractor. When set, successful output is reduced to the
|
|
109
|
+
* value at the path (resolved against the JSON envelope) and printed as-is.
|
|
110
|
+
* Gives pipelines a dependency-free `--get` without needing jq.
|
|
111
|
+
*/
|
|
112
|
+
setJqFilter(path) {
|
|
113
|
+
this.jqFilter = path || '';
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
52
117
|
effectiveFormat() {
|
|
53
118
|
if (this.format === FormatAuto) {
|
|
54
119
|
return isTTY(this.writer) ? FormatStyled : FormatJSON;
|
|
@@ -57,6 +122,16 @@ export class OutputWriter {
|
|
|
57
122
|
}
|
|
58
123
|
|
|
59
124
|
ok(data, opts = {}) {
|
|
125
|
+
// --get extractor: resolve against the JSON envelope and print just that
|
|
126
|
+
// value as JSON. Takes precedence over styling/table formats — a path
|
|
127
|
+
// extract is inherently scriptable, not presentational.
|
|
128
|
+
if (this.jqFilter) {
|
|
129
|
+
const envelope = { ok: true, data, summary: opts.summary || '', breadcrumbs: opts.breadcrumbs || [] };
|
|
130
|
+
const value = resolvePath(envelope, this.jqFilter);
|
|
131
|
+
this.writer.write(JSON.stringify(value, null, 2) + '\n');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
60
135
|
const format = this.effectiveFormat();
|
|
61
136
|
switch (format) {
|
|
62
137
|
case FormatJSON:
|
|
@@ -65,6 +140,8 @@ export class OutputWriter {
|
|
|
65
140
|
return this.writeMarkdown(data, opts);
|
|
66
141
|
case FormatQuiet:
|
|
67
142
|
return this.writeQuiet(data);
|
|
143
|
+
case FormatCSV:
|
|
144
|
+
return this.writeCSV(data, opts);
|
|
68
145
|
case FormatStyled:
|
|
69
146
|
return this.writeStyled(data, opts);
|
|
70
147
|
default:
|
|
@@ -160,7 +237,58 @@ export class OutputWriter {
|
|
|
160
237
|
}
|
|
161
238
|
}
|
|
162
239
|
|
|
240
|
+
writeCSV(data, opts) {
|
|
241
|
+
if (opts.summary && isTTY(this.writer)) {
|
|
242
|
+
this.writer.write(opts.summary + '\n\n');
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(data)) {
|
|
245
|
+
this.writeCSVTable(data);
|
|
246
|
+
} else if (data && typeof data === 'object' && Array.isArray(data.records)) {
|
|
247
|
+
this.writeCSVTable(data.records);
|
|
248
|
+
} else {
|
|
249
|
+
this.writeCSVTable([data]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
writeCSVTable(rows) {
|
|
254
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
255
|
+
this.writer.write('');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const columns = Object.keys(rows[0]);
|
|
259
|
+
this.writer.write(columns.map(csvEscape).join(',') + '\n');
|
|
260
|
+
for (const row of rows) {
|
|
261
|
+
const cells = columns.map((col) => {
|
|
262
|
+
const val = row[col];
|
|
263
|
+
if (val == null) return '';
|
|
264
|
+
if (typeof val === 'object') {
|
|
265
|
+
if (val.display_value != null) return csvEscape(val.display_value);
|
|
266
|
+
if (val.value != null) return csvEscape(val.value);
|
|
267
|
+
return csvEscape(JSON.stringify(val));
|
|
268
|
+
}
|
|
269
|
+
return csvEscape(val);
|
|
270
|
+
});
|
|
271
|
+
this.writer.write(cells.join(',') + '\n');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
163
275
|
writeStyled(data, opts) {
|
|
276
|
+
// ── The `_formatted` interface ──────────────────────────────────────
|
|
277
|
+
// Commands may ship a pre-rendered styled view as `data._formatted`
|
|
278
|
+
// (a plain string). The writer prints it verbatim and suppresses both
|
|
279
|
+
// opts.summary and the records table / field-dump renderers — the
|
|
280
|
+
// curated visual IS the output. JSON/quiet/markdown/csv formats ignore
|
|
281
|
+
// `_formatted` entirely and serialize the full envelope as usual, so
|
|
282
|
+
// scripting (`--json`, `--get`) is unaffected.
|
|
283
|
+
//
|
|
284
|
+
// This is the documented escape hatch for command-curated visuals
|
|
285
|
+
// (auth status, cmdb show cards/trees, docs search results, …). View
|
|
286
|
+
// logic belongs in the command that owns the domain — output.js stays
|
|
287
|
+
// generic. Other underscore-prefixed side-channel keys commands may
|
|
288
|
+
// attach: `_context` ({instance_url, table} consumed by the record
|
|
289
|
+
// renderer), `_attachments`, `_variables` (record card sections), plus
|
|
290
|
+
// `records`/`columns`/`table`/`relationships` for the generic table and
|
|
291
|
+
// record views below.
|
|
164
292
|
const hasFormatted = data && typeof data === 'object' && data._formatted;
|
|
165
293
|
|
|
166
294
|
if (opts.summary && !hasFormatted) {
|
|
@@ -178,48 +306,18 @@ export class OutputWriter {
|
|
|
178
306
|
}
|
|
179
307
|
|
|
180
308
|
const { isRecord, tableName } = detectRecord(data);
|
|
181
|
-
if (isRecord) {
|
|
309
|
+
if (isRecord && !data._formatted) {
|
|
310
|
+
// _formatted (e.g. cmdb show card) replaces the raw field dump —
|
|
311
|
+
// the visual is the curated view; JSON/quiet keep full data.
|
|
182
312
|
this.writeFormattedRecord(data, tableName);
|
|
183
313
|
}
|
|
184
314
|
|
|
185
|
-
if (Array.isArray(data.profiles) && data.profiles.length > 0) {
|
|
186
|
-
this.writer.write('\n');
|
|
187
|
-
for (const p of data.profiles) {
|
|
188
|
-
const prefix = p.default ? '* ' : ' ';
|
|
189
|
-
const authIcon = p.authenticated ? '✓' : '✗';
|
|
190
|
-
|
|
191
|
-
// Show verified status if we got one
|
|
192
|
-
let verifiedStr = '';
|
|
193
|
-
if (p.verified === true) {
|
|
194
|
-
verifiedStr = ' ✅';
|
|
195
|
-
} else if (p.verified === false) {
|
|
196
|
-
verifiedStr = ' ⚠️';
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Show stale hint if >7 days since last seen
|
|
200
|
-
let staleStr = '';
|
|
201
|
-
if (p.stale && p.days_since_last_seen) {
|
|
202
|
-
staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Show lock icon for read-only profiles
|
|
206
|
-
const lockIcon = p.read_only ? ' 🔒' : '';
|
|
207
|
-
|
|
208
|
-
// Show confirmations-off badge for skip_confirmations profiles
|
|
209
|
-
const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
|
|
210
|
-
|
|
211
|
-
if (p.name) {
|
|
212
|
-
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
213
|
-
} else if (p.username) {
|
|
214
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
215
|
-
} else {
|
|
216
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
315
|
if (Array.isArray(data.records) && data.records.length > 0) {
|
|
222
|
-
|
|
316
|
+
// A _formatted visual (e.g. cmdb relationship tree) replaces the
|
|
317
|
+
// flat table in styled mode — showing both is noise.
|
|
318
|
+
if (!data._formatted) {
|
|
319
|
+
this.writeRecordsTable(data);
|
|
320
|
+
}
|
|
223
321
|
}
|
|
224
322
|
} else {
|
|
225
323
|
if (!opts.summary) {
|
|
@@ -241,27 +339,10 @@ export class OutputWriter {
|
|
|
241
339
|
const table = data.table || '';
|
|
242
340
|
const instanceURL = data.context?.instance_url || '';
|
|
243
341
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
state: 10,
|
|
249
|
-
assigned_to: 15,
|
|
250
|
-
risk: 10,
|
|
251
|
-
name: 30,
|
|
252
|
-
user_name: 15,
|
|
253
|
-
email: 30,
|
|
254
|
-
sys_id: 32,
|
|
255
|
-
sys_updated_on: 22,
|
|
256
|
-
sys_created_on: 22,
|
|
257
|
-
opened_at: 22,
|
|
258
|
-
closed_at: 22,
|
|
259
|
-
sys_updated_by: 20,
|
|
260
|
-
sys_created_by: 20,
|
|
261
|
-
opened_by: 20,
|
|
262
|
-
u_category: 20,
|
|
263
|
-
u_subcategory: 20,
|
|
264
|
-
};
|
|
342
|
+
// Default per-column widths for known ServiceNow fields; unknown columns
|
|
343
|
+
// fall back to 20. Commands may override/extend per call by attaching
|
|
344
|
+
// `data.columnWidths = { <col>: <width> }` — hints win over defaults.
|
|
345
|
+
const colWidths = { ...DEFAULT_COLUMN_WIDTHS, ...(data.columnWidths || {}) };
|
|
265
346
|
|
|
266
347
|
this.writer.write('\n');
|
|
267
348
|
|
|
@@ -285,7 +366,9 @@ export class OutputWriter {
|
|
|
285
366
|
const row = records[i];
|
|
286
367
|
for (let j = 0; j < columns.length; j++) {
|
|
287
368
|
const col = columns[j];
|
|
288
|
-
|
|
369
|
+
// Coerce to string — rows can carry ServiceNow reference objects
|
|
370
|
+
// ({display_value, value}) and numeric cells (e.g. cmdb depth)
|
|
371
|
+
let val = getDisplayValue(row[col]);
|
|
289
372
|
const width = colWidths[col] || 20;
|
|
290
373
|
|
|
291
374
|
if (j === 0 && instanceURL && table && row.sys_id) {
|
|
@@ -374,7 +457,7 @@ export class OutputWriter {
|
|
|
374
457
|
}
|
|
375
458
|
}
|
|
376
459
|
|
|
377
|
-
const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_')).sort();
|
|
460
|
+
const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_') && f !== 'relationships').sort();
|
|
378
461
|
if (otherFields.length > 0) {
|
|
379
462
|
this.writer.write('─ Other ─\n');
|
|
380
463
|
for (const field of otherFields) {
|
|
@@ -391,6 +474,33 @@ export class OutputWriter {
|
|
|
391
474
|
this.writer.write('\n');
|
|
392
475
|
}
|
|
393
476
|
|
|
477
|
+
// Inline relationships (e.g. cmdb show). Grouped shape:
|
|
478
|
+
// { parents: [], siblings: [], children: [], counts: {} } renders as
|
|
479
|
+
// sections; a flat array renders as one compact list.
|
|
480
|
+
if (data.relationships && typeof data.relationships === 'object' && !Array.isArray(data.relationships)) {
|
|
481
|
+
const renderGroup = (label, rows, total, arrow, viaKey) => {
|
|
482
|
+
if ((!rows || rows.length === 0) && !total) return;
|
|
483
|
+
const capNote = total && total > (rows?.length || 0) ? ` (showing ${rows.length} of ${total})` : '';
|
|
484
|
+
this.writer.write(`─ ${label}${capNote} ─\n`);
|
|
485
|
+
for (const rel of rows || []) {
|
|
486
|
+
const via = viaKey && rel[viaKey] ? ` (via ${rel[viaKey]})` : '';
|
|
487
|
+
this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}${via}\n`);
|
|
488
|
+
}
|
|
489
|
+
this.writer.write('\n');
|
|
490
|
+
};
|
|
491
|
+
const counts = data.relationships.counts || {};
|
|
492
|
+
renderGroup('Parents', data.relationships.parents, counts.parents, '↑');
|
|
493
|
+
renderGroup('Siblings', data.relationships.siblings, counts.siblings, '↔', 'via');
|
|
494
|
+
renderGroup('Children', data.relationships.children, counts.children, '↓');
|
|
495
|
+
} else if (Array.isArray(data.relationships) && data.relationships.length > 0) {
|
|
496
|
+
this.writer.write('─ Relationships ─\n');
|
|
497
|
+
for (const rel of data.relationships) {
|
|
498
|
+
const arrow = rel.direction === 'upstream' ? '↑' : '↓';
|
|
499
|
+
this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}\n`);
|
|
500
|
+
}
|
|
501
|
+
this.writer.write('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
394
504
|
// ── Attachments ──
|
|
395
505
|
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
396
506
|
this.writer.write('─ Attachments ─\n');
|
|
@@ -458,6 +568,12 @@ function detectRecord(data) {
|
|
|
458
568
|
}
|
|
459
569
|
}
|
|
460
570
|
|
|
571
|
+
// Explicit table stamp set by command handlers (e.g. _context.table on
|
|
572
|
+
// cmdb_rel_ci rows, which have neither sys_class_name nor number).
|
|
573
|
+
if (!tableName && data._context && typeof data._context === 'object' && data._context.table) {
|
|
574
|
+
tableName = data._context.table;
|
|
575
|
+
}
|
|
576
|
+
|
|
461
577
|
return { isRecord: hasSysID && (hasSysClass || tableName), tableName };
|
|
462
578
|
}
|
|
463
579
|
|
|
@@ -486,3 +602,13 @@ export function getDisplayValue(val) {
|
|
|
486
602
|
}
|
|
487
603
|
return String(val);
|
|
488
604
|
}
|
|
605
|
+
|
|
606
|
+
/** Escape a single CSV field: quote when it contains a delimiter, quote, or newline. */
|
|
607
|
+
export function csvEscape(val) {
|
|
608
|
+
if (val == null) return '';
|
|
609
|
+
const s = String(val);
|
|
610
|
+
if (/[",\n\r]/.test(s)) {
|
|
611
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
612
|
+
}
|
|
613
|
+
return s;
|
|
614
|
+
}
|