@jacebenson/jsn 4.3.13-dev.83.1 → 4.3.13-dev.85.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/package.json +1 -1
- package/src/commands/_generic.js +24 -20
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +23 -20
- package/src/commands/changes.js +2 -4
- package/src/commands/incidents.js +2 -4
- package/src/commands/records.js +43 -32
- package/src/commands/requests.js +9 -15
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/errors.js +7 -0
- package/src/essential-records.js +40 -0
- package/src/metadata/records.js +5 -1
- package/src/output.js +26 -0
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +88 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +6 -0
- package/src/resolve-record.js +50 -28
package/package.json
CHANGED
package/src/commands/_generic.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
|
|
1
2
|
// Generic table-based CRUD command builder
|
|
2
3
|
|
|
3
4
|
import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
|
|
4
|
-
import {
|
|
5
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
6
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
5
7
|
import { getCurrentUser, getCurrentApplication } from '../context.js';
|
|
6
8
|
import { FormatAuto } from '../output.js';
|
|
7
9
|
import { declareCapabilities } from '../capabilities.js';
|
|
@@ -120,7 +122,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
120
122
|
command: 'list',
|
|
121
123
|
aliases: ['ls'],
|
|
122
124
|
describe: `List ${name}`,
|
|
123
|
-
builder: (y) => y
|
|
125
|
+
builder: (y) => recordOptions(y)
|
|
124
126
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
|
|
125
127
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
|
|
126
128
|
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
@@ -147,7 +149,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
147
149
|
const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
|
|
148
150
|
record._context = { instance_url: app.getEffectiveInstance(), table };
|
|
149
151
|
if (opts.onShow) {
|
|
150
|
-
await opts.onShow(record, app);
|
|
152
|
+
await opts.onShow(record, app, argv);
|
|
151
153
|
}
|
|
152
154
|
app.ok(record, {
|
|
153
155
|
summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
|
|
@@ -160,19 +162,15 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
// Non-interactive: text/table output
|
|
163
|
-
const params = new URLSearchParams();
|
|
164
|
-
params.set('sysparm_limit', String(limit));
|
|
165
|
-
params.set('sysparm_display_value', 'all');
|
|
166
|
-
params.set('sysparm_fields', ['sys_id', ...columns].join(','));
|
|
167
165
|
const baseQ = extraQuery ? extraQuery + '^' : '';
|
|
168
166
|
const q = query ? query + '^ORDERBYDESCsys_updated_on' : (baseQ + 'ORDERBYDESCsys_updated_on');
|
|
169
|
-
params
|
|
170
|
-
const records = await app.sdk
|
|
167
|
+
const params = recordListParams({ limit, fields: ['sys_id', ...columns], query: q });
|
|
168
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
|
|
171
169
|
app.ok({
|
|
172
170
|
table,
|
|
173
171
|
count: records.length,
|
|
174
172
|
columns,
|
|
175
|
-
records: records.map(r => formatRecordForDisplay(r, columns)),
|
|
173
|
+
records: argv.displayValues || opts.listProjection === 'full' ? records : records.map(r => formatRecordForDisplay(r, columns)),
|
|
176
174
|
context: { instance_url: app.getEffectiveInstance() },
|
|
177
175
|
}, {
|
|
178
176
|
summary: `${records.length} ${name}(s)`,
|
|
@@ -184,6 +182,11 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
184
182
|
command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
|
|
185
183
|
aliases: ['get'],
|
|
186
184
|
describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
|
|
185
|
+
builder: y => {
|
|
186
|
+
const command = relatedListOptions(recordOptions(y));
|
|
187
|
+
return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true,
|
|
188
|
+
describe: 'Fetch profile-declared essential children; --no-essential skips them. Supplementary lists remain lazy.' }) : command;
|
|
189
|
+
},
|
|
187
190
|
handler: wrap(async (argv, app) => {
|
|
188
191
|
const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
|
|
189
192
|
if (id === undefined) return;
|
|
@@ -192,16 +195,17 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
192
195
|
const fields = (showFields && showFields.length > 0)
|
|
193
196
|
? [...new Set(['sys_id', ...showFields])]
|
|
194
197
|
: undefined;
|
|
195
|
-
const record = await
|
|
198
|
+
const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular, fields });
|
|
196
199
|
record._context = {
|
|
197
200
|
instance_url: app.getEffectiveInstance(),
|
|
198
201
|
table,
|
|
199
202
|
};
|
|
200
203
|
|
|
201
204
|
if (opts.onShow) {
|
|
202
|
-
await opts.onShow(record, app);
|
|
205
|
+
await opts.onShow(record, app, argv);
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
await enrichRelatedLists(record, { table, argv, app });
|
|
205
209
|
const summary = typeof showSummary === 'function' ? showSummary(record, id) : showSummary;
|
|
206
210
|
const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(record, id) : showBreadcrumbs;
|
|
207
211
|
|
|
@@ -214,14 +218,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
214
218
|
.command({
|
|
215
219
|
command: 'create',
|
|
216
220
|
describe: `Create a new ${singular}`,
|
|
217
|
-
builder: (y) => y
|
|
221
|
+
builder: (y) => recordOptions(y, false)
|
|
218
222
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
219
223
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
220
224
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
221
225
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
222
226
|
handler: wrap(async (argv, app) => {
|
|
223
227
|
const recordData = buildRecordPayload(argv);
|
|
224
|
-
const record = await app.sdk
|
|
228
|
+
const record = await createRecord(app.sdk, { table, data: recordData });
|
|
225
229
|
app.ok(record, {
|
|
226
230
|
summary: `Created ${singular}`,
|
|
227
231
|
breadcrumbs: [
|
|
@@ -233,31 +237,31 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
233
237
|
.command({
|
|
234
238
|
command: 'update <identifier>',
|
|
235
239
|
describe: `Update ${vowelArticle(singular)} ${singular}`,
|
|
236
|
-
builder: (y) => y
|
|
240
|
+
builder: (y) => recordOptions(y, false)
|
|
237
241
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
238
242
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
239
243
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
240
244
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
241
245
|
handler: wrap(async (argv, app) => {
|
|
242
246
|
const id = argv.identifier;
|
|
243
|
-
const record = await
|
|
247
|
+
const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
|
|
244
248
|
if (scopeValidation) {
|
|
245
249
|
await guardScope(app.sdk, record);
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
const sysID = unwrapSysId(record);
|
|
249
253
|
const recordData = buildRecordPayload(argv);
|
|
250
|
-
const updated = await app.sdk
|
|
254
|
+
const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
|
|
251
255
|
app.ok(updated, { summary: `Updated ${singular} ${id}` });
|
|
252
256
|
}),
|
|
253
257
|
})
|
|
254
258
|
.command({
|
|
255
259
|
command: 'delete <identifier>',
|
|
256
260
|
describe: `Delete ${vowelArticle(singular)} ${singular}`,
|
|
257
|
-
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
261
|
+
builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
258
262
|
handler: wrap(async (argv, app) => {
|
|
259
263
|
const id = argv.identifier;
|
|
260
|
-
const record = await
|
|
264
|
+
const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
|
|
261
265
|
if (scopeValidation) {
|
|
262
266
|
await guardScope(app.sdk, record);
|
|
263
267
|
}
|
|
@@ -267,7 +271,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
267
271
|
|
|
268
272
|
await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
|
|
269
273
|
|
|
270
|
-
await app.sdk
|
|
274
|
+
await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
|
|
271
275
|
app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
|
|
272
276
|
}),
|
|
273
277
|
});
|
package/src/commands/_simple.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
2
2
|
import { getStringField } from '../helpers.js';
|
|
3
3
|
// Simple table-based commands — singular names passed for grammar
|
|
4
4
|
// Default columns and aliases match the Go version exactly
|
|
@@ -47,13 +47,13 @@ async function fetchExtensionChain(sdk, record) {
|
|
|
47
47
|
|
|
48
48
|
export { actionsCmd } from './actions.js';
|
|
49
49
|
|
|
50
|
-
export const includesCmd = (wrap) =>
|
|
50
|
+
export const includesCmd = (wrap) => buildProfileCommand('includes', wrap);
|
|
51
51
|
|
|
52
|
-
export const rulesCmd = (wrap) =>
|
|
52
|
+
export const rulesCmd = (wrap) => buildProfileCommand('rules', wrap);
|
|
53
53
|
|
|
54
|
-
export const clientScriptsCmd = (wrap) =>
|
|
54
|
+
export const clientScriptsCmd = (wrap) => buildProfileCommand('clientscripts', wrap);
|
|
55
55
|
|
|
56
|
-
export const uiActionsCmd = (wrap) =>
|
|
56
|
+
export const uiActionsCmd = (wrap) => buildProfileCommand('uiactions', wrap);
|
|
57
57
|
|
|
58
58
|
// NOTE: uipolicies has exactly ONE definition — the rich one in
|
|
59
59
|
// ./uipolicies.js (re-exported below at the bottom of this file). A plain
|
|
@@ -62,10 +62,7 @@ export const uiActionsCmd = (wrap) => buildDevCmd('uiactions', 'sys_ui_action',
|
|
|
62
62
|
// this module's uiPoliciesCmd before uipoliciesCmd — so the factory version
|
|
63
63
|
// was dead code shadowing nothing. Deleted; the re-export is the behavior.
|
|
64
64
|
|
|
65
|
-
export const tablesCmd = (wrap) =>
|
|
66
|
-
singular: 'table',
|
|
67
|
-
scopeValidation: true,
|
|
68
|
-
showFields: ['name', 'label', 'super_class', 'create_access_controls', 'sys_scope', 'sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'is_extendable'],
|
|
65
|
+
export const tablesCmd = (wrap) => buildProfileCommand('tables', wrap, {
|
|
69
66
|
async onShow(record, app) {
|
|
70
67
|
const tableName = getStringField(record, 'name');
|
|
71
68
|
const [count, extChain] = await Promise.all([
|
|
@@ -81,48 +78,48 @@ export { columnsCmd } from './columns.js';
|
|
|
81
78
|
|
|
82
79
|
// Read-only commands (Go only has list/show)
|
|
83
80
|
export { importCmd } from './import.js';
|
|
84
|
-
export const spPagesCmd = (wrap) =>
|
|
85
|
-
export const spWidgetsCmd = (wrap) =>
|
|
86
|
-
export const uiPagesCmd = (wrap) =>
|
|
87
|
-
export const appMenuCmd = (wrap) =>
|
|
81
|
+
export const spPagesCmd = (wrap) => buildProfileCommand('sppages', wrap);
|
|
82
|
+
export const spWidgetsCmd = (wrap) => buildProfileCommand('spwidgets', wrap);
|
|
83
|
+
export const uiPagesCmd = (wrap) => buildProfileCommand('uipages', wrap);
|
|
84
|
+
export const appMenuCmd = (wrap) => buildProfileCommand('appmenu', wrap);
|
|
88
85
|
|
|
89
86
|
// Commands with full CRUD
|
|
90
|
-
export const aclsCmd = (wrap) =>
|
|
91
|
-
export const rolesCmd = (wrap) =>
|
|
92
|
-
export const propertiesCmd = (wrap) =>
|
|
87
|
+
export const aclsCmd = (wrap) => buildProfileCommand('acls', wrap);
|
|
88
|
+
export const rolesCmd = (wrap) => buildProfileCommand('roles', wrap);
|
|
89
|
+
export const propertiesCmd = (wrap) => buildProfileCommand('properties', wrap);
|
|
93
90
|
|
|
94
91
|
// New commands for scoped-app tables
|
|
95
|
-
export const relationshipsCmd = (wrap) =>
|
|
96
|
-
export const appmodulesCmd = (wrap) =>
|
|
97
|
-
export const listcontrolsCmd = (wrap) =>
|
|
98
|
-
export const viewsCmd = (wrap) =>
|
|
99
|
-
export const privilegesCmd = (wrap) =>
|
|
100
|
-
export const uxscriptsCmd = (wrap) =>
|
|
92
|
+
export const relationshipsCmd = (wrap) => buildProfileCommand('relationships', wrap);
|
|
93
|
+
export const appmodulesCmd = (wrap) => buildProfileCommand('appmodules', wrap);
|
|
94
|
+
export const listcontrolsCmd = (wrap) => buildProfileCommand('listcontrols', wrap);
|
|
95
|
+
export const viewsCmd = (wrap) => buildProfileCommand('views', wrap);
|
|
96
|
+
export const privilegesCmd = (wrap) => buildProfileCommand('privileges', wrap);
|
|
97
|
+
export const uxscriptsCmd = (wrap) => buildProfileCommand('uxscripts', wrap);
|
|
101
98
|
export { aliasesCmd } from './aliases.js';
|
|
102
|
-
export const catalogscriptsCmd = (wrap) =>
|
|
99
|
+
export const catalogscriptsCmd = (wrap) => buildProfileCommand('catalogscripts', wrap);
|
|
103
100
|
|
|
104
101
|
// ── Automation: Async ──
|
|
105
|
-
export const scriptactionsCmd = (wrap) =>
|
|
106
|
-
export const scheduledjobsCmd = (wrap) =>
|
|
107
|
-
export const asyncrulesCmd = (wrap) =>
|
|
108
|
-
export const triggersCmd = (wrap) =>
|
|
102
|
+
export const scriptactionsCmd = (wrap) => buildProfileCommand('scriptactions', wrap);
|
|
103
|
+
export const scheduledjobsCmd = (wrap) => buildProfileCommand('scheduledjobs', wrap);
|
|
104
|
+
export const asyncrulesCmd = (wrap) => buildProfileCommand('asyncrules', wrap);
|
|
105
|
+
export const triggersCmd = (wrap) => buildProfileCommand('triggers', wrap);
|
|
109
106
|
|
|
110
107
|
// ── Automation: In Memory ──
|
|
111
108
|
|
|
112
|
-
export const assignmentsCmd = (wrap) =>
|
|
109
|
+
export const assignmentsCmd = (wrap) => buildProfileCommand('assignments', wrap);
|
|
113
110
|
|
|
114
111
|
// ── Automation: Inbound ──
|
|
115
|
-
export const emailCmd = (wrap) =>
|
|
112
|
+
export const emailCmd = (wrap) => buildProfileCommand('email', wrap);
|
|
116
113
|
|
|
117
114
|
// ── Automation: Outbound ──
|
|
118
115
|
export { restmessageCmd } from './restmessage.js';
|
|
119
116
|
export { soapmessagesCmd } from './soapmessages.js';
|
|
120
117
|
|
|
121
118
|
// ── UX: Core UI ──
|
|
122
|
-
export const uimacrosCmd = (wrap) =>
|
|
119
|
+
export const uimacrosCmd = (wrap) => buildProfileCommand('uimacros', wrap);
|
|
123
120
|
|
|
124
121
|
// ── UX: Workspaces ──
|
|
125
|
-
export const uxlistsCmd = (wrap) =>
|
|
126
|
-
export const uxapplicabilityCmd = (wrap) =>
|
|
122
|
+
export const uxlistsCmd = (wrap) => buildProfileCommand('uxlists', wrap);
|
|
123
|
+
export const uxapplicabilityCmd = (wrap) => buildProfileCommand('uxapplicability', wrap);
|
|
127
124
|
export { uipoliciesCmd } from './uipolicies.js';
|
|
128
|
-
export const cataloguipoliciesCmd = (wrap) =>
|
|
125
|
+
export const cataloguipoliciesCmd = (wrap) => buildProfileCommand('cataloguipolicies', wrap);
|
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'] });
|
|
@@ -21,7 +23,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
21
23
|
command: 'list',
|
|
22
24
|
aliases: ['ls'],
|
|
23
25
|
describe: `List ${table}`,
|
|
24
|
-
builder: (y) => y
|
|
26
|
+
builder: (y) => recordOptions(y)
|
|
25
27
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
26
28
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
27
29
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
@@ -81,17 +83,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
const params = new URLSearchParams();
|
|
85
|
-
params.set('sysparm_limit', String(limit));
|
|
86
|
-
params.set('sysparm_offset', String(offset));
|
|
87
|
-
params.set('sysparm_display_value', 'all');
|
|
88
86
|
const fields = resolveFieldsParam(columns);
|
|
89
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
90
87
|
const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
91
|
-
params
|
|
88
|
+
const params = recordListParams({ limit, offset, fields, query: q });
|
|
92
89
|
|
|
93
|
-
const records = await app.sdk
|
|
94
|
-
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;
|
|
95
92
|
|
|
96
93
|
const breadcrumbs = [
|
|
97
94
|
{ action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
|
|
@@ -130,9 +127,15 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
130
127
|
command: 'show <number>',
|
|
131
128
|
aliases: ['get'],
|
|
132
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
|
+
},
|
|
133
134
|
handler: wrap(async (argv, app) => {
|
|
134
135
|
const number = argv.number;
|
|
135
|
-
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 });
|
|
136
139
|
record._context = {
|
|
137
140
|
instance_url: app.getEffectiveInstance(),
|
|
138
141
|
table,
|
|
@@ -149,7 +152,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
149
152
|
.command({
|
|
150
153
|
command: 'create',
|
|
151
154
|
describe: `Create a new ${displayName}`,
|
|
152
|
-
builder: (y) => y
|
|
155
|
+
builder: (y) => recordOptions(y, false)
|
|
153
156
|
.option('description', { alias: 'd', type: 'string', describe: 'Short description' })
|
|
154
157
|
.option('priority', { type: 'string', describe: 'Priority (1-5)' })
|
|
155
158
|
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
@@ -163,7 +166,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
163
166
|
if (!recordData.short_description) {
|
|
164
167
|
throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
|
|
165
168
|
}
|
|
166
|
-
const record = await app.sdk
|
|
169
|
+
const record = await createRecord(app.sdk, { table, data: recordData });
|
|
167
170
|
app.ok(record, {
|
|
168
171
|
summary: `Created ${displayName} ${getStringField(record, 'number')}`,
|
|
169
172
|
breadcrumbs: [
|
|
@@ -175,7 +178,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
175
178
|
.command({
|
|
176
179
|
command: 'update <number>',
|
|
177
180
|
describe: `Update a ${displayName}`,
|
|
178
|
-
builder: (y) => y
|
|
181
|
+
builder: (y) => recordOptions(y, false)
|
|
179
182
|
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
180
183
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
181
184
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
@@ -183,8 +186,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
183
186
|
handler: wrap(async (argv, app) => {
|
|
184
187
|
const number = argv.number;
|
|
185
188
|
const recordData = buildRecordPayload(argv);
|
|
186
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
187
|
-
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 });
|
|
188
191
|
app.ok(updated, {
|
|
189
192
|
summary: `Updated ${displayName} ${number}`,
|
|
190
193
|
breadcrumbs: [
|
|
@@ -196,12 +199,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
196
199
|
.command({
|
|
197
200
|
command: 'delete <number>',
|
|
198
201
|
describe: `Delete a ${displayName}`,
|
|
199
|
-
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' }),
|
|
200
203
|
handler: wrap(async (argv, app) => {
|
|
201
204
|
const number = argv.number;
|
|
202
205
|
await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
|
|
203
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
204
|
-
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 } });
|
|
205
208
|
app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
|
|
206
209
|
summary: `Deleted ${displayName} ${number}`,
|
|
207
210
|
});
|
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
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const incidentDefaultColumns = ['number', 'short_description', 'priority', 'state', 'assigned_to'];
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
4
2
|
|
|
5
3
|
export function incidentsCmd(wrap) {
|
|
6
|
-
return
|
|
4
|
+
return buildProfileCommand('incidents', wrap);
|
|
7
5
|
}
|
package/src/commands/records.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete,
|
|
3
|
+
import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, verifyWriteBack } from '../helpers.js';
|
|
4
|
+
import { listRecords, recordListParams, getRecord, createRecord, updateRecord, deleteRecord } from '../records-engine.js';
|
|
5
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
6
|
+
import { errUsage } from '../errors.js';
|
|
4
7
|
import { declareCapabilities } from '../capabilities.js';
|
|
5
8
|
|
|
6
9
|
declareCapabilities('records', {
|
|
@@ -67,6 +70,15 @@ function getDefaultColumns(table) {
|
|
|
67
70
|
return tableDefaultColumns[table] || ['sys_id'];
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
function recordLookup(argv) {
|
|
74
|
+
const sysId = argv['sys-id'];
|
|
75
|
+
if ((sysId === undefined) === (argv.identifier === undefined)) throw errUsage('Supply exactly one of --sys-id or --identifier.');
|
|
76
|
+
if (sysId !== undefined && argv.identifierField && argv.identifierField !== 'sys_id') throw errUsage('--sys-id cannot be combined with a different --identifier-field.');
|
|
77
|
+
return { table: argv.table, identifier: sysId ?? argv.identifier,
|
|
78
|
+
identifierField: sysId !== undefined ? 'sys_id' : (argv.identifierField || 'sys_id'),
|
|
79
|
+
displayValues: argv.displayValues, resource: 'Record' };
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
export function recordsCmd(wrap) {
|
|
71
83
|
return {
|
|
72
84
|
command: 'records [subcommand]',
|
|
@@ -83,6 +95,7 @@ export function recordsCmd(wrap) {
|
|
|
83
95
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
84
96
|
.option('limit', { type: 'number', default: 20, describe: 'Max records' })
|
|
85
97
|
.option('offset', { type: 'number', default: 0, describe: 'Offset' })
|
|
98
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
86
99
|
.option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' })
|
|
87
100
|
.epilogue([
|
|
88
101
|
'In an interactive terminal with automatic output, browse a picker (10 visible items).',
|
|
@@ -130,15 +143,10 @@ export function recordsCmd(wrap) {
|
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
// Text/table fallback (or sys-id direct lookup)
|
|
133
|
-
const params = new URLSearchParams();
|
|
134
|
-
params.set('sysparm_limit', argv['sys-id'] ? '1' : String(argv.limit));
|
|
135
|
-
params.set('sysparm_offset', String(argv.offset));
|
|
136
|
-
params.set('sysparm_display_value', 'all');
|
|
137
146
|
const fields = resolveFieldsParam(columns);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
147
|
+
const params = recordListParams({ limit: argv['sys-id'] ? 1 : argv.limit, offset: argv.offset, fields, query });
|
|
148
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues });
|
|
149
|
+
const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
142
150
|
|
|
143
151
|
// include_counts: default opt-in on every list. Opt out per-profile
|
|
144
152
|
// (config include_counts:false) or per-invocation (--no-count).
|
|
@@ -190,28 +198,23 @@ export function recordsCmd(wrap) {
|
|
|
190
198
|
})
|
|
191
199
|
.command({
|
|
192
200
|
command: 'get',
|
|
193
|
-
describe: 'Get
|
|
201
|
+
describe: 'Get one record by sys_id or an explicit identifier field',
|
|
194
202
|
builder: (y) => y
|
|
195
203
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
196
|
-
.option('sys-id', { type: 'string',
|
|
204
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
205
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
206
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
207
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
197
208
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
198
209
|
.option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
|
|
199
|
-
.epilogue('
|
|
210
|
+
.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'),
|
|
200
211
|
handler: wrap(async (argv, app) => {
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
params.set('sysparm_limit', '1');
|
|
205
|
-
params.set('sysparm_display_value', 'true');
|
|
206
|
-
if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', `sys_id,${argv.columns}`);
|
|
207
|
-
const records = await app.sdk.list(argv.table, params);
|
|
208
|
-
if (records.length === 0) {
|
|
209
|
-
throw new Error(`Record not found: ${argv['sys-id']}`);
|
|
210
|
-
}
|
|
211
|
-
const record = records[0];
|
|
212
|
+
const lookup = recordLookup(argv);
|
|
213
|
+
const fields = argv.columns && argv.columns !== '*' ? argv.columns.split(',') : undefined;
|
|
214
|
+
const record = await getRecord(app.sdk, { ...lookup, fields, displayValue: 'true' });
|
|
212
215
|
if (argv.attachments) {
|
|
213
216
|
try {
|
|
214
|
-
const atts = await app.sdk.listAttachments(
|
|
217
|
+
const atts = await app.sdk.listAttachments(unwrapSysId(record));
|
|
215
218
|
record._attachments = atts;
|
|
216
219
|
} catch {
|
|
217
220
|
record._attachments = [];
|
|
@@ -241,7 +244,7 @@ export function recordsCmd(wrap) {
|
|
|
241
244
|
for (const w of warnings) {
|
|
242
245
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
243
246
|
}
|
|
244
|
-
const record = await app.sdk
|
|
247
|
+
const record = await createRecord(app.sdk, { table: argv.table, data: recordData });
|
|
245
248
|
const sysID = getStringField(record, 'sys_id');
|
|
246
249
|
const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
|
|
247
250
|
for (const m of mismatches) {
|
|
@@ -258,7 +261,9 @@ export function recordsCmd(wrap) {
|
|
|
258
261
|
describe: 'Update an existing record',
|
|
259
262
|
builder: (y) => y
|
|
260
263
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
261
|
-
.option('sys-id', { type: 'string',
|
|
264
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
265
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
266
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
262
267
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
263
268
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
264
269
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
@@ -270,8 +275,10 @@ export function recordsCmd(wrap) {
|
|
|
270
275
|
for (const w of warnings) {
|
|
271
276
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
272
277
|
}
|
|
273
|
-
const
|
|
274
|
-
const
|
|
278
|
+
const lookup = recordLookup(argv);
|
|
279
|
+
const resolved = await getRecord(app.sdk, lookup);
|
|
280
|
+
const record = await updateRecord(app.sdk, { table: argv.table, record: resolved, data: recordData });
|
|
281
|
+
const mismatches = await verifyWriteBack(app, argv.table, unwrapSysId(resolved), recordData);
|
|
275
282
|
for (const m of mismatches) {
|
|
276
283
|
process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
|
|
277
284
|
}
|
|
@@ -286,12 +293,15 @@ export function recordsCmd(wrap) {
|
|
|
286
293
|
describe: 'Delete a record',
|
|
287
294
|
builder: (y) => y
|
|
288
295
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
289
|
-
.option('sys-id', { type: 'string',
|
|
296
|
+
.option('sys-id', { type: 'string', describe: 'Full or unambiguous partial record sys_id (including legacy IDs)' })
|
|
297
|
+
.option('identifier', { type: 'string', describe: 'Identifier value; use --identifier-field number/name/etc for human identifiers' })
|
|
298
|
+
.option('identifier-field', { type: 'string', describe: 'Exact identifier field (default sys_id); disambiguates names and legacy sys_ids' })
|
|
290
299
|
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
291
300
|
handler: wrap(async (argv, app) => {
|
|
292
|
-
|
|
293
|
-
await app
|
|
294
|
-
|
|
301
|
+
const lookup = recordLookup(argv);
|
|
302
|
+
await confirmDelete(app, argv, `Delete record from ${argv.table} (${lookup.identifier})`);
|
|
303
|
+
const sysID = await deleteRecord(app.sdk, lookup);
|
|
304
|
+
app.ok({ message: 'Record deleted', table: argv.table, sys_id: sysID }, { summary: `Deleted record from ${argv.table}` });
|
|
295
305
|
}),
|
|
296
306
|
})
|
|
297
307
|
.command({
|
|
@@ -369,6 +379,7 @@ export function recordsCmd(wrap) {
|
|
|
369
379
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
370
380
|
.option('query', { type: 'string', describe: 'Encoded query to constrain the aggregation' })
|
|
371
381
|
.option('group-by', { type: 'string', describe: 'Comma-separated fields to group by' })
|
|
382
|
+
.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both' })
|
|
372
383
|
.option('count', { type: 'boolean', default: true, describe: 'Include record counts' })
|
|
373
384
|
.option('avg-fields', { type: 'string', describe: 'Comma-separated fields to average' })
|
|
374
385
|
.option('sum-fields', { type: 'string', describe: 'Comma-separated fields to sum' })
|
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,6 +43,7 @@ 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: [
|
package/src/commands/tasks.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const taskDefaultColumns = ['number', 'short_description', 'state', 'assigned_to'];
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
4
2
|
|
|
5
3
|
export function tasksCmd(wrap) {
|
|
6
|
-
return
|
|
4
|
+
return buildProfileCommand('tasks', wrap);
|
|
7
5
|
}
|
package/src/commands/tickets.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
|
|
2
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload, confirmDelete } from '../helpers.js';
|
|
3
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
4
|
+
import { resolveSysId } from '../resolve-record.js';
|
|
3
5
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
6
|
|
|
5
7
|
declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
@@ -15,23 +17,18 @@ export function ticketsCmd(wrap) {
|
|
|
15
17
|
command: 'list',
|
|
16
18
|
aliases: ['ls'],
|
|
17
19
|
describe: 'List tickets',
|
|
18
|
-
builder: (y) => y
|
|
20
|
+
builder: (y) => recordOptions(y)
|
|
19
21
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
|
|
20
22
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
|
|
21
23
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
22
24
|
.option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
|
|
23
25
|
handler: wrap(async (argv, app) => {
|
|
24
26
|
const columns = argv.columns ? argv.columns.split(',') : ['number', 'short_description', 'state', 'assigned_to'];
|
|
25
|
-
const params = new URLSearchParams();
|
|
26
|
-
params.set('sysparm_limit', String(argv.limit));
|
|
27
|
-
params.set('sysparm_offset', String(argv.offset));
|
|
28
|
-
params.set('sysparm_display_value', 'all');
|
|
29
27
|
const fields = resolveFieldsParam(columns);
|
|
30
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
31
28
|
const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
32
|
-
params.
|
|
33
|
-
const records = await app.sdk
|
|
34
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
29
|
+
const params = recordListParams({ limit: argv.limit, offset: argv.offset, fields, query: q });
|
|
30
|
+
const records = await listRecords(app.sdk, { table: 'task', params, displayValues: argv.displayValues });
|
|
31
|
+
const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
35
32
|
const breadcrumbs = [
|
|
36
33
|
{ action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
|
|
37
34
|
];
|
|
@@ -63,46 +60,50 @@ export function ticketsCmd(wrap) {
|
|
|
63
60
|
command: 'show <number>',
|
|
64
61
|
aliases: ['get'],
|
|
65
62
|
describe: 'Show a ticket',
|
|
63
|
+
builder: y => relatedListOptions(recordOptions(y)),
|
|
66
64
|
handler: wrap(async (argv, app) => {
|
|
67
|
-
const record = await
|
|
65
|
+
const record = await getRecord(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
66
|
+
await enrichRelatedLists(record, { table: 'task', argv, app });
|
|
68
67
|
app.ok(record, { summary: `Ticket ${argv.number}` });
|
|
69
68
|
}),
|
|
70
69
|
})
|
|
71
70
|
.command({
|
|
72
71
|
command: 'create',
|
|
73
72
|
describe: 'Create a new ticket',
|
|
74
|
-
builder: (y) => y
|
|
73
|
+
builder: (y) => recordOptions(y, false)
|
|
75
74
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
76
75
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
77
76
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
78
77
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
79
78
|
handler: wrap(async (argv, app) => {
|
|
80
79
|
const recordData = buildRecordPayload(argv);
|
|
81
|
-
const record = await app.sdk
|
|
80
|
+
const record = await createRecord(app.sdk, { table: 'task', data: recordData });
|
|
82
81
|
app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
|
|
83
82
|
}),
|
|
84
83
|
})
|
|
85
84
|
.command({
|
|
86
85
|
command: 'update <number>',
|
|
87
86
|
describe: 'Update a ticket',
|
|
88
|
-
builder: (y) => y
|
|
87
|
+
builder: (y) => recordOptions(y, false)
|
|
89
88
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
90
89
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
91
90
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
92
91
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
93
92
|
handler: wrap(async (argv, app) => {
|
|
94
93
|
const recordData = buildRecordPayload(argv);
|
|
95
|
-
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
96
|
-
const updated = await app.sdk
|
|
94
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
95
|
+
const updated = await updateRecord(app.sdk, { table: 'task', record: { sys_id: sysID }, data: recordData });
|
|
97
96
|
app.ok(updated, { summary: `Updated ticket ${argv.number}` });
|
|
98
97
|
}),
|
|
99
98
|
})
|
|
100
99
|
.command({
|
|
101
100
|
command: 'delete <number>',
|
|
102
101
|
describe: 'Delete a ticket',
|
|
102
|
+
builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
|
|
103
103
|
handler: wrap(async (argv, app) => {
|
|
104
|
-
|
|
105
|
-
await app.sdk
|
|
104
|
+
await confirmDelete(app, argv, `Delete ticket ${argv.number}`);
|
|
105
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
106
|
+
await deleteRecord(app.sdk, { table: 'task', record: { sys_id: sysID } });
|
|
106
107
|
app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
|
|
107
108
|
}),
|
|
108
109
|
})
|
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getStringField } from '../helpers.js';
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
3
2
|
|
|
4
|
-
export const uipoliciesCmd =
|
|
5
|
-
['short_description', 'table', 'sys_class_name', 'active'], wrap, {
|
|
6
|
-
singular: 'UI policy',
|
|
7
|
-
scopeValidation: true,
|
|
8
|
-
onShow: async (record, app) => {
|
|
9
|
-
const sysID = getStringField(record, 'sys_id') || '';
|
|
10
|
-
if (!sysID) return;
|
|
11
|
-
const mp = new URLSearchParams();
|
|
12
|
-
mp.set('sysparm_query', `ui_policy=${sysID}^ORDERBYorder`);
|
|
13
|
-
mp.set('sysparm_limit', '100'); mp.set('sysparm_display_value', 'all');
|
|
14
|
-
mp.set('sysparm_fields', 'name,table,field_name,read_only,mandatory,visible,sys_id');
|
|
15
|
-
const actions = await app.sdk.list('sys_ui_policy_action', mp);
|
|
16
|
-
if (actions.length > 0) {
|
|
17
|
-
console.log(` Actions (${actions.length}):`);
|
|
18
|
-
for (const a of actions) {
|
|
19
|
-
const f = getStringField(a, 'field_name') || '(all fields)';
|
|
20
|
-
const flags = [];
|
|
21
|
-
if (getStringField(a, 'mandatory') === 'true') flags.push('mandatory');
|
|
22
|
-
if (getStringField(a, 'read_only') === 'true') flags.push('read-only');
|
|
23
|
-
if (getStringField(a, 'visible') === 'false') flags.push('hidden');
|
|
24
|
-
console.log(` ${f} ${flags.length ? `[${flags.join(', ')}]` : ''}`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
});
|
|
3
|
+
export const uipoliciesCmd = wrap => buildProfileCommand('uipolicies', wrap);
|
package/src/errors.js
CHANGED
|
@@ -256,6 +256,13 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
|
|
|
256
256
|
return { stream: 'stderr', text, exitCode: 1 };
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
if (err.resolution) {
|
|
260
|
+
if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
|
|
261
|
+
const lookup = err.resolution;
|
|
262
|
+
const text = `Error (${code}): ${err.message}\nLookup: ${lookup.mode} ${lookup.table}.${lookup.field}; ${lookup.at_least ? 'at least ' : ''}${lookup.matches} matches.\n${err.hint ? `\nHint: ${err.hint}\n` : ''}`;
|
|
263
|
+
return { stream: 'stderr', text, exitCode: 1 };
|
|
264
|
+
}
|
|
265
|
+
|
|
259
266
|
if (code === CodeNotFound) {
|
|
260
267
|
let text = `Error (${code}): ${err.message}\n`;
|
|
261
268
|
if (identifier) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Essential relationships are a declarative opt-in of specialized profiles.
|
|
2
|
+
// Supplementary metadata/counts live separately; this never fetches them.
|
|
3
|
+
import { assertSafeExactMatch, quoteShellArg } from './helpers.js';
|
|
4
|
+
import { listRecords, displayValue, recordListParams } from './records-engine.js';
|
|
5
|
+
|
|
6
|
+
export async function enrichEssentialRecords(record, { sdk, relationships, displayValues = 'both', profileName, instance }) {
|
|
7
|
+
if (!relationships?.length) return record;
|
|
8
|
+
const id = typeof record.sys_id === 'object' ? record.sys_id?.value : record.sys_id;
|
|
9
|
+
const entries = await Promise.all(relationships.map(async relationship => {
|
|
10
|
+
const limit = relationship.limit ?? 100;
|
|
11
|
+
const query = `${relationship.foreignKey}=${id}`;
|
|
12
|
+
const result = { table: relationship.table, query, limit, records: [], loaded: 0,
|
|
13
|
+
coverage: 'visible_rows_only', visibility: 'Subject to field/row ACLs; empty results do not prove no related records.' };
|
|
14
|
+
try {
|
|
15
|
+
if (!id) throw new Error('No readable raw parent sys_id');
|
|
16
|
+
assertSafeExactMatch(id);
|
|
17
|
+
const ordered = query + (relationship.orderBy ? `^ORDERBY${relationship.orderBy}` : '');
|
|
18
|
+
const params = recordListParams({ limit: limit + 1, query: ordered,
|
|
19
|
+
fields: relationship.columns ? ['sys_id', ...relationship.columns] : undefined,
|
|
20
|
+
displayValue: displayValue(displayValues),
|
|
21
|
+
});
|
|
22
|
+
const rows = await listRecords(sdk, { table: relationship.table, params });
|
|
23
|
+
if (!Array.isArray(rows)) throw new Error('Invalid related record response');
|
|
24
|
+
result.records = rows.slice(0, limit);
|
|
25
|
+
result.loaded = result.records.length;
|
|
26
|
+
result.has_more = rows.length > limit;
|
|
27
|
+
result.status = 'loaded';
|
|
28
|
+
const selector = profileName ? ` --profile ${quoteShellArg(profileName)}` : instance ? ` --instance ${quoteShellArg(instance)}` : '';
|
|
29
|
+
result.command = `jsn${selector} records list --table ${quoteShellArg(relationship.table)} --query ${quoteShellArg(ordered)} --limit ${limit} --json`;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Provider bodies can contain private data. Preserve uncertainty, not raw errors.
|
|
32
|
+
result.status = 'unavailable';
|
|
33
|
+
result.error = { code: error.code === 'forbidden' || error.status === 403 ? 'forbidden' : 'unavailable',
|
|
34
|
+
message: 'Essential related records could not be read. Check parent identity, table/field permissions and connection.' };
|
|
35
|
+
}
|
|
36
|
+
return [relationship.name, result];
|
|
37
|
+
}));
|
|
38
|
+
record._essential = Object.fromEntries(entries);
|
|
39
|
+
return record;
|
|
40
|
+
}
|
package/src/metadata/records.js
CHANGED
|
@@ -237,7 +237,11 @@ function buildRelationships({ entries, dictionary, relationships, sourceTable, n
|
|
|
237
237
|
const existing = result.get(id);
|
|
238
238
|
const descriptor = existing || { id, kind: 'unresolved', table: '', reference_field: '', query_status: 'unavailable',
|
|
239
239
|
reason: 'Configured related list metadata is absent, unsupported, or not visible' };
|
|
240
|
-
|
|
240
|
+
const filter = entry.filter;
|
|
241
|
+
const filterAvailable = Boolean(Object.hasOwn(entry, 'filter') && (filter === null || typeof filter === 'string'
|
|
242
|
+
|| (filter && typeof filter === 'object' && Object.hasOwn(filter, 'value') && (filter.value === null || typeof filter.value === 'string'))));
|
|
243
|
+
if (!filterAvailable) warnings.push({ source: 'sys_ui_related_list_entry', reason: 'A configured filter is absent or not visible; do not assume an unfiltered list' });
|
|
244
|
+
result.set(id, { ...descriptor, configured: true, filter_available: filterAvailable, source_table: sourceTable, position: position(entry),
|
|
241
245
|
filter: field(entry, 'filter'), order_by: field(entry, 'order_by') });
|
|
242
246
|
if (!existing) warnings.push({ source: 'sys_ui_related_list_entry', reason: descriptor.reason });
|
|
243
247
|
}
|
package/src/output.js
CHANGED
|
@@ -160,6 +160,7 @@ export class OutputWriter {
|
|
|
160
160
|
const format = this.effectiveFormat();
|
|
161
161
|
const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
|
|
162
162
|
const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
|
|
163
|
+
if (e.resolution) resp.resolution = e.resolution;
|
|
163
164
|
if (e.context) resp.context = e.context;
|
|
164
165
|
if (e.status === 401 || e.status === 403) resp.status = e.status;
|
|
165
166
|
if (e.recovery) {
|
|
@@ -514,6 +515,31 @@ export class OutputWriter {
|
|
|
514
515
|
this.writer.write('\n');
|
|
515
516
|
}
|
|
516
517
|
|
|
518
|
+
if (data._essential && typeof data._essential === 'object') {
|
|
519
|
+
for (const [name, related] of Object.entries(data._essential)) {
|
|
520
|
+
this.writer.write(`─ Essential: ${name} (${related.loaded} loaded${related.has_more ? ', more available' : ''}) ─\n`);
|
|
521
|
+
if (related.status !== 'loaded') this.writer.write(` Unavailable: ${related.error?.message || 'Read could not be completed.'}\n`);
|
|
522
|
+
for (const row of related.records || []) {
|
|
523
|
+
const fields = Object.entries(row).filter(([field]) => field !== 'sys_id').map(([field, value]) => `${field}: ${getDisplayValue(value)}`);
|
|
524
|
+
this.writer.write(` ${fields.join(' | ')}\n`);
|
|
525
|
+
}
|
|
526
|
+
this.writer.write(` ${related.visibility}\n`);
|
|
527
|
+
if (related.command) this.writer.write(` Retrieve: ${related.command}\n`);
|
|
528
|
+
this.writer.write('\n');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (data._related && typeof data._related === 'object') {
|
|
533
|
+
this.writer.write('─ Related lists ─\n');
|
|
534
|
+
for (const list of data._related.nonempty || []) {
|
|
535
|
+
this.writer.write(` ${list.label}: ${list.count}\n`);
|
|
536
|
+
if (list.command) this.writer.write(` Retrieve: ${list.command}\n`);
|
|
537
|
+
}
|
|
538
|
+
this.writer.write(` Coverage: ${data._related.coverage?.state || 'unknown'}; counts reflect aggregate API access and visible metadata.\n`);
|
|
539
|
+
for (const warning of data._related.coverage?.warnings || []) this.writer.write(` Unknown (${warning.source}): ${warning.reason}\n`);
|
|
540
|
+
this.writer.write('\n');
|
|
541
|
+
}
|
|
542
|
+
|
|
517
543
|
// ── Attachments ──
|
|
518
544
|
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
519
545
|
this.writer.write('─ Attachments ─\n');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Edit table/ticket command defaults here. The key is the CLI command name.
|
|
2
|
+
// Identifier/output defaults and empty relationship policies live in record-profiles.js.
|
|
3
|
+
export default {
|
|
4
|
+
includes: { table: 'sys_script_include', aliases: ['include', 'si'], columns: ['name', 'api_name', 'active', 'sys_scope'],
|
|
5
|
+
options: { singular: 'script include', scopeValidation: true } },
|
|
6
|
+
rules: { table: 'sys_script', aliases: ['rule', 'br'], columns: ['name', 'collection', 'active', 'order', 'sys_scope'],
|
|
7
|
+
options: { singular: 'business rule', scopeValidation: true } },
|
|
8
|
+
clientscripts: { table: 'sys_script_client', aliases: ['clientscript', 'cs'], columns: ['name', 'table', 'active', 'type', 'sys_scope'],
|
|
9
|
+
options: { singular: 'client script', scopeValidation: true } },
|
|
10
|
+
uiactions: { table: 'sys_ui_action', aliases: ['uiaction', 'ua'], columns: ['name', 'table', 'active', 'order', 'sys_scope'],
|
|
11
|
+
options: { singular: 'UI action', scopeValidation: true } },
|
|
12
|
+
sppages: { table: 'sp_page', aliases: ['sp-pages'], columns: ['id', 'title', 'sys_scope'],
|
|
13
|
+
options: { singular: 'Service Portal page', readOnly: true } },
|
|
14
|
+
spwidgets: { table: 'sp_widget', aliases: ['sp-widget', 'widgets'], columns: ['id', 'name', 'sys_scope'],
|
|
15
|
+
options: { singular: 'Service Portal widget', readOnly: true } },
|
|
16
|
+
uipages: { table: 'sys_ui_page', aliases: ['ui-page', 'pages'], columns: ['name', 'sys_scope'],
|
|
17
|
+
options: { singular: 'UI page', scopeValidation: true } },
|
|
18
|
+
appmenu: { table: 'sys_app_application', aliases: ['app-menu', 'menu'], columns: ['name', 'active', 'sys_scope'],
|
|
19
|
+
options: { singular: 'application menu', scopeValidation: true } },
|
|
20
|
+
acls: { table: 'sys_security_acl', aliases: ['acl'], columns: ['name', 'operation', 'type', 'active', 'sys_scope'],
|
|
21
|
+
options: { singular: 'ACL', scopeValidation: true } },
|
|
22
|
+
roles: { table: 'sys_user_role', aliases: ['role', 'r'], columns: ['name', 'description', 'elevated_privilege', 'sys_scope'],
|
|
23
|
+
options: { singular: 'role', scopeValidation: true } },
|
|
24
|
+
properties: { table: 'sys_properties', aliases: ['property', 'prop'], columns: ['name', 'value', 'description', 'sys_scope'],
|
|
25
|
+
options: { singular: 'property', scopeValidation: true } },
|
|
26
|
+
relationships: { table: 'sys_relationship', aliases: ['relationship', 'rel'], columns: ['name', 'sys_scope'],
|
|
27
|
+
options: { singular: 'relationship', scopeValidation: true } },
|
|
28
|
+
appmodules: { table: 'sys_app_module', aliases: ['appmodule', 'am'], columns: ['name', 'active', 'sys_scope'],
|
|
29
|
+
options: { singular: 'application module', scopeValidation: true } },
|
|
30
|
+
listcontrols: { table: 'sys_ui_list_control', aliases: ['listcontrol', 'lc'], columns: ['name', 'active', 'sys_scope'],
|
|
31
|
+
options: { singular: 'list control', scopeValidation: true } },
|
|
32
|
+
views: { table: 'sys_ui_view', aliases: ['view', 'vw'], columns: ['name', 'title', 'sys_scope'],
|
|
33
|
+
options: { singular: 'view', scopeValidation: true } },
|
|
34
|
+
privileges: { table: 'sys_scope_privilege', aliases: ['privilege', 'priv'], columns: ['source_scope', 'target_scope', 'target_name', 'operation', 'status'],
|
|
35
|
+
options: { singular: 'privilege', scopeValidation: true } },
|
|
36
|
+
uxscripts: { table: 'sys_ux_lib_source_script', aliases: ['uxscript', 'ux'], columns: ['name', 'active', 'sys_scope'],
|
|
37
|
+
options: { singular: 'UX script', scopeValidation: true } },
|
|
38
|
+
catalogscripts: { table: 'catalog_script_client', aliases: ['catalogscript'], columns: ['name', 'table', 'active', 'sys_scope'],
|
|
39
|
+
options: { singular: 'catalog script', scopeValidation: true } },
|
|
40
|
+
scriptactions: { table: 'sysevent_script_action', aliases: ['scriptaction'], columns: ['name', 'active', 'sys_scope'],
|
|
41
|
+
options: { singular: 'script action', scopeValidation: true } },
|
|
42
|
+
scheduledjobs: { table: 'sysauto_script', aliases: ['scheduledjob'], columns: ['name', 'active', 'run_type', 'sys_scope'],
|
|
43
|
+
options: { singular: 'scheduled job', scopeValidation: true } },
|
|
44
|
+
asyncrules: { table: 'sys_script', aliases: ['asyncrule'], columns: ['name', 'collection', 'active', 'order', 'sys_scope'],
|
|
45
|
+
options: { singular: 'async business rule', scopeValidation: true, extraQuery: 'when=async' } },
|
|
46
|
+
triggers: { table: 'sysevent_register', aliases: ['trigger'], columns: ['name', 'event_name', 'active', 'sys_scope'],
|
|
47
|
+
options: { singular: 'event trigger', scopeValidation: true } },
|
|
48
|
+
assignments: { table: 'sysrule_assignment', aliases: ['assignment'], columns: ['name', 'active', 'sys_scope'],
|
|
49
|
+
options: { singular: 'assignment rule', scopeValidation: true, readOnly: true } },
|
|
50
|
+
email: { table: 'sysevent_in_email_action', aliases: ['emails'], columns: ['name', 'active', 'type', 'sys_scope'],
|
|
51
|
+
options: { singular: 'inbound email action', scopeValidation: true } },
|
|
52
|
+
uimacros: { table: 'sys_ui_macro', aliases: ['uimacro'], columns: ['name', 'active', 'sys_scope'],
|
|
53
|
+
options: { singular: 'UI macro', scopeValidation: true } },
|
|
54
|
+
uxlists: { table: 'sys_ux_list', aliases: ['uxlist'], columns: ['name', 'active', 'sys_scope'],
|
|
55
|
+
options: { singular: 'workspace list', scopeValidation: true, readOnly: true } },
|
|
56
|
+
uxapplicability: { table: 'sys_ux_applicability_m2m', aliases: ['uxapp'], columns: ['name', 'active', 'sys_scope'],
|
|
57
|
+
options: { singular: 'workspace applicability', scopeValidation: true, readOnly: true } },
|
|
58
|
+
cataloguipolicies: { table: 'catalog_ui_policy', aliases: ['cataloguipolicy', 'cup'], columns: ['short_description', 'catalog_item', 'variable_set', 'active', 'sys_scope'],
|
|
59
|
+
options: { singular: 'catalog UI policy', scopeValidation: true } },
|
|
60
|
+
tables: { table: 'sys_db_object', aliases: ['table', 't'], columns: ['name', 'label', 'super_class', 'create_access_controls'],
|
|
61
|
+
options: { singular: 'table', scopeValidation: true, showFields: ['name', 'label', 'super_class', 'create_access_controls', 'sys_scope', 'sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'is_extendable'] } },
|
|
62
|
+
incidents: { table: 'incident', aliases: ['incident', 'inc'], columns: ['number', 'short_description', 'priority', 'state', 'assigned_to'], kind: 'ticket' },
|
|
63
|
+
changes: { table: 'change_request', aliases: ['change_request', 'chg'], columns: ['number', 'short_description', 'risk', 'state', 'assigned_to'], kind: 'ticket' },
|
|
64
|
+
requests: { table: 'sc_req_item', aliases: ['sc_req_item', 'req'], columns: ['number', 'short_description', 'stage', 'assigned_to'], kind: 'ticket' },
|
|
65
|
+
tasks: { table: 'sc_task', aliases: ['sc_task', 'task'], columns: ['number', 'short_description', 'state', 'assigned_to'], kind: 'ticket' },
|
|
66
|
+
uipolicies: { table: 'sys_ui_policy', aliases: ['uipolicy', 'up'], columns: ['short_description', 'table', 'sys_class_name', 'active'],
|
|
67
|
+
identifierField: 'short_description', options: { singular: 'UI policy', scopeValidation: true },
|
|
68
|
+
relationships: { essential: [{ name: 'actions', table: 'sys_ui_policy_action', foreignKey: 'ui_policy', limit: 100, orderBy: 'order',
|
|
69
|
+
columns: ['name', 'table', 'field_name', 'read_only', 'mandatory', 'visible'] }] } },
|
|
70
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Declarative command metadata is consumed by the actual command factories.
|
|
2
|
+
// Hooks stay executable code; the compact registry owns aliases/table/defaults/relationship policy.
|
|
3
|
+
import definitions from './record-command-profiles.js';
|
|
4
|
+
import { enrichEssentialRecords } from './essential-records.js';
|
|
5
|
+
import { buildDevCmd } from './commands/_generic.js';
|
|
6
|
+
import { buildTicketCommands } from './commands/_ticket.js';
|
|
7
|
+
|
|
8
|
+
const namePattern = /^[a-z][a-z0-9_-]*$/i;
|
|
9
|
+
const fieldPattern = /^[a-z_][a-z0-9_]*$/i;
|
|
10
|
+
function freeze(value) {
|
|
11
|
+
if (value && typeof value === 'object') {
|
|
12
|
+
for (const entry of Object.values(value)) freeze(entry);
|
|
13
|
+
Object.freeze(value);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function validateRecordProfiles(profiles) {
|
|
18
|
+
if (!Array.isArray(profiles)) throw new Error('Record profiles must be an array.');
|
|
19
|
+
const names = new Set();
|
|
20
|
+
return profiles.map(definition => {
|
|
21
|
+
for (const key of ['options', 'output', 'relationships']) {
|
|
22
|
+
if (definition[key] !== undefined && (!definition[key] || typeof definition[key] !== 'object' || Array.isArray(definition[key]))) throw new Error(`Invalid ${key} for ${definition.name}.`);
|
|
23
|
+
}
|
|
24
|
+
const profile = { ...definition,
|
|
25
|
+
identifierField: definition.identifierField ?? (definition.kind === 'ticket' ? 'number' : 'name'),
|
|
26
|
+
options: { ...definition.options },
|
|
27
|
+
output: { references: 'both', listProjection: 'display', ...definition.output },
|
|
28
|
+
relationships: { essential: [], supplementary: [], ...definition.relationships },
|
|
29
|
+
};
|
|
30
|
+
if (!namePattern.test(profile.name || '') || names.has(profile.name)) throw new Error('Invalid or duplicate record profile name.');
|
|
31
|
+
names.add(profile.name);
|
|
32
|
+
if (!fieldPattern.test(profile.table || '') || !fieldPattern.test(profile.identifierField || '')) throw new Error(`Invalid table/identifier field for ${profile.name}.`);
|
|
33
|
+
if (!Array.isArray(profile.aliases) || !profile.aliases.every(alias => namePattern.test(alias))) throw new Error(`Invalid aliases for ${profile.name}.`);
|
|
34
|
+
if (!Array.isArray(profile.columns) || !profile.columns.length || !profile.columns.every(field => fieldPattern.test(field))) throw new Error(`Invalid columns for ${profile.name}.`);
|
|
35
|
+
if (!['dev', 'ticket'].includes(profile.kind || 'dev')) throw new Error(`Invalid factory kind for ${profile.name}.`);
|
|
36
|
+
if (!['raw', 'display', 'both'].includes(profile.output?.references) || !['display', 'full'].includes(profile.output?.listProjection)) throw new Error(`Invalid output preferences for ${profile.name}.`);
|
|
37
|
+
if (!profile.options || typeof profile.options !== 'object' || Array.isArray(profile.options)) throw new Error(`Invalid factory options for ${profile.name}.`);
|
|
38
|
+
for (const key of ['readOnly', 'scopeValidation']) {
|
|
39
|
+
if (profile.options[key] !== undefined && typeof profile.options[key] !== 'boolean') throw new Error(`Invalid ${key} option for ${profile.name}.`);
|
|
40
|
+
}
|
|
41
|
+
for (const kind of ['essential', 'supplementary']) {
|
|
42
|
+
if (!Array.isArray(profile.relationships?.[kind])) throw new Error(`Missing ${kind} relationship policy for ${profile.name}.`);
|
|
43
|
+
const relationshipNames = new Set();
|
|
44
|
+
for (const relationship of profile.relationships[kind]) {
|
|
45
|
+
if (relationshipNames.has(relationship.name)) throw new Error(`Duplicate relationship for ${profile.name}.`);
|
|
46
|
+
relationshipNames.add(relationship.name);
|
|
47
|
+
if (relationship.limit !== undefined && (!Number.isInteger(relationship.limit) || relationship.limit < 1 || relationship.limit > 1000)) throw new Error(`Invalid relationship limit for ${profile.name}.`);
|
|
48
|
+
if (relationship.orderBy !== undefined && !fieldPattern.test(relationship.orderBy)) throw new Error(`Invalid relationship ordering for ${profile.name}.`);
|
|
49
|
+
if (relationship.columns !== undefined && (!Array.isArray(relationship.columns) || !relationship.columns.every(field => fieldPattern.test(field)))) throw new Error(`Invalid relationship columns for ${profile.name}.`);
|
|
50
|
+
if (!namePattern.test(relationship.name || '') || !fieldPattern.test(relationship.table || '') || !fieldPattern.test(relationship.foreignKey || '')) throw new Error(`Invalid relationship for ${profile.name}.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return freeze(structuredClone(profile));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const profiles = freeze(validateRecordProfiles(Object.entries(definitions).map(([name, profile]) => ({ name, ...profile }))));
|
|
57
|
+
const index = new Map(profiles.map(profile => [profile.name, profile]));
|
|
58
|
+
export const recordProfiles = () => profiles;
|
|
59
|
+
export function getRecordProfile(name) {
|
|
60
|
+
const profile = index.get(name);
|
|
61
|
+
if (!profile) throw new Error(`Unknown record command profile: ${name}`);
|
|
62
|
+
return profile;
|
|
63
|
+
}
|
|
64
|
+
export function buildProfileCommand(name, wrap, hooks = {}) {
|
|
65
|
+
return buildRecordProfile(getRecordProfile(name), wrap, hooks);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildRecordProfile(definition, wrap, hooks = {}) {
|
|
69
|
+
const [profile] = validateRecordProfiles([definition]);
|
|
70
|
+
const options = { ...profile.options, identifierField: profile.identifierField,
|
|
71
|
+
referenceMode: profile.output.references, listProjection: profile.output.listProjection,
|
|
72
|
+
relationships: profile.relationships, ...hooks };
|
|
73
|
+
if (profile.relationships.essential.length) {
|
|
74
|
+
const hook = options.onShow;
|
|
75
|
+
options.onShow = async (record, app, argv = {}) => {
|
|
76
|
+
if (hook) await hook(record, app, argv);
|
|
77
|
+
if (argv.essential !== false) await enrichEssentialRecords(record, { sdk: app.sdk,
|
|
78
|
+
relationships: profile.relationships.essential, displayValues: argv.displayValues ?? profile.output.references,
|
|
79
|
+
profileName: app.session?.profileName, instance: app.getEffectiveInstance?.() });
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const command = profile.kind === 'ticket'
|
|
83
|
+
? buildTicketCommands(profile.table, profile.name, profile.aliases.find(alias => alias !== profile.table), profile.columns, {}, null, wrap, options)
|
|
84
|
+
: buildDevCmd(profile.name, profile.table, profile.aliases, profile.columns, wrap, options);
|
|
85
|
+
command.aliases = [...profile.aliases];
|
|
86
|
+
command.recordProfile = profile;
|
|
87
|
+
return command;
|
|
88
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// In-process CRUD transport shared by generic and specialized commands.
|
|
2
|
+
// Policy (confirmation, scope, payload and output shape) stays with callers.
|
|
3
|
+
import { resolveRecord, unwrapSysId } from './resolve-record.js';
|
|
4
|
+
import { errUsage } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export function displayValue(mode, fallback = 'all') {
|
|
7
|
+
if (mode === undefined) return fallback;
|
|
8
|
+
const values = { raw: 'false', display: 'true', both: 'all' };
|
|
9
|
+
if (!Object.hasOwn(values, mode)) throw errUsage('display-values must be raw, display or both');
|
|
10
|
+
return values[mode];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function recordListParams({ limit, offset, query, fields, displayValue: representation = 'all' } = {}) {
|
|
14
|
+
const params = new URLSearchParams({ sysparm_display_value: representation });
|
|
15
|
+
if (limit !== undefined) params.set('sysparm_limit', String(limit));
|
|
16
|
+
if (offset !== undefined) params.set('sysparm_offset', String(offset));
|
|
17
|
+
if (query) params.set('sysparm_query', query);
|
|
18
|
+
if (fields) params.set('sysparm_fields', Array.isArray(fields) ? fields.join(',') : fields);
|
|
19
|
+
return params;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function listRecords(sdk, { table, params, displayValues, ...options }) {
|
|
23
|
+
const query = params ? new URLSearchParams(params) : recordListParams(options);
|
|
24
|
+
if (displayValues !== undefined) query.set('sysparm_display_value', displayValue(displayValues));
|
|
25
|
+
return sdk.list(table, query);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getRecord(sdk, options) {
|
|
29
|
+
return resolveRecord(sdk, { ...options, displayValue: displayValue(options.displayValues, options.displayValue || 'all') });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createRecord(sdk, { table, data }) {
|
|
33
|
+
return sdk.create(table, data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mutationId(record) {
|
|
37
|
+
const id = unwrapSysId(record);
|
|
38
|
+
if (!id) throw errUsage('The matched record has no readable sys_id; check field ACLs before retrying.');
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function updateRecord(sdk, { table, data, record, ...lookup }) {
|
|
43
|
+
const resolved = record || await getRecord(sdk, { table, ...lookup });
|
|
44
|
+
return sdk.update(table, mutationId(resolved), data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function deleteRecord(sdk, { table, record, ...lookup }) {
|
|
48
|
+
const resolved = record || await getRecord(sdk, { table, ...lookup });
|
|
49
|
+
const id = mutationId(resolved);
|
|
50
|
+
await sdk.delete(table, id);
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function recordOptions(y, display = true) {
|
|
55
|
+
const command = y.option('identifier-field', { type: 'string', describe: 'Exact field for disambiguation (sys_id, number, name, etc); no human-field partial matches' });
|
|
56
|
+
return display ? command.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both; omitted preserves existing output' }) : command;
|
|
57
|
+
}
|
package/src/related-lists.js
CHANGED
|
@@ -145,3 +145,9 @@ export async function enrichRelatedLists(record, { table, argv = {}, app }) {
|
|
|
145
145
|
instance: app.getEffectiveInstance?.() || '' });
|
|
146
146
|
return record;
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
export function relatedListOptions(y) {
|
|
150
|
+
return y.option('related', { type: 'boolean', default: false, describe: 'Count related lists under Aggregate API access; suggest commands without loading supplementary records' })
|
|
151
|
+
.option('view', { type: 'string', describe: 'Form view for --related metadata (default: Default view)' })
|
|
152
|
+
.option('related-limit', { type: 'number', describe: 'Max related-list descriptors to count with --related (default 50)' });
|
|
153
|
+
}
|
package/src/resolve-record.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// resolve → mutate → report.
|
|
10
10
|
|
|
11
11
|
import { assertSafeExactMatch, isHexString } from './helpers.js';
|
|
12
|
-
import { errNotFound } from './errors.js';
|
|
12
|
+
import { errNotFound, errUsage, AppError, CodeAmbiguous } from './errors.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* True when the identifier looks like a ServiceNow sys_id (32 hex chars).
|
|
@@ -25,40 +25,60 @@ export function isSysId(identifier) {
|
|
|
25
25
|
*/
|
|
26
26
|
export function unwrapSysId(record) {
|
|
27
27
|
const sysId = record?.sys_id;
|
|
28
|
-
if (sysId && typeof sysId === 'object') return sysId.value ||
|
|
28
|
+
if (sysId && typeof sysId === 'object') return sysId.value || '';
|
|
29
29
|
return sysId || '';
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* @param {object} opts
|
|
37
|
-
* @param {string} opts.table — ServiceNow table
|
|
38
|
-
* @param {string} opts.identifier — sys_id (32 hex) or value of matchField
|
|
39
|
-
* @param {string} opts.matchField — field to match non-sys_id identifiers on
|
|
40
|
-
* (e.g. 'number', 'name', 'user_name')
|
|
41
|
-
* @param {string} [opts.resource] — label for the not-found error
|
|
42
|
-
* (e.g. 'User' → "User not found: <id>"); defaults to matchField
|
|
43
|
-
* @param {string[]} [opts.fields] — restrict sysparm_fields when set
|
|
44
|
-
* @returns {Promise<object>} the record (display values requested)
|
|
45
|
-
* @throws {AppError} code not_found when no record matches
|
|
33
|
+
* Exact lookups precede partial sys_id lookup. Configured human identifiers keep
|
|
34
|
+
* their legacy precedence; identifierField explicitly disambiguates collisions.
|
|
35
|
+
* Each query requests at most two rows so no operation silently picks a match.
|
|
46
36
|
*/
|
|
47
|
-
export async function resolveRecord(sdk, { table, identifier, matchField,
|
|
37
|
+
export async function resolveRecord(sdk, { table, identifier, matchField = 'sys_id', identifierField,
|
|
38
|
+
resource, fields, displayValue = 'all', partial = true }) {
|
|
48
39
|
assertSafeExactMatch(identifier);
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
params.set('sysparm_limit', '1');
|
|
53
|
-
params.set('sysparm_display_value', 'all');
|
|
54
|
-
if (fields && fields.length > 0) {
|
|
55
|
-
params.set('sysparm_fields', fields.join(','));
|
|
40
|
+
if (typeof identifier !== 'string' || !identifier.trim()) throw errUsage('An identifier is required.');
|
|
41
|
+
for (const value of [table, identifierField || matchField]) {
|
|
42
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value || '')) throw errUsage('Invalid table or identifier field name.');
|
|
56
43
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
const explicit = identifierField !== undefined;
|
|
45
|
+
const firstField = identifierField || (isSysId(identifier) ? 'sys_id' : matchField);
|
|
46
|
+
const attempts = [];
|
|
47
|
+
async function lookup(field, operator, mode) {
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
sysparm_query: `${field}${operator}${identifier}`,
|
|
50
|
+
sysparm_limit: '2', sysparm_display_value: displayValue,
|
|
51
|
+
});
|
|
52
|
+
if (fields?.length) params.set('sysparm_fields', [...new Set(['sys_id', ...fields])].join(','));
|
|
53
|
+
const records = await sdk.list(table, params);
|
|
54
|
+
const resolution = { table, field, mode, matches: Math.min(records.length, 2),
|
|
55
|
+
...(records.length >= 2 ? { at_least: true } : {}) };
|
|
56
|
+
attempts.push(resolution);
|
|
57
|
+
if (records.length > 1) {
|
|
58
|
+
const error = new AppError(CodeAmbiguous, `Multiple ${resource || 'records'} found matching ${mode} ${field}: ${identifier}`,
|
|
59
|
+
'Use a longer/full sys_id or select the exact human field with --identifier-field <field>.');
|
|
60
|
+
error.resolution = { ...resolution, attempts };
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return records[0];
|
|
60
64
|
}
|
|
61
|
-
|
|
65
|
+
let record = await lookup(firstField, '=', 'exact');
|
|
66
|
+
if (record) return record;
|
|
67
|
+
if (!explicit && firstField !== 'sys_id') {
|
|
68
|
+
record = await lookup('sys_id', '=', 'exact');
|
|
69
|
+
if (record) return record;
|
|
70
|
+
}
|
|
71
|
+
// An explicitly selected human field must never fall back to another field.
|
|
72
|
+
if (partial && (!explicit || firstField === 'sys_id')) {
|
|
73
|
+
record = await lookup('sys_id', 'STARTSWITH', 'prefix');
|
|
74
|
+
if (record) return record;
|
|
75
|
+
record = await lookup('sys_id', 'ENDSWITH', 'suffix');
|
|
76
|
+
if (record) return record;
|
|
77
|
+
}
|
|
78
|
+
const error = errNotFound(resource || matchField, identifier);
|
|
79
|
+
error.hint = 'No accessible record matched. Check the table/ACLs, supply a full or longer sys_id, or use --identifier-field <field> for an exact human identifier.';
|
|
80
|
+
error.resolution = { ...attempts.at(-1), attempts };
|
|
81
|
+
throw error;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/**
|
|
@@ -67,5 +87,7 @@ export async function resolveRecord(sdk, { table, identifier, matchField, resour
|
|
|
67
87
|
*/
|
|
68
88
|
export async function resolveSysId(sdk, opts) {
|
|
69
89
|
const record = await resolveRecord(sdk, { fields: ['sys_id'], ...opts });
|
|
70
|
-
|
|
90
|
+
const id = unwrapSysId(record);
|
|
91
|
+
if (!id) throw errUsage('The matched record has no readable sys_id; check field ACLs before retrying.');
|
|
92
|
+
return id;
|
|
71
93
|
}
|