@jacebenson/jsn 4.3.13-dev.82.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/related-lists.js +147 -0
- package/src/resolve-record.js +50 -28
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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Opt-in related-list counts. Counts never fetch supplementary records; hints
|
|
2
|
+
// leave opening the list to an explicit subsequent command.
|
|
3
|
+
import { assertSafeExactMatch, quoteShellArg } from './helpers.js';
|
|
4
|
+
import { errUsage } from './errors.js';
|
|
5
|
+
const namePattern = /^[A-Za-z0-9_]+$/;
|
|
6
|
+
const isName = value => typeof value === 'string' && namePattern.test(value);
|
|
7
|
+
const raw = value => String(value && typeof value === 'object' ? value.value ?? '' : value ?? '');
|
|
8
|
+
|
|
9
|
+
/** Only a conservative, AND-only literal encoded-query subset can be combined
|
|
10
|
+
* with a record reference. Unknown platform/dynamic logic stays unavailable. */
|
|
11
|
+
export function safeRelatedFilter(value = '') {
|
|
12
|
+
if (typeof value !== 'string' || /javascript\s*:|\r|\n/i.test(value)) return null;
|
|
13
|
+
if (!value) return '';
|
|
14
|
+
const clauses = value.split('^');
|
|
15
|
+
if (clauses.at(-1) === 'EQ') clauses.pop();
|
|
16
|
+
if (!clauses.length) return '';
|
|
17
|
+
for (const clause of clauses) {
|
|
18
|
+
if (!clause || /^(?:OR|NQ|ORDERBY)/i.test(clause)) return null;
|
|
19
|
+
if (!/^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*(?:(?:!=|>=|<=|=|>|<|NOT LIKE|STARTSWITH|ENDSWITH|LIKE|NOT IN|IN)[^^]*|ISEMPTY|ISNOTEMPTY)$/.test(clause)) return null;
|
|
20
|
+
}
|
|
21
|
+
return clauses.join('^');
|
|
22
|
+
}
|
|
23
|
+
function parseCount(body) {
|
|
24
|
+
const result = body?.result;
|
|
25
|
+
const entry = Array.isArray(result) && result.length === 1 && !result[0]?.groupby_fields?.length
|
|
26
|
+
? result[0] : result;
|
|
27
|
+
let stats = entry?.stats;
|
|
28
|
+
if (typeof stats === 'string') { try { stats = JSON.parse(stats); } catch { return null; } }
|
|
29
|
+
const count = stats?.count;
|
|
30
|
+
if (typeof count !== 'number' && (typeof count !== 'string' || !/^\d+$/.test(count))) return null;
|
|
31
|
+
const number = Number(count);
|
|
32
|
+
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
|
33
|
+
}
|
|
34
|
+
function hint(table, query, { profile, instance }) {
|
|
35
|
+
const tokens = ['jsn', 'records', 'list', '--table', table, '--query', query];
|
|
36
|
+
if (profile) tokens.push('--profile', profile);
|
|
37
|
+
if (instance) tokens.push('--instance', instance);
|
|
38
|
+
return tokens.map(quoteShellArg).join(' ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Invocation-bound reader. Metadata/aggregate failures are coverage gaps, not
|
|
42
|
+
* zero counts. Optional batch owns endpoint fallback; items never replay. */
|
|
43
|
+
export function createRelatedListReader(sdk, { metadataReader, maxLists = 50 } = {}) {
|
|
44
|
+
if (!Number.isInteger(maxLists) || maxLists < 1 || maxLists > 1000) throw errUsage('Related-list limit must be an integer from 1 to 1000.');
|
|
45
|
+
const cache = new Map();
|
|
46
|
+
let reader = metadataReader;
|
|
47
|
+
async function read({ table, recordId, view = 'Default view', profile = '', instance = '' }) {
|
|
48
|
+
if (!isName(table)) throw errUsage('Related-list table must be a valid table name.');
|
|
49
|
+
const id = raw(recordId);
|
|
50
|
+
if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) throw errUsage('Related-list counts require a safe, resolved record sys_id.');
|
|
51
|
+
assertSafeExactMatch(id);
|
|
52
|
+
if (!reader) {
|
|
53
|
+
const { createRecordMetadataReader } = await import('./metadata/records.js');
|
|
54
|
+
reader = createRecordMetadataReader(sdk);
|
|
55
|
+
}
|
|
56
|
+
const metadata = await reader.read({ table, view });
|
|
57
|
+
const warnings = [...(metadata.coverage?.warnings || [])];
|
|
58
|
+
const descriptors = metadata.related_lists || [];
|
|
59
|
+
const prepared = descriptors.map((list, index) => {
|
|
60
|
+
const result = { id: list.id, label: list.label || list.id, table: list.table || null,
|
|
61
|
+
reference_field: list.reference_field || null, configured: !!list.configured,
|
|
62
|
+
status: 'unavailable', count: null };
|
|
63
|
+
let reason;
|
|
64
|
+
const filter = safeRelatedFilter(list.filter || '');
|
|
65
|
+
if (list.query_status !== 'reference' || !isName(list.table) || !isName(list.reference_field)) reason = list.reason || 'Related list requires platform relationship logic or unavailable metadata.';
|
|
66
|
+
else if (list.configured && list.filter_available === false) reason = 'Configured related-list filter is not visible; count was not requested.';
|
|
67
|
+
else if (filter === null) reason = 'Related-list filter uses unsupported dynamic or disjunctive query logic; count was not requested.';
|
|
68
|
+
if (reason) {
|
|
69
|
+
result.reason = reason; warnings.push({ source: list.id, reason }); return { result };
|
|
70
|
+
}
|
|
71
|
+
const query = `${list.reference_field}=${id}${filter ? `^${filter}` : ''}`;
|
|
72
|
+
if (index >= maxLists) {
|
|
73
|
+
const reason = `Related-list limit ${maxLists} reached; count was not requested. Open this specific list explicitly or raise --related-limit.`;
|
|
74
|
+
warnings.push({ source: list.id, reason });
|
|
75
|
+
return { result: { ...result, reason, query, command: hint(list.table, query, { profile, instance }) } };
|
|
76
|
+
}
|
|
77
|
+
const params = new URLSearchParams({ sysparm_count: 'true', sysparm_query: query });
|
|
78
|
+
const url = `/api/now/stats/${list.table}?${params}`;
|
|
79
|
+
return { result, query, url };
|
|
80
|
+
});
|
|
81
|
+
const unique = [...new Set(prepared.filter(item => item.url && !cache.has(item.url)).map(item => item.url))];
|
|
82
|
+
if (unique.length) {
|
|
83
|
+
const pending = (async () => {
|
|
84
|
+
if (typeof sdk.batch === 'function' && unique.length > 1) {
|
|
85
|
+
try {
|
|
86
|
+
const batch = await sdk.batch(unique.map((url, index) => ({ id: `related-${index}`, method: 'GET', url })), { fallback: true });
|
|
87
|
+
return unique.map((_, index) => {
|
|
88
|
+
const item = batch.results?.find(value => value.id === `related-${index}`);
|
|
89
|
+
if (!item?.ok) return { count: null, status: item?.status || null,
|
|
90
|
+
reason: item?.error?.message || 'Aggregate subrequest was unavailable or unserviced.' };
|
|
91
|
+
const count = parseCount(item.data);
|
|
92
|
+
return { count, status: item.status, ...(count === null ? { reason: 'Aggregate response did not contain a valid count.' } : {}) };
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return unique.map(() => ({ count: null, status: error.status || null, reason: error.message || 'Aggregate batch failed.' }));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const individual = async url => {
|
|
99
|
+
try {
|
|
100
|
+
const body = await sdk.request(`${sdk.baseURL}${url}`, { method: 'GET', readOnly: true });
|
|
101
|
+
const count = parseCount(body);
|
|
102
|
+
return { count, ...(count === null ? { reason: 'Aggregate response did not contain a valid count.' } : {}) };
|
|
103
|
+
} catch (error) { return { count: null, status: error.status || null, reason: error.message || 'Aggregate request failed.' }; }
|
|
104
|
+
};
|
|
105
|
+
const results = new Array(unique.length);
|
|
106
|
+
let next = 0;
|
|
107
|
+
await Promise.all(Array.from({ length: Math.min(4, unique.length) }, async () => {
|
|
108
|
+
while (next < unique.length) {
|
|
109
|
+
const index = next++;
|
|
110
|
+
results[index] = await individual(unique[index]);
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
return results;
|
|
114
|
+
})();
|
|
115
|
+
unique.forEach((url, index) => cache.set(url, pending.then(items => items[index])));
|
|
116
|
+
}
|
|
117
|
+
const lists = await Promise.all(prepared.map(async item => {
|
|
118
|
+
if (!item.url) return item.result;
|
|
119
|
+
const observed = await cache.get(item.url);
|
|
120
|
+
if (observed.count === null) {
|
|
121
|
+
warnings.push({ source: item.result.id, reason: observed.reason, ...(observed.status ? { status: observed.status } : {}) });
|
|
122
|
+
return { ...item.result, reason: observed.reason, ...(observed.status ? { http_status: observed.status } : {}) };
|
|
123
|
+
}
|
|
124
|
+
return { ...item.result, status: 'available', count: observed.count, query: item.query,
|
|
125
|
+
...(observed.count > 0 ? { command: hint(item.result.table, item.query, { profile, instance }) } : {}) };
|
|
126
|
+
}));
|
|
127
|
+
return { table, record_id: id, view, lists, nonempty: lists.filter(list => list.count > 0),
|
|
128
|
+
coverage: { state: metadata.coverage?.state === 'partial' || warnings.length ? 'partial' : 'observed',
|
|
129
|
+
scope: 'aggregate_api_access', warnings } };
|
|
130
|
+
}
|
|
131
|
+
return { read, clear() { cache.clear(); reader?.clear?.(); } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Specialized show hook; ordinary shows and generic records get do no work. */
|
|
135
|
+
export async function enrichRelatedLists(record, { table, argv = {}, app }) {
|
|
136
|
+
if (!argv.related) return record;
|
|
137
|
+
const limit = argv['related-limit'] ?? 50;
|
|
138
|
+
if (!app.relatedListReader || app._relatedListReaderSDK !== app.sdk || (app._relatedListReaderLimit !== undefined && app._relatedListReaderLimit !== limit)) {
|
|
139
|
+
app.relatedListReader = createRelatedListReader(app.sdk, { maxLists: limit });
|
|
140
|
+
app._relatedListReaderSDK = app.sdk;
|
|
141
|
+
app._relatedListReaderLimit = limit;
|
|
142
|
+
}
|
|
143
|
+
record._related = await app.relatedListReader.read({ table, recordId: record.sys_id,
|
|
144
|
+
view: argv.view || 'Default view', profile: app.session?.profileName || argv.profile || '',
|
|
145
|
+
instance: app.getEffectiveInstance?.() || '' });
|
|
146
|
+
return record;
|
|
147
|
+
}
|