@jacebenson/jsn 4.3.13-dev.83.1 → 4.3.13-dev.84.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 +22 -20
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +21 -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 +7 -15
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +18 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/errors.js +7 -0
- package/src/essential-records.js +40 -0
- package/src/output.js +15 -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/resolve-record.js +50 -28
package/package.json
CHANGED
package/src/commands/_generic.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Generic table-based CRUD command builder
|
|
2
2
|
|
|
3
3
|
import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
5
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
5
6
|
import { getCurrentUser, getCurrentApplication } from '../context.js';
|
|
6
7
|
import { FormatAuto } from '../output.js';
|
|
7
8
|
import { declareCapabilities } from '../capabilities.js';
|
|
@@ -120,7 +121,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
120
121
|
command: 'list',
|
|
121
122
|
aliases: ['ls'],
|
|
122
123
|
describe: `List ${name}`,
|
|
123
|
-
builder: (y) => y
|
|
124
|
+
builder: (y) => recordOptions(y)
|
|
124
125
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
|
|
125
126
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
|
|
126
127
|
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
@@ -147,7 +148,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
147
148
|
const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
|
|
148
149
|
record._context = { instance_url: app.getEffectiveInstance(), table };
|
|
149
150
|
if (opts.onShow) {
|
|
150
|
-
await opts.onShow(record, app);
|
|
151
|
+
await opts.onShow(record, app, argv);
|
|
151
152
|
}
|
|
152
153
|
app.ok(record, {
|
|
153
154
|
summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
|
|
@@ -160,19 +161,15 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
// 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
164
|
const baseQ = extraQuery ? extraQuery + '^' : '';
|
|
168
165
|
const q = query ? query + '^ORDERBYDESCsys_updated_on' : (baseQ + 'ORDERBYDESCsys_updated_on');
|
|
169
|
-
params
|
|
170
|
-
const records = await app.sdk
|
|
166
|
+
const params = recordListParams({ limit, fields: ['sys_id', ...columns], query: q });
|
|
167
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
|
|
171
168
|
app.ok({
|
|
172
169
|
table,
|
|
173
170
|
count: records.length,
|
|
174
171
|
columns,
|
|
175
|
-
records: records.map(r => formatRecordForDisplay(r, columns)),
|
|
172
|
+
records: argv.displayValues || opts.listProjection === 'full' ? records : records.map(r => formatRecordForDisplay(r, columns)),
|
|
176
173
|
context: { instance_url: app.getEffectiveInstance() },
|
|
177
174
|
}, {
|
|
178
175
|
summary: `${records.length} ${name}(s)`,
|
|
@@ -184,6 +181,11 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
184
181
|
command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
|
|
185
182
|
aliases: ['get'],
|
|
186
183
|
describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
|
|
184
|
+
builder: y => {
|
|
185
|
+
const command = recordOptions(y);
|
|
186
|
+
return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true,
|
|
187
|
+
describe: 'Fetch profile-declared essential children; --no-essential skips them. Supplementary lists remain lazy.' }) : command;
|
|
188
|
+
},
|
|
187
189
|
handler: wrap(async (argv, app) => {
|
|
188
190
|
const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
|
|
189
191
|
if (id === undefined) return;
|
|
@@ -192,14 +194,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
192
194
|
const fields = (showFields && showFields.length > 0)
|
|
193
195
|
? [...new Set(['sys_id', ...showFields])]
|
|
194
196
|
: undefined;
|
|
195
|
-
const record = await
|
|
197
|
+
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
198
|
record._context = {
|
|
197
199
|
instance_url: app.getEffectiveInstance(),
|
|
198
200
|
table,
|
|
199
201
|
};
|
|
200
202
|
|
|
201
203
|
if (opts.onShow) {
|
|
202
|
-
await opts.onShow(record, app);
|
|
204
|
+
await opts.onShow(record, app, argv);
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
const summary = typeof showSummary === 'function' ? showSummary(record, id) : showSummary;
|
|
@@ -214,14 +216,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
214
216
|
.command({
|
|
215
217
|
command: 'create',
|
|
216
218
|
describe: `Create a new ${singular}`,
|
|
217
|
-
builder: (y) => y
|
|
219
|
+
builder: (y) => recordOptions(y, false)
|
|
218
220
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
219
221
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
220
222
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
221
223
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
222
224
|
handler: wrap(async (argv, app) => {
|
|
223
225
|
const recordData = buildRecordPayload(argv);
|
|
224
|
-
const record = await app.sdk
|
|
226
|
+
const record = await createRecord(app.sdk, { table, data: recordData });
|
|
225
227
|
app.ok(record, {
|
|
226
228
|
summary: `Created ${singular}`,
|
|
227
229
|
breadcrumbs: [
|
|
@@ -233,31 +235,31 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
233
235
|
.command({
|
|
234
236
|
command: 'update <identifier>',
|
|
235
237
|
describe: `Update ${vowelArticle(singular)} ${singular}`,
|
|
236
|
-
builder: (y) => y
|
|
238
|
+
builder: (y) => recordOptions(y, false)
|
|
237
239
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
238
240
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
239
241
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
240
242
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
241
243
|
handler: wrap(async (argv, app) => {
|
|
242
244
|
const id = argv.identifier;
|
|
243
|
-
const record = await
|
|
245
|
+
const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
|
|
244
246
|
if (scopeValidation) {
|
|
245
247
|
await guardScope(app.sdk, record);
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
const sysID = unwrapSysId(record);
|
|
249
251
|
const recordData = buildRecordPayload(argv);
|
|
250
|
-
const updated = await app.sdk
|
|
252
|
+
const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
|
|
251
253
|
app.ok(updated, { summary: `Updated ${singular} ${id}` });
|
|
252
254
|
}),
|
|
253
255
|
})
|
|
254
256
|
.command({
|
|
255
257
|
command: 'delete <identifier>',
|
|
256
258
|
describe: `Delete ${vowelArticle(singular)} ${singular}`,
|
|
257
|
-
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
259
|
+
builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
258
260
|
handler: wrap(async (argv, app) => {
|
|
259
261
|
const id = argv.identifier;
|
|
260
|
-
const record = await
|
|
262
|
+
const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
|
|
261
263
|
if (scopeValidation) {
|
|
262
264
|
await guardScope(app.sdk, record);
|
|
263
265
|
}
|
|
@@ -267,7 +269,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
267
269
|
|
|
268
270
|
await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
|
|
269
271
|
|
|
270
|
-
await app.sdk
|
|
272
|
+
await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
|
|
271
273
|
app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
|
|
272
274
|
}),
|
|
273
275
|
});
|
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
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
3
|
|
|
4
4
|
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
6
|
+
import { resolveSysId } from '../resolve-record.js';
|
|
6
7
|
import { FormatAuto } from '../output.js';
|
|
7
8
|
import { declareCapabilities } from '../capabilities.js';
|
|
8
9
|
|
|
9
|
-
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
|
|
10
|
+
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap, opts = {}) {
|
|
10
11
|
// Ticket-style CRUD: list/show are reads; create/update/delete mutate.
|
|
11
12
|
// Root-level command only.
|
|
12
13
|
declareCapabilities(displayName, { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
@@ -21,7 +22,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
21
22
|
command: 'list',
|
|
22
23
|
aliases: ['ls'],
|
|
23
24
|
describe: `List ${table}`,
|
|
24
|
-
builder: (y) => y
|
|
25
|
+
builder: (y) => recordOptions(y)
|
|
25
26
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
26
27
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
27
28
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
@@ -81,17 +82,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
81
82
|
return;
|
|
82
83
|
}
|
|
83
84
|
|
|
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
85
|
const fields = resolveFieldsParam(columns);
|
|
89
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
90
86
|
const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
91
|
-
params
|
|
87
|
+
const params = recordListParams({ limit, offset, fields, query: q });
|
|
92
88
|
|
|
93
|
-
const records = await app.sdk
|
|
94
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
89
|
+
const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
|
|
90
|
+
const displayRecords = fields && !argv.displayValues && opts.listProjection !== 'full' ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
95
91
|
|
|
96
92
|
const breadcrumbs = [
|
|
97
93
|
{ action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
|
|
@@ -130,9 +126,14 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
130
126
|
command: 'show <number>',
|
|
131
127
|
aliases: ['get'],
|
|
132
128
|
describe: `Show a specific ${displayName}`,
|
|
129
|
+
builder: y => {
|
|
130
|
+
const command = recordOptions(y);
|
|
131
|
+
return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true, describe: 'Fetch declared essential children; use --no-essential to skip' }) : command;
|
|
132
|
+
},
|
|
133
133
|
handler: wrap(async (argv, app) => {
|
|
134
134
|
const number = argv.number;
|
|
135
|
-
const record = await
|
|
135
|
+
const record = await getRecord(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
136
|
+
if (opts.onShow) await opts.onShow(record, app, argv);
|
|
136
137
|
record._context = {
|
|
137
138
|
instance_url: app.getEffectiveInstance(),
|
|
138
139
|
table,
|
|
@@ -149,7 +150,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
149
150
|
.command({
|
|
150
151
|
command: 'create',
|
|
151
152
|
describe: `Create a new ${displayName}`,
|
|
152
|
-
builder: (y) => y
|
|
153
|
+
builder: (y) => recordOptions(y, false)
|
|
153
154
|
.option('description', { alias: 'd', type: 'string', describe: 'Short description' })
|
|
154
155
|
.option('priority', { type: 'string', describe: 'Priority (1-5)' })
|
|
155
156
|
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
@@ -163,7 +164,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
163
164
|
if (!recordData.short_description) {
|
|
164
165
|
throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
|
|
165
166
|
}
|
|
166
|
-
const record = await app.sdk
|
|
167
|
+
const record = await createRecord(app.sdk, { table, data: recordData });
|
|
167
168
|
app.ok(record, {
|
|
168
169
|
summary: `Created ${displayName} ${getStringField(record, 'number')}`,
|
|
169
170
|
breadcrumbs: [
|
|
@@ -175,7 +176,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
175
176
|
.command({
|
|
176
177
|
command: 'update <number>',
|
|
177
178
|
describe: `Update a ${displayName}`,
|
|
178
|
-
builder: (y) => y
|
|
179
|
+
builder: (y) => recordOptions(y, false)
|
|
179
180
|
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
180
181
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
181
182
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
@@ -183,8 +184,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
183
184
|
handler: wrap(async (argv, app) => {
|
|
184
185
|
const number = argv.number;
|
|
185
186
|
const recordData = buildRecordPayload(argv);
|
|
186
|
-
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
187
|
-
const updated = await app.sdk
|
|
187
|
+
const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
188
|
+
const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
|
|
188
189
|
app.ok(updated, {
|
|
189
190
|
summary: `Updated ${displayName} ${number}`,
|
|
190
191
|
breadcrumbs: [
|
|
@@ -196,12 +197,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
196
197
|
.command({
|
|
197
198
|
command: 'delete <number>',
|
|
198
199
|
describe: `Delete a ${displayName}`,
|
|
199
|
-
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
200
|
+
builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
200
201
|
handler: wrap(async (argv, app) => {
|
|
201
202
|
const number = argv.number;
|
|
202
203
|
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
|
|
204
|
+
const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
|
|
205
|
+
await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
|
|
205
206
|
app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
|
|
206
207
|
summary: `Deleted ${displayName} ${number}`,
|
|
207
208
|
});
|
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,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { getRecord, recordOptions } from '../records-engine.js';
|
|
2
|
+
import { unwrapSysId } from '../resolve-record.js';
|
|
3
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
3
4
|
|
|
4
|
-
const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
|
|
5
5
|
|
|
6
6
|
export function requestsCmd(wrap) {
|
|
7
7
|
// Get the base command definition from the shared ticket builder
|
|
8
|
-
const baseCmd =
|
|
8
|
+
const baseCmd = buildProfileCommand('requests', wrap);
|
|
9
9
|
|
|
10
10
|
// Override the builder to inject our enriched show handler.
|
|
11
11
|
// yargs allows re-registration — the last .command() call wins the slot.
|
|
@@ -16,6 +16,7 @@ export function requestsCmd(wrap) {
|
|
|
16
16
|
command: 'show <number>',
|
|
17
17
|
aliases: ['get'],
|
|
18
18
|
describe: 'Show a specific request with attachments and catalog variables',
|
|
19
|
+
builder: y => recordOptions(y),
|
|
19
20
|
handler: wrap(enrichedShowHandler),
|
|
20
21
|
});
|
|
21
22
|
};
|
|
@@ -25,17 +26,8 @@ export function requestsCmd(wrap) {
|
|
|
25
26
|
|
|
26
27
|
async function enrichedShowHandler(argv, app) {
|
|
27
28
|
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');
|
|
29
|
+
const record = await getRecord(app.sdk, { table: 'sc_req_item', identifier: number, matchField: 'number', identifierField: argv.identifierField, displayValues: argv.displayValues, resource: 'Request' });
|
|
30
|
+
const sysID = unwrapSysId(record);
|
|
39
31
|
|
|
40
32
|
// Fetch attachments and catalog variables in parallel
|
|
41
33
|
const [attachments, variables] = await Promise.all([
|
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,6 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
|
|
2
|
-
import {
|
|
1
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload, confirmDelete } from '../helpers.js';
|
|
2
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
3
|
+
import { resolveSysId } from '../resolve-record.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
|
|
5
6
|
declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
@@ -15,23 +16,18 @@ export function ticketsCmd(wrap) {
|
|
|
15
16
|
command: 'list',
|
|
16
17
|
aliases: ['ls'],
|
|
17
18
|
describe: 'List tickets',
|
|
18
|
-
builder: (y) => y
|
|
19
|
+
builder: (y) => recordOptions(y)
|
|
19
20
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
|
|
20
21
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
|
|
21
22
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
22
23
|
.option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
|
|
23
24
|
handler: wrap(async (argv, app) => {
|
|
24
25
|
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
26
|
const fields = resolveFieldsParam(columns);
|
|
30
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
31
27
|
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;
|
|
28
|
+
const params = recordListParams({ limit: argv.limit, offset: argv.offset, fields, query: q });
|
|
29
|
+
const records = await listRecords(app.sdk, { table: 'task', params, displayValues: argv.displayValues });
|
|
30
|
+
const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
35
31
|
const breadcrumbs = [
|
|
36
32
|
{ action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
|
|
37
33
|
];
|
|
@@ -63,46 +59,49 @@ export function ticketsCmd(wrap) {
|
|
|
63
59
|
command: 'show <number>',
|
|
64
60
|
aliases: ['get'],
|
|
65
61
|
describe: 'Show a ticket',
|
|
62
|
+
builder: y => recordOptions(y),
|
|
66
63
|
handler: wrap(async (argv, app) => {
|
|
67
|
-
const record = await
|
|
64
|
+
const record = await getRecord(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
68
65
|
app.ok(record, { summary: `Ticket ${argv.number}` });
|
|
69
66
|
}),
|
|
70
67
|
})
|
|
71
68
|
.command({
|
|
72
69
|
command: 'create',
|
|
73
70
|
describe: 'Create a new ticket',
|
|
74
|
-
builder: (y) => y
|
|
71
|
+
builder: (y) => recordOptions(y, false)
|
|
75
72
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
76
73
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
77
74
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
78
75
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
79
76
|
handler: wrap(async (argv, app) => {
|
|
80
77
|
const recordData = buildRecordPayload(argv);
|
|
81
|
-
const record = await app.sdk
|
|
78
|
+
const record = await createRecord(app.sdk, { table: 'task', data: recordData });
|
|
82
79
|
app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
|
|
83
80
|
}),
|
|
84
81
|
})
|
|
85
82
|
.command({
|
|
86
83
|
command: 'update <number>',
|
|
87
84
|
describe: 'Update a ticket',
|
|
88
|
-
builder: (y) => y
|
|
85
|
+
builder: (y) => recordOptions(y, false)
|
|
89
86
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
90
87
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
91
88
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
92
89
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
93
90
|
handler: wrap(async (argv, app) => {
|
|
94
91
|
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
|
|
92
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
93
|
+
const updated = await updateRecord(app.sdk, { table: 'task', record: { sys_id: sysID }, data: recordData });
|
|
97
94
|
app.ok(updated, { summary: `Updated ticket ${argv.number}` });
|
|
98
95
|
}),
|
|
99
96
|
})
|
|
100
97
|
.command({
|
|
101
98
|
command: 'delete <number>',
|
|
102
99
|
describe: 'Delete a ticket',
|
|
100
|
+
builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
|
|
103
101
|
handler: wrap(async (argv, app) => {
|
|
104
|
-
|
|
105
|
-
await app.sdk
|
|
102
|
+
await confirmDelete(app, argv, `Delete ticket ${argv.number}`);
|
|
103
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
104
|
+
await deleteRecord(app.sdk, { table: 'task', record: { sys_id: sysID } });
|
|
106
105
|
app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
|
|
107
106
|
}),
|
|
108
107
|
})
|
|
@@ -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/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,20 @@ 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
|
+
|
|
517
532
|
// ── Attachments ──
|
|
518
533
|
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
519
534
|
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/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
|
}
|