@jacebenson/jsn 4.3.13 → 4.4.0-dev.113.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 +137 -0
- package/package.json +4 -3
- 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 +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- 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/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- 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 +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- 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 +12 -2
- 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/_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
5
|
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
|
|
5
|
-
import {
|
|
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' })
|
|
@@ -80,17 +83,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
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,
|
|
@@ -148,7 +152,7 @@ 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
158
|
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
@@ -162,7 +166,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
162
166
|
if (!recordData.short_description) {
|
|
163
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,7 +178,7 @@ 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
|
|
181
|
+
builder: (y) => recordOptions(y, false)
|
|
178
182
|
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
179
183
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
180
184
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
@@ -182,8 +186,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
182
186
|
handler: wrap(async (argv, app) => {
|
|
183
187
|
const number = argv.number;
|
|
184
188
|
const recordData = buildRecordPayload(argv);
|
|
185
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
186
|
-
const updated = await app.sdk
|
|
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 });
|
|
187
191
|
app.ok(updated, {
|
|
188
192
|
summary: `Updated ${displayName} ${number}`,
|
|
189
193
|
breadcrumbs: [
|
|
@@ -195,12 +199,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
195
199
|
.command({
|
|
196
200
|
command: 'delete <number>',
|
|
197
201
|
describe: `Delete a ${displayName}`,
|
|
198
|
-
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' }),
|
|
199
203
|
handler: wrap(async (argv, app) => {
|
|
200
204
|
const number = argv.number;
|
|
201
205
|
await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
|
|
202
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
203
|
-
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 } });
|
|
204
208
|
app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
|
|
205
209
|
summary: `Deleted ${displayName} ${number}`,
|
|
206
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
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parseDataArg, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const ID = /^[a-f0-9]{32}$/i;
|
|
5
|
+
const API = '/api/sn_sc/servicecatalog/items';
|
|
6
|
+
const object = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
|
|
8
|
+
function emit(app, argv, data, summary, breadcrumbs = []) {
|
|
9
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
10
|
+
app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
11
|
+
}
|
|
12
|
+
function requireId(id) { if (!ID.test(id || '')) throw errUsage('Use the full catalog item sys_id; find it with catalogitems search.'); }
|
|
13
|
+
async function describe(app, id) {
|
|
14
|
+
requireId(id);
|
|
15
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}/${id}`, { method: 'GET' });
|
|
16
|
+
if (!object(response?.result) || response.result.sys_id !== id) throw errAPI(200, 'Catalog API returned no readable item with the requested identity.');
|
|
17
|
+
return response.result;
|
|
18
|
+
}
|
|
19
|
+
function itemHints(id) {
|
|
20
|
+
return [{ action: 'describe', cmd: `jsn catalogitems describe ${id}`, description: 'Inspect catalog inputs and pricing' },
|
|
21
|
+
{ action: 'preview', cmd: `jsn catalogitems order ${id} --dry-run --variables-file variables.json`, description: 'Preview an order without submitting' }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function catalogOrderCommands(wrap) {
|
|
25
|
+
return [
|
|
26
|
+
{ command: 'search <text>', describe: 'Find requestable items through the Service Catalog API',
|
|
27
|
+
builder: y => y.positional('text', { type: 'string' }).option('limit', { type: 'number', default: 20, describe: 'Maximum items (1–100)' }),
|
|
28
|
+
handler: wrap(async (argv, app) => {
|
|
29
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty catalog search text.');
|
|
30
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
31
|
+
const params = new URLSearchParams({ sysparm_text: argv.text.trim(), sysparm_limit: String(argv.limit) });
|
|
32
|
+
const response = await app.sdk.request(`${app.sdk.baseURL}${API}?${params}`, { method: 'GET' });
|
|
33
|
+
if (!Array.isArray(response?.result) || response.result.some(i => !object(i) || !ID.test(i.sys_id || ''))) throw errAPI(200, 'Catalog API returned no valid item list.');
|
|
34
|
+
emit(app, argv, { items: response.result, count: response.result.length, limit: argv.limit,
|
|
35
|
+
possibly_truncated: response.result.length === argv.limit }, `${response.result.length} catalog item(s)`,
|
|
36
|
+
response.result.flatMap(i => itemHints(i.sys_id).slice(0, 1)));
|
|
37
|
+
}) },
|
|
38
|
+
{ command: 'describe <id>', aliases: ['inputs'], describe: 'Read requestable item details, pricing and variable definitions',
|
|
39
|
+
builder: y => y.positional('id', { type: 'string' }),
|
|
40
|
+
handler: wrap(async (argv, app) => {
|
|
41
|
+
const item = await describe(app, argv.id);
|
|
42
|
+
emit(app, argv, item, item.name || item.short_description || argv.id, itemHints(argv.id));
|
|
43
|
+
}) },
|
|
44
|
+
{ command: 'order <id>', describe: 'Submit a catalog order with explicit confirmation',
|
|
45
|
+
builder: y => y.positional('id', { type: 'string' })
|
|
46
|
+
.option('variables', { type: 'string', describe: 'JSON object of catalog variable names/values' })
|
|
47
|
+
.option('variables-file', { type: 'string', describe: 'Read the variables object from a JSON file' })
|
|
48
|
+
.option('quantity', { type: 'number', default: 1, describe: 'Positive integer quantity (max 1000)' })
|
|
49
|
+
.option('requested-for', { type: 'string', describe: 'User sys_id; instance delegation rules still apply' })
|
|
50
|
+
.option('dry-run', { type: 'boolean', default: false, describe: 'Read item details and preview variable names; no order is submitted' })
|
|
51
|
+
.epilogue('Use describe to inspect variables first. Server-side rules validate the order; a preview does not prove submission will succeed. Existing create/list/show commands manage definitions. Orders require a write-enabled profile, including dry runs. No application/update-set selection is needed.'),
|
|
52
|
+
handler: wrap(async (argv, app) => {
|
|
53
|
+
requireId(argv.id);
|
|
54
|
+
if (!Number.isInteger(argv.quantity) || argv.quantity < 1 || argv.quantity > 1000) throw errUsage('--quantity must be an integer from 1 to 1000.');
|
|
55
|
+
const requestedFor = argv.requestedFor ?? argv['requested-for'];
|
|
56
|
+
if (requestedFor !== undefined && !ID.test(requestedFor)) throw errUsage('--requested-for requires a user sys_id.');
|
|
57
|
+
const file = argv.variablesFile ?? argv['variables-file'];
|
|
58
|
+
if (argv.variables !== undefined && file !== undefined) throw errUsage('Choose --variables or --variables-file, not both.');
|
|
59
|
+
const variables = argv.variables !== undefined || file !== undefined
|
|
60
|
+
? parseDataArg({ data: argv.variables, 'data-file': file }) : {};
|
|
61
|
+
if (!object(variables)) throw errUsage('Catalog variables must be a JSON object.');
|
|
62
|
+
const item = await describe(app, argv.id);
|
|
63
|
+
if (item.sys_class_name && item.sys_class_name !== 'sc_cat_item') throw errUsage('order supports standard catalog items; record producers and order guides need their own submission workflows.');
|
|
64
|
+
if (item.active === false || item.active === 'false' || item.can_order === false) throw errUsage('This item is not currently orderable.');
|
|
65
|
+
const plan = { item_id: argv.id, name: item.name || item.short_description, quantity: argv.quantity,
|
|
66
|
+
requested_for: requestedFor || null, variable_names: Object.keys(variables) };
|
|
67
|
+
if (argv.dryRun ?? argv['dry-run']) return emit(app, argv, { ...plan, submitted: false, server_validation: 'not performed' }, 'Catalog order preview; nothing submitted', itemHints(argv.id));
|
|
68
|
+
const rerun = ['catalogitems', 'order', argv.id, '--quantity', String(argv.quantity),
|
|
69
|
+
...(requestedFor ? ['--requested-for', requestedFor] : []),
|
|
70
|
+
...(file ? ['--variables-file', file] : Object.keys(variables).length ? ['--variables-file', '<variables.json>'] : [])];
|
|
71
|
+
try {
|
|
72
|
+
await confirmDelete(app, argv, `Order ${argv.quantity} × ${plan.name || argv.id}${requestedFor ? ` for ${requestedFor}` : ''}?`, { rerun });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.message === 'Deletion cancelled') throw errUsage('Catalog order cancelled.');
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const body = { sysparm_quantity: argv.quantity, variables, ...(requestedFor ? { sysparm_requested_for: requestedFor } : {}) };
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await app.sdk.request(`${app.sdk.baseURL}${API}/${argv.id}/order_now`, { method: 'POST', body: JSON.stringify(body) });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.status >= 400 && error.status < 500 && error.status !== 408) throw errAPI(error.status, 'Catalog order was rejected. Check item access, required variables and requested-for eligibility.');
|
|
83
|
+
throw errAPI(error.status || 0, 'Catalog submission did not return a confirmed order. Inspect your requests before retrying; the order may have been placed.');
|
|
84
|
+
}
|
|
85
|
+
const result = response?.result;
|
|
86
|
+
const requestId = result?.request_id || result?.sys_id, number = result?.request_number || result?.number;
|
|
87
|
+
if (!object(result) || result.error || !ID.test(requestId || '') || typeof number !== 'string' || !number.trim()) throw errAPI(200, 'Catalog response did not confirm an order identity. Inspect your requests before retrying; do not resubmit automatically.');
|
|
88
|
+
emit(app, argv, { item_id: argv.id, quantity: argv.quantity, request_id: requestId, request_number: number, submitted: true },
|
|
89
|
+
`Ordered: ${number}`, [{ action: 'request', cmd: `jsn records get --table sc_request --sys-id ${requestId}`, description: 'Read the request header' },
|
|
90
|
+
{ action: 'items', cmd: `jsn requests list --query ${quoteShellArg(`request=${requestId}`)}`, description: 'Track the requested items' }]);
|
|
91
|
+
}) },
|
|
92
|
+
];
|
|
93
|
+
}
|
package/src/commands/catalog.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { getStringField, interactiveList, resolveItemOptionType } from '../helpers.js';
|
|
1
|
+
import { getStringField, interactiveList, resolveItemOptionType, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { catalogOrderCommands } from './catalog-order.js';
|
|
2
3
|
import { resolveSysId } from '../resolve-record.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
import { enrichCatalogItem, buildCatalogFormatted } from './catalog/enrich.js';
|
|
5
6
|
|
|
6
|
-
declareCapabilities('catalogitems', { mutationSubcommands: ['create'] });
|
|
7
|
+
declareCapabilities('catalogitems', { mutationSubcommands: ['create', 'order'], mutationContextExemptSubcommands: ['order'], localPreviewSubcommands: ['order'] });
|
|
7
8
|
|
|
8
9
|
export function catalogCmd(wrap) {
|
|
9
10
|
return {
|
|
@@ -11,6 +12,7 @@ export function catalogCmd(wrap) {
|
|
|
11
12
|
aliases: ['ci'],
|
|
12
13
|
describe: 'Manage Service Catalog items',
|
|
13
14
|
builder: (yargs) => {
|
|
15
|
+
for (const command of catalogOrderCommands(wrap)) yargs.command(command);
|
|
14
16
|
return yargs
|
|
15
17
|
.command({
|
|
16
18
|
command: 'create',
|
|
@@ -148,14 +150,10 @@ export function catalogCmd(wrap) {
|
|
|
148
150
|
}),
|
|
149
151
|
});
|
|
150
152
|
},
|
|
151
|
-
handler: () => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(' create Create a catalog item');
|
|
156
|
-
console.log(' list Browse catalog items');
|
|
157
|
-
console.log(' show <id> Show catalog item');
|
|
158
|
-
},
|
|
153
|
+
handler: wrap(async (argv, app) => app.ok({ subcommands: argv.subcommands || [],
|
|
154
|
+
_formatted: formatCommandList({ title: 'Service Catalog', commands: argv.subcommands || [] }) },
|
|
155
|
+
{ summary: 'Service Catalog: search, describe and order items; manage definitions with create/list/show',
|
|
156
|
+
breadcrumbs: [{ action: 'search', cmd: 'jsn catalogitems search <text>' + (argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : ''), description: 'Find requestable items' }] })),
|
|
159
157
|
};
|
|
160
158
|
}
|
|
161
159
|
|
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
|
}
|