@jacebenson/jsn 4.3.12 → 4.3.13-dev.101.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 +95 -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 +41 -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/scan.js +234 -0
- 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 +66 -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 +11 -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/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
package/src/commands/records.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, verifyWriteBack } from '../helpers.js';
|
|
2
|
+
import { listRecords, recordListParams, getRecord, createRecord, updateRecord, deleteRecord } from '../records-engine.js';
|
|
3
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
4
|
+
import { errUsage } from '../errors.js';
|
|
4
5
|
import { declareCapabilities } from '../capabilities.js';
|
|
5
6
|
|
|
6
7
|
declareCapabilities('records', {
|
|
7
|
-
mutationSubcommands: ['create', 'update', 'delete', 'bulk'
|
|
8
|
+
mutationSubcommands: ['create', 'update', 'delete', 'bulk'],
|
|
8
9
|
});
|
|
9
10
|
|
|
10
11
|
const tableDefaultColumns = {
|
|
@@ -67,6 +68,15 @@ function getDefaultColumns(table) {
|
|
|
67
68
|
return tableDefaultColumns[table] || ['sys_id'];
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
function recordLookup(argv) {
|
|
72
|
+
const sysId = argv['sys-id'];
|
|
73
|
+
if ((sysId === undefined) === (argv.identifier === undefined)) throw errUsage('Supply exactly one of --sys-id or --identifier.');
|
|
74
|
+
if (sysId !== undefined && argv.identifierField && argv.identifierField !== 'sys_id') throw errUsage('--sys-id cannot be combined with a different --identifier-field.');
|
|
75
|
+
return { table: argv.table, identifier: sysId ?? argv.identifier,
|
|
76
|
+
identifierField: sysId !== undefined ? 'sys_id' : (argv.identifierField || 'sys_id'),
|
|
77
|
+
displayValues: argv.displayValues, resource: 'Record' };
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
export function recordsCmd(wrap) {
|
|
71
81
|
return {
|
|
72
82
|
command: 'records [subcommand]',
|
|
@@ -83,7 +93,16 @@ export function recordsCmd(wrap) {
|
|
|
83
93
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
84
94
|
.option('limit', { type: 'number', default: 20, describe: 'Max records' })
|
|
85
95
|
.option('offset', { type: 'number', default: 0, describe: 'Offset' })
|
|
86
|
-
.option('
|
|
96
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
97
|
+
.option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' })
|
|
98
|
+
.epilogue([
|
|
99
|
+
'In an interactive terminal with automatic output, browse a picker (10 visible items).',
|
|
100
|
+
'Use arrow keys to browse, type to search sys_id on the server, scroll to load more,',
|
|
101
|
+
'and press Enter to select or Esc to cancel. --sys-id skips the picker.',
|
|
102
|
+
'Use --json for noninteractive structured output; pipes and explicit formats also skip the picker.',
|
|
103
|
+
'Example: jsn records list --table incident --query "active=true" --limit 5 --json',
|
|
104
|
+
'Discover fields with: jsn columns --table incident',
|
|
105
|
+
].join('\n')),
|
|
87
106
|
handler: wrap(async (argv, app) => {
|
|
88
107
|
const table = argv.table;
|
|
89
108
|
const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
|
|
@@ -122,15 +141,10 @@ export function recordsCmd(wrap) {
|
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
// Text/table fallback (or sys-id direct lookup)
|
|
125
|
-
const params = new URLSearchParams();
|
|
126
|
-
params.set('sysparm_limit', argv['sys-id'] ? '1' : String(argv.limit));
|
|
127
|
-
params.set('sysparm_offset', String(argv.offset));
|
|
128
|
-
params.set('sysparm_display_value', 'all');
|
|
129
144
|
const fields = resolveFieldsParam(columns);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
145
|
+
const params = recordListParams({ limit: argv['sys-id'] ? 1 : argv.limit, offset: argv.offset, fields, query });
|
|
146
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues });
|
|
147
|
+
const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
134
148
|
|
|
135
149
|
// include_counts: default opt-in on every list. Opt out per-profile
|
|
136
150
|
// (config include_counts:false) or per-invocation (--no-count).
|
|
@@ -142,7 +156,7 @@ export function recordsCmd(wrap) {
|
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
const breadcrumbs = [
|
|
145
|
-
{ action: 'create', cmd: `jsn records create --table ${table}
|
|
159
|
+
{ action: 'create', cmd: `jsn records create --table ${table} -f name=value`, description: 'Create a new record' },
|
|
146
160
|
{ action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
|
|
147
161
|
{ action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
|
|
148
162
|
];
|
|
@@ -182,27 +196,23 @@ export function recordsCmd(wrap) {
|
|
|
182
196
|
})
|
|
183
197
|
.command({
|
|
184
198
|
command: 'get',
|
|
185
|
-
describe: 'Get
|
|
199
|
+
describe: 'Get one record by sys_id or an explicit identifier field',
|
|
186
200
|
builder: (y) => y
|
|
187
201
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
188
|
-
.option('sys-id', { type: 'string',
|
|
202
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
203
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
204
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
205
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
189
206
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
190
|
-
.option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
|
|
207
|
+
.option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
|
|
208
|
+
.epilogue('Exact lookup precedes sys_id prefix, then suffix. Ambiguous matches fail.\nExample: jsn records get --table incident --sys-id <sys_id> --json\nExample: jsn records get --table incident --identifier INC0010001 --identifier-field number --json'),
|
|
191
209
|
handler: wrap(async (argv, app) => {
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
params.set('sysparm_limit', '1');
|
|
196
|
-
params.set('sysparm_display_value', 'true');
|
|
197
|
-
if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', `sys_id,${argv.columns}`);
|
|
198
|
-
const records = await app.sdk.list(argv.table, params);
|
|
199
|
-
if (records.length === 0) {
|
|
200
|
-
throw new Error(`Record not found: ${argv['sys-id']}`);
|
|
201
|
-
}
|
|
202
|
-
const record = records[0];
|
|
210
|
+
const lookup = recordLookup(argv);
|
|
211
|
+
const fields = argv.columns && argv.columns !== '*' ? argv.columns.split(',') : undefined;
|
|
212
|
+
const record = await getRecord(app.sdk, { ...lookup, fields, displayValue: 'true' });
|
|
203
213
|
if (argv.attachments) {
|
|
204
214
|
try {
|
|
205
|
-
const atts = await app.sdk.listAttachments(
|
|
215
|
+
const atts = await app.sdk.listAttachments(unwrapSysId(record));
|
|
206
216
|
record._attachments = atts;
|
|
207
217
|
} catch {
|
|
208
218
|
record._attachments = [];
|
|
@@ -221,17 +231,18 @@ export function recordsCmd(wrap) {
|
|
|
221
231
|
describe: 'Create a new record',
|
|
222
232
|
builder: (y) => y
|
|
223
233
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
224
|
-
.option('data', { type: 'string', describe: 'JSON fields
|
|
234
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
225
235
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
226
236
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
237
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
|
|
227
238
|
.option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
|
|
228
239
|
handler: wrap(async (argv, app) => {
|
|
229
|
-
const recordData =
|
|
240
|
+
const recordData = buildRecordPayload(argv);
|
|
230
241
|
const warnings = checkDerivedFields(argv.table, recordData);
|
|
231
242
|
for (const w of warnings) {
|
|
232
243
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
233
244
|
}
|
|
234
|
-
const record = await app.sdk
|
|
245
|
+
const record = await createRecord(app.sdk, { table: argv.table, data: recordData });
|
|
235
246
|
const sysID = getStringField(record, 'sys_id');
|
|
236
247
|
const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
|
|
237
248
|
for (const m of mismatches) {
|
|
@@ -248,19 +259,24 @@ export function recordsCmd(wrap) {
|
|
|
248
259
|
describe: 'Update an existing record',
|
|
249
260
|
builder: (y) => y
|
|
250
261
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
251
|
-
.option('sys-id', { type: 'string',
|
|
252
|
-
.option('
|
|
262
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
263
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
264
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
265
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
253
266
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
254
267
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
268
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
|
|
255
269
|
.option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
|
|
256
270
|
handler: wrap(async (argv, app) => {
|
|
257
|
-
const recordData =
|
|
271
|
+
const recordData = buildRecordPayload(argv);
|
|
258
272
|
const warnings = checkDerivedFields(argv.table, recordData);
|
|
259
273
|
for (const w of warnings) {
|
|
260
274
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
261
275
|
}
|
|
262
|
-
const
|
|
263
|
-
const
|
|
276
|
+
const lookup = recordLookup(argv);
|
|
277
|
+
const resolved = await getRecord(app.sdk, lookup);
|
|
278
|
+
const record = await updateRecord(app.sdk, { table: argv.table, record: resolved, data: recordData });
|
|
279
|
+
const mismatches = await verifyWriteBack(app, argv.table, unwrapSysId(resolved), recordData);
|
|
264
280
|
for (const m of mismatches) {
|
|
265
281
|
process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
|
|
266
282
|
}
|
|
@@ -275,82 +291,17 @@ export function recordsCmd(wrap) {
|
|
|
275
291
|
describe: 'Delete a record',
|
|
276
292
|
builder: (y) => y
|
|
277
293
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
278
|
-
.option('sys-id', { type: 'string',
|
|
294
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
295
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
296
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
279
297
|
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
280
298
|
handler: wrap(async (argv, app) => {
|
|
281
|
-
|
|
282
|
-
await app
|
|
283
|
-
|
|
299
|
+
const lookup = recordLookup(argv);
|
|
300
|
+
await confirmDelete(app, argv, `Delete record from ${argv.table} (${lookup.identifier})`);
|
|
301
|
+
const sysID = await deleteRecord(app.sdk, lookup);
|
|
302
|
+
app.ok({ message: 'Record deleted', table: argv.table, sys_id: sysID }, { summary: `Deleted record from ${argv.table}` });
|
|
284
303
|
}),
|
|
285
304
|
})
|
|
286
|
-
.command({
|
|
287
|
-
command: 'attachments',
|
|
288
|
-
describe: 'Work with attachments on a record',
|
|
289
|
-
builder: (y) => y
|
|
290
|
-
.command({
|
|
291
|
-
command: 'list',
|
|
292
|
-
describe: 'List attachments on a record',
|
|
293
|
-
builder: (yy) => yy
|
|
294
|
-
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
295
|
-
.option('table', { type: 'string', describe: 'Parent table name (context only)' }),
|
|
296
|
-
handler: wrap(async (argv, app) => {
|
|
297
|
-
app.requireInstance();
|
|
298
|
-
const attachments = await app.sdk.listAttachments(argv['sys-id']);
|
|
299
|
-
app.ok({
|
|
300
|
-
sys_id: argv['sys-id'],
|
|
301
|
-
table: argv.table || '',
|
|
302
|
-
count: attachments.length,
|
|
303
|
-
attachments,
|
|
304
|
-
context: { instance_url: app.getEffectiveInstance() },
|
|
305
|
-
}, { summary: `${attachments.length} attachment(s) on record ${argv['sys-id']}` });
|
|
306
|
-
}),
|
|
307
|
-
})
|
|
308
|
-
.command({
|
|
309
|
-
command: 'get <attachment-id>',
|
|
310
|
-
describe: 'Download an attachment to a local file',
|
|
311
|
-
builder: (yy) => yy
|
|
312
|
-
.positional('attachment-id', { describe: 'Attachment sys_id', type: 'string' })
|
|
313
|
-
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id (used to resolve the file name)' })
|
|
314
|
-
.option('out', { alias: 'o', type: 'string', describe: 'Output file path (default: attachment file name in cwd)' }),
|
|
315
|
-
handler: wrap(async (argv, app) => {
|
|
316
|
-
app.requireInstance();
|
|
317
|
-
const buf = await app.sdk.getAttachment(argv['attachment-id']);
|
|
318
|
-
let name = argv.out;
|
|
319
|
-
if (!name) {
|
|
320
|
-
// Best-effort: derive the file name from the attachment row.
|
|
321
|
-
let fileName = `${argv['attachment-id']}.bin`;
|
|
322
|
-
try {
|
|
323
|
-
const rows = await app.sdk.listAttachments(argv['sys-id']);
|
|
324
|
-
const hit = (rows || []).find((a) => getStringField(a, 'sys_id') === argv['attachment-id']);
|
|
325
|
-
const fn = getStringField(hit, 'file_name');
|
|
326
|
-
if (fn) fileName = fn;
|
|
327
|
-
} catch { /* keep default */ }
|
|
328
|
-
name = fileName;
|
|
329
|
-
}
|
|
330
|
-
await fs.promises.writeFile(name, buf);
|
|
331
|
-
app.ok({ sys_id: argv['attachment-id'], out: name, bytes: buf.length }, { summary: `Wrote ${buf.length} bytes to ${name}` });
|
|
332
|
-
}),
|
|
333
|
-
})
|
|
334
|
-
.command({
|
|
335
|
-
command: 'add <file>',
|
|
336
|
-
describe: 'Upload a file as a new attachment on a record (mutation)',
|
|
337
|
-
builder: (yy) => yy
|
|
338
|
-
.positional('file', { describe: 'Local file path to upload', type: 'string' })
|
|
339
|
-
.option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
|
|
340
|
-
.option('table', { type: 'string', demandOption: true, describe: 'Parent table for the record (e.g. incident)' })
|
|
341
|
-
.option('name', { type: 'string', describe: 'Display file name (default: basename of --file)' })
|
|
342
|
-
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
343
|
-
handler: wrap(async (argv, app) => {
|
|
344
|
-
app.requireInstance();
|
|
345
|
-
await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${argv['sys-id']}?`);
|
|
346
|
-
const content = await fs.promises.readFile(argv.file);
|
|
347
|
-
const fileName = argv.name || path.basename(argv.file);
|
|
348
|
-
const created = await app.sdk.addAttachment(argv.table, argv['sys-id'], content, fileName);
|
|
349
|
-
app.ok({ sys_id: created?.sys_id, file_name: fileName, table: argv.table, recordsys_id: argv['sys-id'] }, { summary: `Attached ${fileName}` });
|
|
350
|
-
}),
|
|
351
|
-
})
|
|
352
|
-
.demandCommand(1, 'Specify an attachment action: list, get, or add'),
|
|
353
|
-
})
|
|
354
305
|
.command({
|
|
355
306
|
command: 'aggregate',
|
|
356
307
|
describe: 'Run ServiceNow Stats API aggregation with optional grouping',
|
|
@@ -358,6 +309,7 @@ export function recordsCmd(wrap) {
|
|
|
358
309
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
359
310
|
.option('query', { type: 'string', describe: 'Encoded query to constrain the aggregation' })
|
|
360
311
|
.option('group-by', { type: 'string', describe: 'Comma-separated fields to group by' })
|
|
312
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
361
313
|
.option('count', { type: 'boolean', default: true, describe: 'Include record counts' })
|
|
362
314
|
.option('avg-fields', { type: 'string', describe: 'Comma-separated fields to average' })
|
|
363
315
|
.option('sum-fields', { type: 'string', describe: 'Comma-separated fields to sum' })
|
|
@@ -401,22 +353,15 @@ export function recordsCmd(wrap) {
|
|
|
401
353
|
builder: (y) => y
|
|
402
354
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
403
355
|
.option('query', { type: 'string', demandOption: true, describe: 'Encoded query selecting the records to update (e.g. "priority=1^state=1")' })
|
|
404
|
-
.option('set', { type: 'string',
|
|
356
|
+
.option('set', { type: 'string', describe: 'JSON object of fields to set' })
|
|
357
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
|
|
405
358
|
.option('dry-run', { type: 'boolean', default: true, describe: 'Preview the count + a sample without mutating (default)' })
|
|
406
359
|
.option('execute', { type: 'boolean', default: false, describe: 'Perform the update after confirmation' })
|
|
407
360
|
.option('limit', { type: 'number', default: 200, describe: 'Max records to update when executing' })
|
|
408
361
|
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation (with --execute)' }),
|
|
409
362
|
handler: wrap(async (argv, app) => {
|
|
410
363
|
app.requireInstance();
|
|
411
|
-
|
|
412
|
-
try {
|
|
413
|
-
set = JSON.parse(argv.set);
|
|
414
|
-
} catch (e) {
|
|
415
|
-
throw new Error(`Invalid --set JSON: ${e.message}`, { cause: e });
|
|
416
|
-
}
|
|
417
|
-
if (!set || typeof set !== 'object' || Array.isArray(set)) {
|
|
418
|
-
throw new Error('--set must be a JSON object of field=value pairs');
|
|
419
|
-
}
|
|
364
|
+
const set = buildRecordPayload({ ...argv, data: argv.set }, { required: true });
|
|
420
365
|
if (!argv['dry-run'] && !argv.execute) {
|
|
421
366
|
argv.execute = true; // explicit --no-dry-run implies execute intent
|
|
422
367
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
|
|
2
|
+
import { getRecord, recordOptions } from '../records-engine.js';
|
|
3
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
4
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
3
5
|
|
|
4
|
-
const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
|
|
5
6
|
|
|
6
7
|
export function requestsCmd(wrap) {
|
|
7
8
|
// Get the base command definition from the shared ticket builder
|
|
8
|
-
const baseCmd =
|
|
9
|
+
const baseCmd = buildProfileCommand('requests', wrap);
|
|
9
10
|
|
|
10
11
|
// Override the builder to inject our enriched show handler.
|
|
11
12
|
// yargs allows re-registration — the last .command() call wins the slot.
|
|
@@ -16,6 +17,7 @@ export function requestsCmd(wrap) {
|
|
|
16
17
|
command: 'show <number>',
|
|
17
18
|
aliases: ['get'],
|
|
18
19
|
describe: 'Show a specific request with attachments and catalog variables',
|
|
20
|
+
builder: y => relatedListOptions(recordOptions(y)),
|
|
19
21
|
handler: wrap(enrichedShowHandler),
|
|
20
22
|
});
|
|
21
23
|
};
|
|
@@ -25,17 +27,8 @@ export function requestsCmd(wrap) {
|
|
|
25
27
|
|
|
26
28
|
async function enrichedShowHandler(argv, app) {
|
|
27
29
|
const number = argv.number;
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
params.set('sysparm_query', `number=${number}`);
|
|
31
|
-
params.set('sysparm_display_value', 'all');
|
|
32
|
-
params.set('sysparm_limit', '1');
|
|
33
|
-
const records = await app.sdk.list('sc_req_item', params);
|
|
34
|
-
if (records.length === 0) {
|
|
35
|
-
throw new Error(`Request not found: ${number}`);
|
|
36
|
-
}
|
|
37
|
-
const record = records[0];
|
|
38
|
-
const sysID = getStringField(record, 'sys_id');
|
|
30
|
+
const record = await getRecord(app.sdk, { table: 'sc_req_item', identifier: number, matchField: 'number', identifierField: argv.identifierField, displayValues: argv.displayValues, resource: 'Request' });
|
|
31
|
+
const sysID = unwrapSysId(record);
|
|
39
32
|
|
|
40
33
|
// Fetch attachments and catalog variables in parallel
|
|
41
34
|
const [attachments, variables] = await Promise.all([
|
|
@@ -50,10 +43,11 @@ async function enrichedShowHandler(argv, app) {
|
|
|
50
43
|
table: 'sc_req_item',
|
|
51
44
|
};
|
|
52
45
|
|
|
46
|
+
await enrichRelatedLists(record, { table: 'sc_req_item', argv, app });
|
|
53
47
|
app.ok(record, {
|
|
54
48
|
summary: `Request ${number}`,
|
|
55
49
|
breadcrumbs: [
|
|
56
|
-
{ action: 'update', cmd: `jsn req update ${number}
|
|
50
|
+
{ action: 'update', cmd: `jsn req update ${number} -f state=2`, description: 'Update this request' },
|
|
57
51
|
{ action: 'list', cmd: `jsn req list`, description: 'Back to all requests' },
|
|
58
52
|
],
|
|
59
53
|
});
|
package/src/commands/rest.js
CHANGED
|
@@ -2,7 +2,7 @@ import { declareCapabilities } from '../capabilities.js';
|
|
|
2
2
|
|
|
3
3
|
// Raw REST passthrough — can hit any endpoint with any method, so the whole
|
|
4
4
|
// command is a mutation surface on read-only profiles.
|
|
5
|
-
declareCapabilities('rest', { mutationSubcommands: [''] });
|
|
5
|
+
declareCapabilities('rest', { mutationSubcommands: [''], mutationContextReadMethods: ['GET', 'HEAD', 'OPTIONS'] });
|
|
6
6
|
|
|
7
7
|
export function restCmd(wrap) {
|
|
8
8
|
return {
|
|
@@ -10,6 +10,10 @@ export function restCmd(wrap) {
|
|
|
10
10
|
describe: 'Make raw REST API calls',
|
|
11
11
|
builder: (yargs) => {
|
|
12
12
|
return yargs
|
|
13
|
+
.positional('endpoint', {
|
|
14
|
+
type: 'string',
|
|
15
|
+
describe: 'API path, optionally including query parameters; with --table, a record sys_id',
|
|
16
|
+
})
|
|
13
17
|
.option('method', {
|
|
14
18
|
alias: 'X',
|
|
15
19
|
type: 'string',
|
|
@@ -34,7 +38,16 @@ export function restCmd(wrap) {
|
|
|
34
38
|
.option('query', {
|
|
35
39
|
type: 'string',
|
|
36
40
|
describe: 'Query parameters string',
|
|
37
|
-
})
|
|
41
|
+
})
|
|
42
|
+
.epilogue([
|
|
43
|
+
'Prefer a specific jsn command or records for normal table operations.',
|
|
44
|
+
'Raw REST is blocked on read-only profiles for every method, including GET.',
|
|
45
|
+
'Examples:',
|
|
46
|
+
' jsn rest "/api/now/table/incident?sysparm_limit=3" --json',
|
|
47
|
+
' jsn rest --table incident --query "sysparm_limit=3" --json',
|
|
48
|
+
' jsn rest /stats.do --raw',
|
|
49
|
+
'Use either query parameters in the endpoint or --query, rather than both.',
|
|
50
|
+
].join('\n'));
|
|
38
51
|
},
|
|
39
52
|
handler: wrap(async (argv, app) => {
|
|
40
53
|
const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { buildRecordPayload, confirmDelete, getStringField, quoteShellArg, verifyWriteBack } from '../helpers.js';
|
|
2
|
+
import { createRecord, updateRecord, deleteRecord } from '../records-engine.js';
|
|
3
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
4
|
+
import { getCurrentApplication, getCurrentUser } from '../context.js';
|
|
5
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
6
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
7
|
+
import { CHECK_TABLES, active, raw, referenceId, boundedLimit, scanList, resolveScanRecord,
|
|
8
|
+
validateScanPayload, checkKind, prepareScan, startScan, scanStatus, scanResults, cancelScan } from '../scan.js';
|
|
9
|
+
|
|
10
|
+
declareCapabilities('scan', { mutationSubcommands: [
|
|
11
|
+
...['checks', 'suites'].flatMap(group => ['create', 'update', 'delete', 'activate', 'deactivate'].map(verb => `${group} ${verb}`)),
|
|
12
|
+
'suites add-check', 'suites remove-check', 'run', 'cancel',
|
|
13
|
+
] });
|
|
14
|
+
|
|
15
|
+
const hint = (action, cmd, description) => ({ action, cmd, description });
|
|
16
|
+
function report(app, argv, data, options) {
|
|
17
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
|
|
18
|
+
: argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
19
|
+
app.ok(data, { ...options, breadcrumbs: options.breadcrumbs.map(item => ({ ...item, cmd: item.cmd + connection })) });
|
|
20
|
+
}
|
|
21
|
+
const rootHints = () => [hint('checks', 'jsn scan checks list', 'Browse available checks'),
|
|
22
|
+
hint('suites', 'jsn scan suites list', 'Browse suites'), hint('run', 'jsn scan run --help', 'Choose an explicit scan target')];
|
|
23
|
+
const itemHints = (group, record) => {
|
|
24
|
+
const id = unwrapSysId(record);
|
|
25
|
+
return [hint('list', `jsn scan ${group} list`, `Browse ${group}`),
|
|
26
|
+
hint('show', `jsn scan ${group} show ${id}`, 'Inspect this definition'),
|
|
27
|
+
hint('update', `jsn scan ${group} update ${id} --data-file ${group === 'checks' ? 'check' : 'suite'}.json`, 'Update selected fields'),
|
|
28
|
+
hint(active(record) ? 'deactivate' : 'activate', `jsn scan ${group} ${active(record) ? 'deactivate' : 'activate'} ${id}`, 'Change eligibility without starting a scan'),
|
|
29
|
+
...(group === 'suites' ? [hint('add-check', `jsn scan suites add-check ${id} --check '<check-id-or-name>'`, 'Add an existing check'),
|
|
30
|
+
hint('run', `jsn scan run --suite ${id} --update-set '<update-set>'`, 'Run against an explicit update set')] :
|
|
31
|
+
[hint('suites', 'jsn scan suites list', 'Choose a suite for this check')]),
|
|
32
|
+
hint('delete', `jsn scan ${group} delete ${id}`, 'Delete this definition after confirmation')];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function executionHints(data) {
|
|
36
|
+
const id = data.execution_id;
|
|
37
|
+
return [hint('status', `jsn scan status ${id}`, 'Read execution progress'),
|
|
38
|
+
hint('results', `jsn scan results ${id}`, 'Inspect findings and recorded check executions'),
|
|
39
|
+
...(!data.terminal ? [hint('cancel', `jsn scan cancel ${id}`, 'Request cancellation')] : []),
|
|
40
|
+
...(data.reusable_target ? [hint('rerun', `jsn scan run --previous ${id}${data.rerun_full ? ' --full' : ''}`, 'Rerun the previous target with current check definitions')] : []),
|
|
41
|
+
hint('checks', 'jsn scan checks list', 'Review available checks')];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payloadOptions = y => y.option('data', { type: 'string', describe: 'JSON fields' })
|
|
45
|
+
.option('data-file', { type: 'string', describe: 'Read JSON fields from a file (recommended for scripts)' })
|
|
46
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON fields from stdin' })
|
|
47
|
+
.option('field', { alias: 'f', type: 'array', describe: 'Set field=value; repeatable' });
|
|
48
|
+
const lookupOptions = y => y.positional('identifier', { type: 'string', describe: 'Unique name or sys_id' });
|
|
49
|
+
|
|
50
|
+
async function guardScope(app, record) {
|
|
51
|
+
const scopeId = referenceId(record.sys_scope) || raw(record.sys_scope);
|
|
52
|
+
if (!scopeId) throw errUsage('The definition scope is unreadable; inspect permissions before changing it.');
|
|
53
|
+
const user = await getCurrentUser(app.sdk);
|
|
54
|
+
if (!user) throw errUsage('Could not verify the current scope.');
|
|
55
|
+
const scope = await getCurrentApplication(app.sdk, user.sys_id, { unknownWhenMissing: true });
|
|
56
|
+
if (scope.appSysId !== scopeId && scope.scope !== scopeId) {
|
|
57
|
+
throw errUsage(`Select the definition scope before changing it: jsn scopes set ${quoteShellArg(scopeId)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function verify(app, table, id, data) {
|
|
62
|
+
if (!id) throw errAPI(200, 'The write returned no sys_id. Inspect the instance before retrying.');
|
|
63
|
+
const mismatches = await verifyWriteBack(app, table, id, data);
|
|
64
|
+
if (mismatches.length) throw errAPI(200, `Write-back verification failed for ${table}/${id}: ${mismatches.map(item => item.field).join(', ')}. The write may have succeeded partially; inspect before retrying.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function definitions(group, wrap) {
|
|
68
|
+
const suite = group === 'suites';
|
|
69
|
+
const baseTable = suite ? 'scan_check_suite' : 'scan_check';
|
|
70
|
+
const emit = (app, argv, record, summary) => report(app, argv, record, { summary, breadcrumbs: itemHints(group, record) });
|
|
71
|
+
return {
|
|
72
|
+
command: `${group} [subcommand]`,
|
|
73
|
+
describe: suite ? 'Manage scan suites and their check memberships' : 'Manage typed Instance Scan check definitions',
|
|
74
|
+
builder: y => {
|
|
75
|
+
y.command({ command: 'list', describe: `List ${group}`,
|
|
76
|
+
builder: p => {
|
|
77
|
+
p.option('query', { type: 'string', describe: 'Encoded query' })
|
|
78
|
+
.option('limit', { type: 'number', default: 50, describe: 'Maximum records (1–200)' })
|
|
79
|
+
.option('offset', { type: 'number', default: 0, describe: 'Pagination offset' });
|
|
80
|
+
return suite ? p : p.option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', describe: 'Filter by check type' });
|
|
81
|
+
}, handler: wrap(async (argv, app) => {
|
|
82
|
+
const table = suite ? baseTable : (argv.type ? CHECK_TABLES[argv.type] : baseTable);
|
|
83
|
+
const records = await scanList(app.sdk, table, { limit: argv.limit, offset: argv.offset, query: argv.query,
|
|
84
|
+
fields: ['sys_id', 'name', 'active', ...(suite ? ['description'] : ['sys_class_name', 'short_description'])] });
|
|
85
|
+
report(app, argv, { table, records, count: records.length, limit: argv.limit, offset: argv.offset,
|
|
86
|
+
more_available: records.length === argv.limit ? null : false }, {
|
|
87
|
+
summary: `${records.length} scan ${group}`, breadcrumbs: [
|
|
88
|
+
...records.slice(0, 5).map(record => hint('show', `jsn scan ${group} show ${unwrapSysId(record)}`, `Inspect ${getStringField(record, 'name')}`)),
|
|
89
|
+
hint('create', `jsn scan ${group} create${suite ? '' : ' --type table'} --data-file ${suite ? 'suite' : 'check'}.json`, 'Create an inactive definition'),
|
|
90
|
+
...(records.length === argv.limit ? [hint('next', `jsn scan ${group} list --offset ${argv.offset + argv.limit} --limit ${argv.limit}${argv.type ? ` --type ${argv.type}` : ''}${argv.query ? ` --query ${quoteShellArg(argv.query)}` : ''}`, 'Next page')] : []),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
}) });
|
|
94
|
+
y.command({ command: 'show <identifier>', describe: `Inspect a ${suite ? 'suite and its members' : 'check'}`, builder: lookupOptions,
|
|
95
|
+
handler: wrap(async (argv, app) => {
|
|
96
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
97
|
+
if (suite) {
|
|
98
|
+
const members = await scanList(app.sdk, 'scan_check_suite_check', { limit: 200, query: `suite=${unwrapSysId(record)}^ORDERBYsys_id`, fields: ['sys_id', 'check'] });
|
|
99
|
+
record.members = members;
|
|
100
|
+
record.members_may_be_truncated = members.length === 200;
|
|
101
|
+
}
|
|
102
|
+
emit(app, argv, record, `Scan ${suite ? 'suite' : 'check'}: ${getStringField(record, 'name')}`);
|
|
103
|
+
}) });
|
|
104
|
+
y.command({ command: 'create', describe: `Create an inactive ${suite ? 'suite' : 'typed check'} unless active is explicitly supplied`,
|
|
105
|
+
builder: p => suite ? payloadOptions(p) : payloadOptions(p).option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', demandOption: true, describe: 'Check type' }),
|
|
106
|
+
handler: wrap(async (argv, app) => {
|
|
107
|
+
const data = validateScanPayload(argv.type, buildRecordPayload(argv), { create: true, suite });
|
|
108
|
+
const table = suite ? baseTable : CHECK_TABLES[argv.type];
|
|
109
|
+
const record = await createRecord(app.sdk, { table, data });
|
|
110
|
+
await verify(app, table, unwrapSysId(record), data);
|
|
111
|
+
emit(app, argv, record, `Created scan ${suite ? 'suite' : 'check'}`);
|
|
112
|
+
}) });
|
|
113
|
+
for (const operation of ['update', 'activate', 'deactivate']) y.command({
|
|
114
|
+
command: `${operation} <identifier>`, describe: `${operation} a scan ${suite ? 'suite' : 'check'} without running it`,
|
|
115
|
+
builder: p => operation === 'update' ? payloadOptions(lookupOptions(p)) : lookupOptions(p),
|
|
116
|
+
handler: wrap(async (argv, app) => {
|
|
117
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
118
|
+
const kind = suite ? null : checkKind(record);
|
|
119
|
+
const patch = operation === 'update' ? validateScanPayload(kind, buildRecordPayload(argv), { suite, current: record }) : { active: operation === 'activate' };
|
|
120
|
+
if (!suite && operation === 'activate') validateScanPayload(kind, {
|
|
121
|
+
name: raw(record.name), script: raw(record.script), ...(kind === 'table' ? { table: raw(record.table), advanced: raw(record.advanced) } : {}),
|
|
122
|
+
...(kind === 'column' ? { column_type: raw(record.column_type) } : {}),
|
|
123
|
+
}, { create: true });
|
|
124
|
+
await guardScope(app, record);
|
|
125
|
+
const table = suite ? baseTable : CHECK_TABLES[kind];
|
|
126
|
+
const updated = await updateRecord(app.sdk, { table, record, data: patch });
|
|
127
|
+
await verify(app, table, unwrapSysId(record), patch);
|
|
128
|
+
emit(app, argv, updated, `Updated scan ${suite ? 'suite' : 'check'}`);
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
y.command({ command: 'delete <identifier>', describe: `Delete a ${suite ? 'suite' : 'check'} definition after confirmation`, builder: lookupOptions,
|
|
132
|
+
handler: wrap(async (argv, app) => {
|
|
133
|
+
const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
134
|
+
await guardScope(app, record);
|
|
135
|
+
await confirmDelete(app, argv, `Delete scan ${suite ? 'suite' : 'check'} "${getStringField(record, 'name')}"?`);
|
|
136
|
+
const id = await deleteRecord(app.sdk, { table: suite ? baseTable : CHECK_TABLES[checkKind(record)], record });
|
|
137
|
+
report(app, argv, { sys_id: id, deleted: true }, { summary: 'Deleted scan definition', breadcrumbs: [hint('list', `jsn scan ${group} list`, 'Browse remaining definitions')] });
|
|
138
|
+
}) });
|
|
139
|
+
if (suite) for (const operation of ['add-check', 'remove-check']) y.command({
|
|
140
|
+
command: `${operation} <identifier>`, describe: `${operation === 'add-check' ? 'Add a check to' : 'Remove a check from'} a suite`,
|
|
141
|
+
builder: p => lookupOptions(p).option('check', { type: 'string', demandOption: true, describe: 'Check name or sys_id' }),
|
|
142
|
+
handler: wrap(async (argv, app) => {
|
|
143
|
+
const suiteRecord = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
|
|
144
|
+
const check = await resolveScanRecord(app.sdk, 'scan_check', argv.check);
|
|
145
|
+
await guardScope(app, suiteRecord);
|
|
146
|
+
const data = { suite: unwrapSysId(suiteRecord), check: unwrapSysId(check) };
|
|
147
|
+
const table = 'scan_check_suite_check';
|
|
148
|
+
const members = await scanList(app.sdk, table, { query: `suite=${data.suite}^check=${data.check}`, limit: 2 });
|
|
149
|
+
if (members.length > 1) throw errUsage('Duplicate suite memberships exist; inspect and reconcile them before changing membership.');
|
|
150
|
+
let changed = false;
|
|
151
|
+
if (operation === 'add-check' && !members.length) {
|
|
152
|
+
const member = await createRecord(app.sdk, { table, data });
|
|
153
|
+
await verify(app, table, unwrapSysId(member), data);
|
|
154
|
+
changed = true;
|
|
155
|
+
} else if (operation === 'remove-check' && members.length) {
|
|
156
|
+
await confirmDelete(app, argv, 'Remove this check from the suite? The check definition will be retained.');
|
|
157
|
+
await deleteRecord(app.sdk, { table, record: members[0] });
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
report(app, argv, { ...data, changed, member: operation === 'add-check' }, { summary: 'Suite membership reconciled', breadcrumbs: itemHints(group, suiteRecord) });
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
return y;
|
|
164
|
+
},
|
|
165
|
+
handler: wrap(async (argv, app) => report(app, argv, { commands: ['list', 'show', 'create', 'update', 'delete', 'activate', 'deactivate', ...(suite ? ['add-check', 'remove-check'] : [])] }, {
|
|
166
|
+
summary: `Scan ${group}`, breadcrumbs: [hint('list', `jsn scan ${group} list`, `Browse ${group}`)],
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function scanCmd(wrap) {
|
|
172
|
+
return {
|
|
173
|
+
command: 'scan [subcommand]', describe: 'Instance Scan checks, suites, explicit targets, progress and findings',
|
|
174
|
+
builder: y => y.command(definitions('checks', wrap)).command(definitions('suites', wrap))
|
|
175
|
+
.command({ command: 'run', describe: 'Start an explicit target scan (confirmation required; no implicit full scan)',
|
|
176
|
+
builder: p => p.option('update-set', { type: 'string', describe: 'Update set name or sys_id; scans current records' })
|
|
177
|
+
.option('scope', { type: 'string', describe: 'Application scope value or sys_id' })
|
|
178
|
+
.option('record', { type: 'string', describe: 'Record sys_id; requires --table' })
|
|
179
|
+
.option('table', { type: 'string', describe: 'Table for --record' })
|
|
180
|
+
.option('full', { type: 'boolean', describe: 'Explicitly allow an instance-wide scan' })
|
|
181
|
+
.option('previous', { type: 'string', describe: 'Rerun a previous execution target; full scans also require --full' })
|
|
182
|
+
.option('suite', { type: 'string', describe: 'Optional active suite name or sys_id' })
|
|
183
|
+
.option('wait', { type: 'boolean', default: false, describe: 'Wait for completion; timeout does not cancel the scan' })
|
|
184
|
+
.option('timeout', { type: 'number', default: 120, describe: 'Maximum wait in seconds (1–3600)' }),
|
|
185
|
+
handler: wrap(async (argv, app) => {
|
|
186
|
+
if (!Number.isFinite(argv.timeout) || argv.timeout < 1 || argv.timeout > 3600) throw errUsage('--timeout must be from 1 to 3600 seconds.');
|
|
187
|
+
const plan = await prepareScan(app.sdk, argv);
|
|
188
|
+
await confirmDelete(app, argv, `Start scan of ${plan.target.kind} ${plan.target.name || plan.target.sys_id || plan.target.execution_id || 'the full instance'}${plan.suite_id ? ` using suite ${plan.suite_id}` : ''}?`);
|
|
189
|
+
const scheduled = await startScan(app.sdk, plan);
|
|
190
|
+
let data = { ...scheduled, status: 'scheduled', terminal: false };
|
|
191
|
+
if (argv.wait) {
|
|
192
|
+
try {
|
|
193
|
+
const deadline = Date.now() + argv.timeout * 1000;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
try {
|
|
196
|
+
data = { ...scheduled, ...await scanStatus(app.sdk, scheduled.execution_id, { allowInitializing: true }) };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code !== 'not_found' && error.status !== 404) throw error;
|
|
199
|
+
// The processor can return before its execution tracker is visible.
|
|
200
|
+
data = { ...scheduled, status: 'scheduled', terminal: false };
|
|
201
|
+
}
|
|
202
|
+
if (data.terminal) { data = { ...scheduled, ...await scanResults(app.sdk, scheduled.execution_id) }; break; }
|
|
203
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
|
|
204
|
+
}
|
|
205
|
+
if (!data.terminal) data.wait_timed_out = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw errAPI(error.status || 0, `Scan ${scheduled.execution_id} was scheduled, but progress could not be read: ${error.message}. Use jsn scan status ${scheduled.execution_id}; do not resubmit automatically.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const breadcrumbs = executionHints(data);
|
|
211
|
+
if (data.terminal && !data.reusable_target) breadcrumbs.push(hint('rerun', 'jsn scan run --help', 'Choose a target for another scan'));
|
|
212
|
+
report(app, argv, data, { summary: `Scan ${data.execution_id}: ${data.status}${data.wait_timed_out ? ' (still running; wait timed out)' : ''}`, breadcrumbs });
|
|
213
|
+
}) })
|
|
214
|
+
.command({ command: 'status <id>', describe: 'Read scan execution progress',
|
|
215
|
+
handler: wrap(async (argv, app) => {
|
|
216
|
+
const data = await scanStatus(app.sdk, argv.id);
|
|
217
|
+
report(app, argv, data, { summary: `Scan ${data.status}: ${data.message}`, breadcrumbs: executionHints(data) });
|
|
218
|
+
}) })
|
|
219
|
+
.command({ command: 'results <id>', describe: 'Read findings and check execution coverage; unreadable data is not zero',
|
|
220
|
+
builder: p => p.option('limit', { type: 'number', default: 50, describe: 'Maximum findings/check rows (1–200); truncation is reported' }),
|
|
221
|
+
handler: wrap(async (argv, app) => {
|
|
222
|
+
const data = await scanResults(app.sdk, argv.id, boundedLimit(argv.limit));
|
|
223
|
+
report(app, argv, data, { summary: `Scan ${data.status}; ${data.findings_count ?? 'unknown'} findings; ${data.checks_executed ?? 'unknown'} recorded check executions${data.incomplete ? '; incomplete data' : ''}`, breadcrumbs: executionHints(data) });
|
|
224
|
+
}) })
|
|
225
|
+
.command({ command: 'cancel <id>', describe: 'Request cancellation of this scan after confirmation',
|
|
226
|
+
handler: wrap(async (argv, app) => {
|
|
227
|
+
const status = await scanStatus(app.sdk, argv.id);
|
|
228
|
+
if (!status.terminal) await confirmDelete(app, argv, `Cancel scan ${argv.id}?`);
|
|
229
|
+
const data = status.terminal ? { ...status, cancellation_requested: false } : await cancelScan(app.sdk, argv.id);
|
|
230
|
+
report(app, argv, data, { summary: data.cancellation_requested ? `Cancellation requested: ${argv.id}` : 'Scan already finished; no cancellation sent', breadcrumbs: executionHints(data) });
|
|
231
|
+
}) }),
|
|
232
|
+
handler: wrap(async (argv, app) => report(app, argv, { commands: ['checks', 'suites', 'run', 'status', 'results', 'cancel'] }, { summary: 'Instance Scan', breadcrumbs: rootHints() })),
|
|
233
|
+
};
|
|
234
|
+
}
|