@jacebenson/jsn 4.3.12 → 4.3.13-dev.100.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 +55 -0
- package/package.json +2 -1
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +24 -4
- package/src/cli.js +39 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +58 -37
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +38 -31
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/groups.js +11 -8
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +64 -119
- package/src/commands/requests.js +10 -16
- package/src/commands/rest.js +15 -2
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +31 -22
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +65 -13
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +10 -2
- package/src/helpers.js +44 -0
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
package/src/commands/_ticket.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
|
|
1
2
|
// Generic command builder for CRUD operations on a ServiceNow table
|
|
2
3
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
4
|
|
|
4
|
-
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
|
|
6
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
7
|
+
import { resolveSysId } from '../resolve-record.js';
|
|
6
8
|
import { FormatAuto } from '../output.js';
|
|
7
9
|
import { declareCapabilities } from '../capabilities.js';
|
|
8
10
|
|
|
9
|
-
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
|
|
11
|
+
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap, opts = {}) {
|
|
10
12
|
// Ticket-style CRUD: list/show are reads; create/update/delete mutate.
|
|
11
13
|
// Root-level command only.
|
|
12
14
|
declareCapabilities(displayName, { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
@@ -14,13 +16,14 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
14
16
|
command: `${displayName} [subcommand]`,
|
|
15
17
|
aliases: [table, alias],
|
|
16
18
|
describe: `Manage ${displayName} (e.g. "${displayName} list --query priority=1")`,
|
|
19
|
+
discovery: { table, defaultColumns, benefits: ['Configured list columns', 'Ticket-specific create/update flags'] },
|
|
17
20
|
builder: (yargs) => {
|
|
18
21
|
return yargs
|
|
19
22
|
.command({
|
|
20
23
|
command: 'list',
|
|
21
24
|
aliases: ['ls'],
|
|
22
25
|
describe: `List ${table}`,
|
|
23
|
-
builder: (y) => y
|
|
26
|
+
builder: (y) => recordOptions(y)
|
|
24
27
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
25
28
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
26
29
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
@@ -73,24 +76,19 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
73
76
|
app.ok(record, {
|
|
74
77
|
summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
|
|
75
78
|
breadcrumbs: [
|
|
76
|
-
{ action: 'update', cmd: `jsn ${alias} update ${number}
|
|
79
|
+
{ action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
|
|
77
80
|
{ action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
|
|
78
81
|
],
|
|
79
82
|
});
|
|
80
83
|
return;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
const params = new URLSearchParams();
|
|
84
|
-
params.set('sysparm_limit', String(limit));
|
|
85
|
-
params.set('sysparm_offset', String(offset));
|
|
86
|
-
params.set('sysparm_display_value', 'all');
|
|
87
86
|
const fields = resolveFieldsParam(columns);
|
|
88
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
89
87
|
const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
90
|
-
params
|
|
88
|
+
const params = recordListParams({ limit, offset, fields, query: q });
|
|
91
89
|
|
|
92
|
-
const records = await app.sdk
|
|
93
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
90
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
|
|
91
|
+
const displayRecords = fields && !argv.displayValues && opts.listProjection !== 'full' ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
94
92
|
|
|
95
93
|
const breadcrumbs = [
|
|
96
94
|
{ action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
|
|
@@ -129,9 +127,15 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
129
127
|
command: 'show <number>',
|
|
130
128
|
aliases: ['get'],
|
|
131
129
|
describe: `Show a specific ${displayName}`,
|
|
130
|
+
builder: y => {
|
|
131
|
+
const command = relatedListOptions(recordOptions(y));
|
|
132
|
+
return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true, describe: 'Fetch declared essential children; use --no-essential to skip' }) : command;
|
|
133
|
+
},
|
|
132
134
|
handler: wrap(async (argv, app) => {
|
|
133
135
|
const number = argv.number;
|
|
134
|
-
const record = await
|
|
136
|
+
const record = await getRecord(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
137
|
+
if (opts.onShow) await opts.onShow(record, app, argv);
|
|
138
|
+
await enrichRelatedLists(record, { table, argv, app });
|
|
135
139
|
record._context = {
|
|
136
140
|
instance_url: app.getEffectiveInstance(),
|
|
137
141
|
table,
|
|
@@ -139,7 +143,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
139
143
|
app.ok(record, {
|
|
140
144
|
summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
|
|
141
145
|
breadcrumbs: [
|
|
142
|
-
{ action: 'update', cmd: `jsn ${alias} update ${number}
|
|
146
|
+
{ action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
|
|
143
147
|
{ action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
|
|
144
148
|
],
|
|
145
149
|
});
|
|
@@ -148,21 +152,21 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
148
152
|
.command({
|
|
149
153
|
command: 'create',
|
|
150
154
|
describe: `Create a new ${displayName}`,
|
|
151
|
-
builder: (y) => y
|
|
155
|
+
builder: (y) => recordOptions(y, false)
|
|
152
156
|
.option('description', { alias: 'd', type: 'string', describe: 'Short description' })
|
|
153
157
|
.option('priority', { type: 'string', describe: 'Priority (1-5)' })
|
|
154
|
-
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
158
|
+
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
159
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
160
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
161
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
155
162
|
handler: wrap(async (argv, app) => {
|
|
156
|
-
const recordData = {};
|
|
157
|
-
if (argv.data) {
|
|
158
|
-
Object.assign(recordData, JSON.parse(argv.data));
|
|
159
|
-
}
|
|
163
|
+
const recordData = buildRecordPayload(argv, { required: false });
|
|
160
164
|
if (argv.description) recordData.short_description = argv.description;
|
|
161
165
|
if (argv.priority) recordData.priority = argv.priority;
|
|
162
166
|
if (!recordData.short_description) {
|
|
163
|
-
throw new Error('short_description is required (use --description or --data)');
|
|
167
|
+
throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
|
|
164
168
|
}
|
|
165
|
-
const record = await app.sdk
|
|
169
|
+
const record = await createRecord(app.sdk, { table, data: recordData });
|
|
166
170
|
app.ok(record, {
|
|
167
171
|
summary: `Created ${displayName} ${getStringField(record, 'number')}`,
|
|
168
172
|
breadcrumbs: [
|
|
@@ -174,13 +178,16 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
174
178
|
.command({
|
|
175
179
|
command: 'update <number>',
|
|
176
180
|
describe: `Update a ${displayName}`,
|
|
177
|
-
builder: (y) => y
|
|
178
|
-
.option('data', { type: 'string',
|
|
181
|
+
builder: (y) => recordOptions(y, false)
|
|
182
|
+
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
183
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
184
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
185
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
179
186
|
handler: wrap(async (argv, app) => {
|
|
180
187
|
const number = argv.number;
|
|
181
|
-
const recordData =
|
|
182
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
183
|
-
const updated = await app.sdk
|
|
188
|
+
const recordData = buildRecordPayload(argv);
|
|
189
|
+
const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
190
|
+
const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
|
|
184
191
|
app.ok(updated, {
|
|
185
192
|
summary: `Updated ${displayName} ${number}`,
|
|
186
193
|
breadcrumbs: [
|
|
@@ -192,12 +199,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
192
199
|
.command({
|
|
193
200
|
command: 'delete <number>',
|
|
194
201
|
describe: `Delete a ${displayName}`,
|
|
195
|
-
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
202
|
+
builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
196
203
|
handler: wrap(async (argv, app) => {
|
|
197
204
|
const number = argv.number;
|
|
198
205
|
await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
|
|
199
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
200
|
-
await app.sdk
|
|
206
|
+
const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
207
|
+
await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
|
|
201
208
|
app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
|
|
202
209
|
summary: `Deleted ${displayName} ${number}`,
|
|
203
210
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { confirmDelete, getStringField } from '../helpers.js';
|
|
4
|
+
import { errUsage } from '../errors.js';
|
|
5
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
6
|
+
|
|
7
|
+
declareCapabilities('attachments', { mutationSubcommands: ['upload', 'add'] });
|
|
8
|
+
const identifier = value => {
|
|
9
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw errUsage('Supply a safe record or attachment sys_id.');
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// A server-provided filename may never choose directories or platform devices.
|
|
14
|
+
export function attachmentFilename(value, id) {
|
|
15
|
+
const name = path.posix.basename(String(value || '').replaceAll('\\', '/'))
|
|
16
|
+
.replace(/[<>:"|?*\p{Cc}]/gu, '_').replace(/[. ]+$/g, '');
|
|
17
|
+
return !name || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name) ? `${id}.bin` : name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const attachmentsCmd = wrap => ({
|
|
21
|
+
command: 'attachments [subcommand]',
|
|
22
|
+
aliases: ['attachment'],
|
|
23
|
+
describe: 'List, download and upload files attached to any record',
|
|
24
|
+
builder: y => y
|
|
25
|
+
.epilogue([
|
|
26
|
+
'List files: jsn attachments list --sys-id <record-id>',
|
|
27
|
+
'Download by attachment ID: jsn attachments download <attachment-id> --out ./file.bin',
|
|
28
|
+
'Upload: jsn attachments upload ./file.txt --table incident --sys-id <record-id>',
|
|
29
|
+
'download/get and upload/add are aliases. The former records attachments subtree has moved here.',
|
|
30
|
+
'Downloads use a safe basename and refuse to overwrite existing local files unless --overwrite is supplied.',
|
|
31
|
+
].join('\n'))
|
|
32
|
+
.command({
|
|
33
|
+
command: 'list',
|
|
34
|
+
describe: 'List attachment metadata for a parent record',
|
|
35
|
+
builder: y => y
|
|
36
|
+
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
37
|
+
.option('table', { type: 'string', describe: 'Parent table (output context only)' }),
|
|
38
|
+
handler: wrap(async (argv, app) => {
|
|
39
|
+
const id = identifier(argv['sys-id']);
|
|
40
|
+
app.requireInstance();
|
|
41
|
+
const attachments = await app.sdk.listAttachments(id);
|
|
42
|
+
app.ok({ sys_id: id, table: argv.table || '', count: attachments.length, attachments,
|
|
43
|
+
context: { instance_url: app.getEffectiveInstance() } }, { summary: `${attachments.length} attachment(s) on record ${id}` });
|
|
44
|
+
}),
|
|
45
|
+
})
|
|
46
|
+
.command({
|
|
47
|
+
command: 'download <attachment-id>',
|
|
48
|
+
aliases: ['get'],
|
|
49
|
+
describe: 'Download file bytes by attachment ID; no parent record ID needed',
|
|
50
|
+
builder: y => y
|
|
51
|
+
.positional('attachment-id', { type: 'string', describe: 'Attachment sys_id' })
|
|
52
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Local output path (default: safe attachment filename in cwd)' })
|
|
53
|
+
.option('overwrite', { type: 'boolean', default: false, describe: 'Replace an existing local file' }),
|
|
54
|
+
handler: wrap(async (argv, app) => {
|
|
55
|
+
const id = identifier(argv.attachmentId ?? argv['attachment-id']);
|
|
56
|
+
app.requireInstance();
|
|
57
|
+
let out = argv.out;
|
|
58
|
+
if (!out) {
|
|
59
|
+
const metadata = await app.sdk.request(`${app.sdk.baseURL}/api/now/attachment/${id}`, { method: 'GET' });
|
|
60
|
+
out = attachmentFilename(getStringField(metadata?.result, 'file_name'), id);
|
|
61
|
+
}
|
|
62
|
+
const bytes = await app.sdk.getAttachment(id);
|
|
63
|
+
await fs.writeFile(out, bytes, { flag: argv.overwrite ? 'w' : 'wx' });
|
|
64
|
+
app.ok({ sys_id: id, out, bytes: bytes.length }, { summary: `Wrote ${bytes.length} bytes to ${out}` });
|
|
65
|
+
}),
|
|
66
|
+
})
|
|
67
|
+
.command({
|
|
68
|
+
command: 'upload <file>',
|
|
69
|
+
aliases: ['add'],
|
|
70
|
+
describe: 'Upload a local file to a record (requires confirmation)',
|
|
71
|
+
builder: y => y
|
|
72
|
+
.positional('file', { type: 'string', describe: 'Local file to upload' })
|
|
73
|
+
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
74
|
+
.option('table', { type: 'string', demandOption: true, describe: 'Parent table, for example incident' })
|
|
75
|
+
.option('name', { type: 'string', describe: 'Attachment filename (default: local basename)' })
|
|
76
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip upload confirmation after approval' }),
|
|
77
|
+
handler: wrap(async (argv, app) => {
|
|
78
|
+
const id = identifier(argv['sys-id']);
|
|
79
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(argv.table)) throw errUsage('Supply a valid parent table name.');
|
|
80
|
+
app.requireInstance();
|
|
81
|
+
await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${id}?`);
|
|
82
|
+
const content = await fs.readFile(argv.file);
|
|
83
|
+
const fileName = argv.name || path.basename(argv.file);
|
|
84
|
+
const created = await app.sdk.addAttachment(argv.table, id, content, fileName);
|
|
85
|
+
app.ok({ sys_id: getStringField(created, 'sys_id'), file_name: fileName, table: argv.table, recordsys_id: id }, { summary: `Attached ${fileName}` });
|
|
86
|
+
}),
|
|
87
|
+
}),
|
|
88
|
+
});
|
package/src/commands/auth.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { detectCommandAudience } from '../command-audience.js';
|
|
1
2
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
|
|
2
|
-
import { errUsage, errUsageHint } from '../errors.js';
|
|
3
|
+
import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
|
|
3
4
|
import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
4
5
|
import { declareCapabilities } from '../capabilities.js';
|
|
5
6
|
import process from 'node:process';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
6
8
|
import { refreshSession } from '../session.js';
|
|
7
9
|
|
|
8
10
|
function syncSession(app) {
|
|
@@ -139,7 +141,7 @@ export function resolveTargetProfile(argv = {}, cfg = {}, instanceURL = '') {
|
|
|
139
141
|
*/
|
|
140
142
|
export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
|
|
141
143
|
if (argv.basic || argv.password) return true;
|
|
142
|
-
if (argv.oauth || argv.code || argv.printUrl || argv.browser || argv.gck || argv.headers) return false;
|
|
144
|
+
if (argv.oauth || argv.code || argv.printUrl || argv.browser || argv.gck || argv.headers || argv.headersFile !== undefined) return false;
|
|
143
145
|
if (targetProfile) return targetProfile.auth_method === 'basic';
|
|
144
146
|
return Object.values(cfg.profiles || {}).some(profile =>
|
|
145
147
|
profile.instance_url === instanceURL && profile.auth_method === 'basic'
|
|
@@ -147,7 +149,7 @@ export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', target
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
|
|
150
|
-
if (argv.gck || argv.headers || argv.browser) return true;
|
|
152
|
+
if (argv.gck || argv.headers || argv.browser || argv.headersFile !== undefined) return true;
|
|
151
153
|
if (argv.basic || argv.password || argv.oauth || argv.code || argv.printUrl) return false;
|
|
152
154
|
if (targetProfile) return targetProfile.auth_method === 'gck';
|
|
153
155
|
return Object.values(cfg.profiles || {}).some(profile =>
|
|
@@ -157,10 +159,11 @@ export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetPr
|
|
|
157
159
|
|
|
158
160
|
export function validateLoginSelectors(argv = {}, targetProfile = null) {
|
|
159
161
|
const basic = argv.basic === true || argv.password === true;
|
|
160
|
-
const gck = argv.gck === true || argv.headers !== undefined || argv.browser === true;
|
|
162
|
+
const gck = argv.gck === true || argv.headers !== undefined || argv.headersFile !== undefined || argv.browser === true;
|
|
161
163
|
const oauth = argv.oauth === true || argv.code !== undefined || argv.printUrl === true || argv.waitFile !== undefined;
|
|
162
164
|
if (oauth && (basic || gck)) throw errUsage('Choose only one authentication method: --oauth, --basic, or --browser/--gck.');
|
|
163
165
|
if (argv.waitFile && !argv.printUrl) throw errUsage('--wait-file requires --print-url.');
|
|
166
|
+
if (argv.headers !== undefined && argv.headersFile !== undefined) throw errUsage('Choose --headers-file or --headers, not both.');
|
|
164
167
|
if (argv.code !== undefined && argv.printUrl) throw errUsage('--code cannot be combined with --print-url.');
|
|
165
168
|
if (argv.browserUrl !== undefined && !argv.browser) {
|
|
166
169
|
throw errUsage('--browser-url requires --browser.');
|
|
@@ -168,8 +171,8 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
|
|
|
168
171
|
if (argv.browserName !== undefined && (!argv.browser || argv.browserUrl !== undefined)) {
|
|
169
172
|
throw errUsage('--browser-name requires --browser and cannot be combined with --browser-url.');
|
|
170
173
|
}
|
|
171
|
-
if (argv.browser && (argv.headers !== undefined || argv.code !== undefined || argv.printUrl || argv.waitFile)) {
|
|
172
|
-
throw errUsage('--browser cannot be combined with --headers, --code, --print-url, or --wait-file.');
|
|
174
|
+
if (argv.browser && (argv.headers !== undefined || argv.headersFile !== undefined || argv.code !== undefined || argv.printUrl || argv.waitFile)) {
|
|
175
|
+
throw errUsage('--browser cannot be combined with --headers-file, --headers, --code, --print-url, or --wait-file.');
|
|
173
176
|
}
|
|
174
177
|
if (basic && gck) {
|
|
175
178
|
throw errUsage('Basic and browser-session selectors are mutually exclusive; choose --basic/--password or --gck/--headers.');
|
|
@@ -278,6 +281,22 @@ export async function readBrowserSessionInput() {
|
|
|
278
281
|
});
|
|
279
282
|
}
|
|
280
283
|
|
|
284
|
+
/** Read captured session data without putting it in argv or printing a prompt. */
|
|
285
|
+
async function readBrowserSessionFile(file) {
|
|
286
|
+
let content;
|
|
287
|
+
if (file === '-') {
|
|
288
|
+
if (process.stdin.isTTY) throw errUsageHint('--headers-file - requires piped or redirected input.', 'Pipe captured headers into this command, or pass a file path.');
|
|
289
|
+
const chunks = [];
|
|
290
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
291
|
+
content = Buffer.concat(chunks).toString('utf8');
|
|
292
|
+
} else {
|
|
293
|
+
try { content = await readFile(file, 'utf8'); }
|
|
294
|
+
catch { throw errUsageHint('Could not read browser-session input file.', 'Check the --headers-file path and file permissions.'); }
|
|
295
|
+
}
|
|
296
|
+
if (!content.trim()) throw errUsage('Browser-session input is empty. Supply captured headers or a cURL request.');
|
|
297
|
+
return content;
|
|
298
|
+
}
|
|
299
|
+
|
|
281
300
|
/** Collect human answers, then call the same login operation as auth login. */
|
|
282
301
|
export async function loginWizard(app, argv = {}) {
|
|
283
302
|
const { input, select, confirm, password } = await import('../prompts.js');
|
|
@@ -382,13 +401,18 @@ export async function modifyProfile(app, argv = {}) {
|
|
|
382
401
|
const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
|
|
383
402
|
const profile = app.config.profiles?.[name];
|
|
384
403
|
if (!profile) throw new Error(`Profile not found: ${name}`);
|
|
385
|
-
|
|
404
|
+
if (argv.reauth !== undefined && !['auto', 'confirm', 'never'].includes(argv.reauth)) {
|
|
405
|
+
throw errUsage('--reauth must be auto, confirm, or never.');
|
|
406
|
+
}
|
|
407
|
+
const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts', 'reauth'].some(flag => argv[flag] !== undefined);
|
|
386
408
|
if (explicitSettings) {
|
|
387
409
|
if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
|
|
388
410
|
const updated = await configureProfileOptions({ ...profile }, argv);
|
|
411
|
+
if (argv.reauth !== undefined) updated.reauth = argv.reauth;
|
|
389
412
|
app.config.profiles[name] = updated;
|
|
390
413
|
await saveConfig(app.config);
|
|
391
|
-
app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false
|
|
414
|
+
app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false,
|
|
415
|
+
...(argv.reauth !== undefined ? { reauth: updated.reauth } : {}) },
|
|
392
416
|
{ summary: `Settings saved for ${name}` });
|
|
393
417
|
return;
|
|
394
418
|
}
|
|
@@ -588,8 +612,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
588
612
|
const useBasic = shouldUseBasicAuth(argv, methodConfig, instanceURL, targetProfile);
|
|
589
613
|
const useGck = shouldUseGckAuth(argv, methodConfig, instanceURL, targetProfile);
|
|
590
614
|
const interactive = canUsePicker(app, argv);
|
|
591
|
-
|
|
592
|
-
|
|
615
|
+
const capturedHeaders = argv.headers !== undefined || argv.headersFile !== undefined;
|
|
616
|
+
if (useGck && !argv.browser && !capturedHeaders && !interactive) {
|
|
617
|
+
throw errUsage('Browser sign-in needs a human. Pass --browser to explicitly open a login window, or supply a captured session with --headers-file <path|->.');
|
|
593
618
|
}
|
|
594
619
|
if (!useGck && !useBasic && !argv.printUrl && !argv.code && !interactive) {
|
|
595
620
|
throw errUsage('OAuth login requires --print-url followed by --code <code> when noninteractive.');
|
|
@@ -606,9 +631,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
606
631
|
// --basic (with --password as a compatibility alias): authenticate
|
|
607
632
|
// with basic auth from env vars and persist it for this profile.
|
|
608
633
|
if (useGck) {
|
|
609
|
-
const credentials = (argv.browser || (!argv.gck && !
|
|
634
|
+
const credentials = (argv.browser || (!argv.gck && !capturedHeaders))
|
|
610
635
|
? await loginUsingBrowser(app, instanceURL, argv, targetUsername || null)
|
|
611
|
-
: await app.auth.loginWithGck(instanceURL, argv.headers || await readBrowserSessionInput(), targetUsername || null);
|
|
636
|
+
: await app.auth.loginWithGck(instanceURL, argv.headersFile !== undefined ? await readBrowserSessionFile(argv.headersFile) : argv.headers || await readBrowserSessionInput(), targetUsername || null);
|
|
612
637
|
targetUsername = credentials?.username || targetUsername;
|
|
613
638
|
} else if (useBasic) {
|
|
614
639
|
let credentials;
|
|
@@ -686,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
686
711
|
// profile exists; preserve it when verification is unavailable.
|
|
687
712
|
username = username || targetUsername;
|
|
688
713
|
|
|
714
|
+
const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
|
|
715
|
+
|
|
689
716
|
// Save to profiles — deduplicate by instance URL
|
|
690
717
|
if (!app.config.profiles) {
|
|
691
718
|
app.config.profiles = {};
|
|
@@ -716,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
716
743
|
read_only: preferences.read_only,
|
|
717
744
|
skip_confirmations: preferences.skip_confirmations,
|
|
718
745
|
include_counts: preferences.include_counts,
|
|
746
|
+
...(commandAudience ? { command_audience: commandAudience } : {}),
|
|
719
747
|
};
|
|
748
|
+
if (!commandAudience) delete app.config.profiles[profileName].command_audience;
|
|
720
749
|
if (!targetProfile && interactive) {
|
|
721
750
|
try {
|
|
722
751
|
const domains = await import('./domains.js');
|
|
@@ -803,10 +832,33 @@ Examples:
|
|
|
803
832
|
}
|
|
804
833
|
const target = resolveTargetProfile(argv, app.config, instanceURL);
|
|
805
834
|
const username = target.profile?.username ?? null;
|
|
806
|
-
if (username
|
|
807
|
-
|
|
835
|
+
if (!username && !canLogoutBareProfile(app, instanceURL, target.profile, !!target.name)) {
|
|
836
|
+
throw errUsageHint('Cannot identify which stored credentials to clear.', 'Choose an unambiguous identity with --profile <name>.');
|
|
837
|
+
}
|
|
838
|
+
const selector = target.name ? `--profile ${quoteShellArg(target.name)}` : quoteShellArg(instanceURL);
|
|
839
|
+
const rerun = `jsn auth logout ${selector}`;
|
|
840
|
+
const warning = `Clear locally stored credentials for ${target.name ? `profile "${target.name}" at ` : ''}${instanceURL}${username ? ` (user ${username})` : ''}? You will need to authenticate again. The profile is retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`;
|
|
841
|
+
if (!argv.force) {
|
|
842
|
+
if (!canUsePicker(app, argv)) throw errConfirmationRequired(warning, rerun);
|
|
843
|
+
const { confirm } = await import('../prompts.js');
|
|
844
|
+
if (!await confirm({ message: warning, default: false })) {
|
|
845
|
+
app.ok({ logged_out: false, credentials_cleared: false, profile: target.name, instance: instanceURL },
|
|
846
|
+
{ summary: 'Logout cancelled; stored credentials were retained.' });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
808
849
|
}
|
|
809
|
-
app.
|
|
850
|
+
const cleared = app.auth.logout(instanceURL, username) === true;
|
|
851
|
+
const nextActions = cleared ? [
|
|
852
|
+
{ action: 'Authenticate again', cmd: `jsn auth login ${selector}`, description: 'Restore locally stored authentication' },
|
|
853
|
+
...(target.name ? [{ action: 'Remove retained profile', cmd: `jsn auth remove ${quoteShellArg(target.name)}`, description: 'Remove this connection if no longer needed' }] : []),
|
|
854
|
+
] : [];
|
|
855
|
+
app.ok({ logged_out: cleared, credentials_cleared: cleared, profile: target.name, instance: instanceURL,
|
|
856
|
+
profile_retained: !!target.name, environment_credentials_affected: false, remote_session_terminated: false,
|
|
857
|
+
next_actions: nextActions }, {
|
|
858
|
+
summary: cleared ? `✓ Cleared local credentials for ${target.name || instanceURL}. Profile retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`
|
|
859
|
+
: `No stored credentials were cleared for ${target.name || instanceURL}. Environment credentials and ServiceNow/Chrome sessions are unaffected.`,
|
|
860
|
+
breadcrumbs: nextActions,
|
|
861
|
+
});
|
|
810
862
|
}
|
|
811
863
|
|
|
812
864
|
export async function showAuthStatus(argv, app) {
|
|
@@ -966,10 +1018,16 @@ Examples:
|
|
|
966
1018
|
|
|
967
1019
|
const target = resolveTargetProfile(argv, app.config, instanceURL);
|
|
968
1020
|
const targetProfile = target.profile;
|
|
1021
|
+
const browser = argv.browser || (targetProfile?.auth_method === 'gck'
|
|
1022
|
+
&& argv.headers === undefined && argv.headersFile === undefined && canUsePicker(app, argv));
|
|
1023
|
+
validateLoginSelectors({ ...argv, browser }, targetProfile);
|
|
1024
|
+
if (targetProfile?.auth_method !== 'gck' && (argv.headersFile !== undefined || argv.headers !== undefined || argv.browser)) {
|
|
1025
|
+
throw errUsage('Browser-session input requires a browser-session profile. Select it with --profile, or add a separate connection with auth login --name <name>.');
|
|
1026
|
+
}
|
|
969
1027
|
if (targetProfile?.auth_method === 'gck') {
|
|
970
1028
|
return loginProfile({
|
|
971
1029
|
...argv, instance: instanceURL, profile: target.name,
|
|
972
|
-
browser
|
|
1030
|
+
browser,
|
|
973
1031
|
}, app);
|
|
974
1032
|
}
|
|
975
1033
|
const targetAuthOptions = {
|
|
@@ -997,12 +1055,22 @@ Examples:
|
|
|
997
1055
|
const name = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
998
1056
|
if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
|
|
999
1057
|
app.config.profiles[name].domain_separation = hasDS;
|
|
1058
|
+
|
|
1000
1059
|
await saveConfig(app.config);
|
|
1001
1060
|
}
|
|
1002
1061
|
} catch {
|
|
1003
1062
|
// Non-fatal — capability probe is best-effort
|
|
1004
1063
|
}
|
|
1005
1064
|
|
|
1065
|
+
const audienceName = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
1066
|
+
const audienceProfile = app.config.profiles?.[audienceName];
|
|
1067
|
+
if (audienceProfile?.instance_url === instanceURL) {
|
|
1068
|
+
const sdk = app.getSDKForProfile
|
|
1069
|
+
? app.getSDKForProfile(instanceURL, { ...targetAuthOptions, profile: audienceProfile }) : app.sdk;
|
|
1070
|
+
audienceProfile.command_audience = await detectCommandAudience(sdk, audienceProfile.username, instanceURL);
|
|
1071
|
+
await saveConfig(app.config);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1006
1074
|
app.ok({
|
|
1007
1075
|
refreshed: true,
|
|
1008
1076
|
instance: instanceURL,
|
|
@@ -1055,7 +1123,11 @@ export function authCmd(wrap) {
|
|
|
1055
1123
|
type: 'boolean',
|
|
1056
1124
|
})
|
|
1057
1125
|
.option('headers', {
|
|
1058
|
-
describe: '
|
|
1126
|
+
describe: 'Inline browser headers (compatibility); prefer --headers-file to keep secrets out of arguments',
|
|
1127
|
+
type: 'string',
|
|
1128
|
+
})
|
|
1129
|
+
.option('headers-file', {
|
|
1130
|
+
describe: 'Read browser headers/cURL from a file, or - for stdin; no secrets in arguments',
|
|
1059
1131
|
type: 'string',
|
|
1060
1132
|
})
|
|
1061
1133
|
.option('browser', {
|
|
@@ -1094,7 +1166,8 @@ export function authCmd(wrap) {
|
|
|
1094
1166
|
})
|
|
1095
1167
|
.command({
|
|
1096
1168
|
command: 'logout [instance]',
|
|
1097
|
-
describe: 'Clear
|
|
1169
|
+
describe: 'Clear local credentials after approval; retains the profile. Unnecessary after ordinary operations; does not sign out of ServiceNow/Chrome or clear environment credentials.',
|
|
1170
|
+
builder: sub => sub.option('force', { type: 'boolean', describe: 'Clear credentials only after explicit approval for this exact logout' }),
|
|
1098
1171
|
handler: wrap(logoutProfile),
|
|
1099
1172
|
})
|
|
1100
1173
|
.command({
|
|
@@ -1114,7 +1187,8 @@ export function authCmd(wrap) {
|
|
|
1114
1187
|
.option('browser', { type: 'boolean', describe: 'Explicitly open a browser to renew the session' })
|
|
1115
1188
|
.option('browser-name', { type: 'string', describe: 'Browser to launch: chromium, chrome, or edge' })
|
|
1116
1189
|
.option('browser-url', { type: 'string', describe: 'Advanced: local browser debugging URL (requires --browser)' })
|
|
1117
|
-
.option('headers', { type: 'string', describe: '
|
|
1190
|
+
.option('headers', { type: 'string', describe: 'Inline captured headers (compatibility); prefer --headers-file' })
|
|
1191
|
+
.option('headers-file', { type: 'string', describe: 'Captured headers/cURL file, or - for stdin; keeps secrets out of arguments' }),
|
|
1118
1192
|
handler: wrap(refreshProfile),
|
|
1119
1193
|
})
|
|
1120
1194
|
.command({
|
|
@@ -1134,6 +1208,7 @@ export function authCmd(wrap) {
|
|
|
1134
1208
|
.option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
|
|
1135
1209
|
.option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
|
|
1136
1210
|
.option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
|
|
1211
|
+
.option('reauth', { type: 'string', choices: ['auto', 'confirm', 'never'], describe: 'Browser recovery policy: auto, confirm (default), or never; interactive only' })
|
|
1137
1212
|
.option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
|
|
1138
1213
|
.positional('name', {
|
|
1139
1214
|
describe: 'Profile name to modify',
|
package/src/commands/b4rules.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { buildDevCmd } from './_generic.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
// Query rules are explicitly marked; their order is not a classification rule.
|
|
4
|
+
export const b4rulesCmd = wrap => buildDevCmd('b4rules', 'sys_script', ['b4rule'],
|
|
5
|
+
['name', 'collection', 'active', 'order', 'sys_scope'], wrap, {
|
|
6
|
+
singular: 'before-query business rule', scopeValidation: true,
|
|
7
|
+
extraQuery: 'when=before^action_query=true',
|
|
8
|
+
describe: 'Inspect before-query business rules that can filter record reads',
|
|
9
|
+
help: 'Before-query business rules use when=before and the Boolean action_query=true. Filter collection and active status for the affected table. For field behavior, see jsn columns --help; do not bypass server rules or ACLs.',
|
|
10
|
+
});
|
package/src/commands/changes.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const changeDefaultColumns = ['number', 'short_description', 'risk', 'state', 'assigned_to'];
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
4
2
|
|
|
5
3
|
export function changesCmd(wrap) {
|
|
6
|
-
return
|
|
4
|
+
return buildProfileCommand('changes', wrap);
|
|
7
5
|
}
|
package/src/commands/columns.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { fieldBehaviorHelp } from '../field-behavior.js';
|
|
1
2
|
import { buildDevCmd } from './_generic.js';
|
|
2
3
|
|
|
3
4
|
export const columnsCmd = (wrap) => {
|
|
4
5
|
const base = buildDevCmd('columns', 'sys_dictionary', ['column', 'col'],
|
|
5
6
|
['element', 'column_label', 'internal_type', 'mandatory', 'max_length', 'active'],
|
|
6
|
-
wrap, { singular: 'column', scopeValidation: true });
|
|
7
|
+
wrap, { singular: 'column', scopeValidation: true, help: fieldBehaviorHelp });
|
|
7
8
|
|
|
8
9
|
// Add --table filter
|
|
9
10
|
const origBuilder = base.builder;
|
|
@@ -4,6 +4,7 @@ import { canUsePicker } from '../helpers.js';
|
|
|
4
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
5
5
|
import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
|
|
6
6
|
import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
|
|
7
|
+
import { customizationGuidance, formatCustomizationGuidance } from '../customization-guidance.js';
|
|
7
8
|
|
|
8
9
|
// Only capture needs an instance. Saved reports never need network access.
|
|
9
10
|
declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
|
|
@@ -25,7 +26,7 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
|
|
|
25
26
|
function show(app, report, options = {}) {
|
|
26
27
|
const actionable = buildActionableReport(report);
|
|
27
28
|
const triage = actionable.artifacts.map(({ category, category_label, identity, triage: classification }) => ({ category, category_label, identity, ...classification }));
|
|
28
|
-
app.ok({ ...report, triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
29
|
+
app.ok({ ...report, interpretation: customizationGuidance(), triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
29
30
|
}
|
|
30
31
|
export function customizationsCmd(wrap) {
|
|
31
32
|
return {
|
|
@@ -36,7 +37,8 @@ export function customizationsCmd(wrap) {
|
|
|
36
37
|
builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
|
|
37
38
|
.option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
|
|
38
39
|
.option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
|
|
39
|
-
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
40
|
+
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
41
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nExample: jsn customizations capture --label review --customer-scope x_acme_app --json`),
|
|
40
42
|
handler: wrap(async (argv, app) => {
|
|
41
43
|
app.requireInstance();
|
|
42
44
|
if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
|
|
@@ -55,18 +57,21 @@ export function customizationsCmd(wrap) {
|
|
|
55
57
|
})
|
|
56
58
|
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
|
|
57
59
|
.option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
|
|
58
|
-
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
|
|
60
|
+
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
|
|
61
|
+
.epilogue(`Offline review: inspect coverage, interpretation and triage in --json output.\n${formatCustomizationGuidance()}\nExample: jsn customizations show <run_id> --get data.triage`), handler: wrap(async (argv, app) => {
|
|
59
62
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
60
63
|
if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
|
|
61
64
|
}) })
|
|
62
65
|
.command({ command: 'html [run_id]', describe: 'Write a searchable, self-contained HTML review of a saved capture', builder: b => b
|
|
63
|
-
.option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
|
|
66
|
+
.option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
|
|
67
|
+
.epilogue('Offline review with the same interpretation and coverage as show; writes a local file without changing instance records.\nExample: jsn customizations html <run_id> --out review.html'), handler: wrap(async (argv, app) => {
|
|
64
68
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
65
69
|
if (!report) return;
|
|
66
70
|
const file = writeCustomizationHtml(report, { out: argv.out });
|
|
67
|
-
app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0) }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
|
|
71
|
+
app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0), interpretation: customizationGuidance() }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
|
|
68
72
|
}) })
|
|
69
|
-
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
73
|
+
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
74
|
+
builder: b => b.epilogue(`Offline metadata/evidence comparison; inspect both captures' coverage before interpreting changes.\n${formatCustomizationGuidance()}\nExample: jsn customizations compare <baseline_id> <new_id> --json`), handler: wrap(async (argv, app) => {
|
|
70
75
|
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
|
71
76
|
if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
|
|
72
77
|
const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
|
|
@@ -76,7 +81,8 @@ export function customizationsCmd(wrap) {
|
|
|
76
81
|
if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
|
|
77
82
|
const result = compareReports(baseline, newer);
|
|
78
83
|
app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
|
|
79
|
-
}) })
|
|
84
|
+
}) })
|
|
85
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nStart: jsn customizations capture --help\nReview: jsn customizations show --help\nCompare: jsn customizations compare --help`),
|
|
80
86
|
handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
|
|
81
87
|
};
|
|
82
88
|
}
|