@jacebenson/jsn 4.3.12-dev.70 → 4.3.13-dev.100.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/package.json +2 -1
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +12 -0
- package/src/cli.js +39 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +54 -104
- package/src/commands/requests.js +9 -15
- package/src/commands/rest.js +15 -2
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +65 -13
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +10 -2
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Local convenience history, independent of documentation indexes and sync.
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { errUsage } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const operations = ['create', 'update', 'delete'];
|
|
10
|
+
const outcomes = ['succeeded', 'failed', 'unknown'];
|
|
11
|
+
export function historyPath() {
|
|
12
|
+
return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'history.db');
|
|
13
|
+
}
|
|
14
|
+
function openHistory(filename, now) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
16
|
+
const db = new Database(filename);
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
|
|
19
|
+
db.pragma('busy_timeout = 3000');
|
|
20
|
+
db.exec(`CREATE TABLE IF NOT EXISTS mutation_history (
|
|
21
|
+
id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, instance TEXT NOT NULL,
|
|
22
|
+
profile TEXT, table_name TEXT NOT NULL, sys_id TEXT, operation TEXT NOT NULL,
|
|
23
|
+
outcome TEXT NOT NULL, reason TEXT, http_status INTEGER
|
|
24
|
+
); CREATE INDEX IF NOT EXISTS mutation_history_time ON mutation_history(timestamp)`);
|
|
25
|
+
db.prepare('DELETE FROM mutation_history WHERE timestamp < ?').run(now - RETENTION_MS);
|
|
26
|
+
return db;
|
|
27
|
+
} catch (error) { db.close(); throw error; }
|
|
28
|
+
}
|
|
29
|
+
export function sanitizeReason(value) {
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
// eslint-disable-next-line no-control-regex -- strip controls from user-supplied prose
|
|
32
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
33
|
+
.replace(/\b(?:Bearer|Basic)\s+\S+/gi, '[redacted]')
|
|
34
|
+
.replace(/\b(password|token|cookie|secret|authorization|access_token|sysparm_ck)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$1=[redacted]')
|
|
35
|
+
.slice(0, 1000);
|
|
36
|
+
}
|
|
37
|
+
export function appendMutation(event, { filename = historyPath(), now = Date.now() } = {}) {
|
|
38
|
+
if (!operations.includes(event.operation) || !outcomes.includes(event.outcome)) throw errUsage('Invalid history event');
|
|
39
|
+
const instance = new URL(event.instance).origin;
|
|
40
|
+
// Deliberate whitelist: never serialize SDK options, response bodies, or payloads.
|
|
41
|
+
const row = { timestamp: now, instance, profile: event.profile || null,
|
|
42
|
+
table_name: event.table, sys_id: event.sys_id || null, operation: event.operation,
|
|
43
|
+
outcome: event.outcome, reason: sanitizeReason(event.reason), http_status: event.http_status || null };
|
|
44
|
+
const db = openHistory(filename, now);
|
|
45
|
+
try {
|
|
46
|
+
db.prepare(`INSERT INTO mutation_history
|
|
47
|
+
(timestamp,instance,profile,table_name,sys_id,operation,outcome,reason,http_status)
|
|
48
|
+
VALUES (@timestamp,@instance,@profile,@table_name,@sys_id,@operation,@outcome,@reason,@http_status)`).run(row);
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
}
|
|
51
|
+
export function pruneHistory({ filename = historyPath(), now = Date.now() } = {}) {
|
|
52
|
+
const db = openHistory(filename, now);
|
|
53
|
+
try { return { retentionDays: 7 }; } finally { db.close(); }
|
|
54
|
+
}
|
|
55
|
+
export function listHistory(filters = {}, { filename = historyPath(), now = Date.now() } = {}) {
|
|
56
|
+
const where = [], values = [];
|
|
57
|
+
for (const [key, column] of Object.entries({ instance: 'instance', profile: 'profile', table: 'table_name', operation: 'operation', record: 'sys_id' })) {
|
|
58
|
+
if (filters[key]) { where.push(`${column} = ?`); values.push(key === 'instance' ? new URL(filters[key]).origin : filters[key]); }
|
|
59
|
+
}
|
|
60
|
+
for (const [key, operator] of [['since', '>='], ['until', '<=']]) {
|
|
61
|
+
if (filters[key]) {
|
|
62
|
+
const time = Date.parse(filters[key]);
|
|
63
|
+
if (!Number.isFinite(time)) throw errUsage(`Invalid --${key} timestamp; use an ISO date`);
|
|
64
|
+
where.push(`timestamp ${operator} ?`); values.push(time);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const limit = filters.limit ?? 50;
|
|
68
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) throw errUsage('--limit must be an integer from 1 to 10000');
|
|
69
|
+
const db = openHistory(filename, now);
|
|
70
|
+
try {
|
|
71
|
+
return db.prepare(`SELECT id,timestamp,instance,profile,table_name AS "table",sys_id,operation,outcome,reason,http_status
|
|
72
|
+
FROM mutation_history ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY timestamp DESC,id DESC LIMIT ?`)
|
|
73
|
+
.all(...values, limit).map(row => ({ ...row, timestamp: new Date(row.timestamp).toISOString() }));
|
|
74
|
+
} finally { db.close(); }
|
|
75
|
+
}
|
package/src/mutations.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// create` (read-only bypass), and listed `restmethods`, a command that was
|
|
9
9
|
// never registered. Derived data can't drift that way.
|
|
10
10
|
|
|
11
|
-
import { mutationPaths } from './capabilities.js';
|
|
11
|
+
import { mutationPaths, isLocalPreview } from './capabilities.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Back-compat export: the derived mutation path list, refreshed by
|
|
@@ -33,7 +33,7 @@ function currentPaths(paths) {
|
|
|
33
33
|
* Prefix match: a command whose argv._ path begins with a mutation pattern
|
|
34
34
|
* IS that mutation (trailing positionals like a sys_id or file path don't
|
|
35
35
|
* change the intent). This lets positional mutation subcommands (e.g.
|
|
36
|
-
* `
|
|
36
|
+
* `attachments upload <file>`) be gated on read-only profiles.
|
|
37
37
|
*
|
|
38
38
|
* @param {object} argv — yargs parsed argv with `_` array
|
|
39
39
|
* @param {Array<string[]>} [paths] — mutation path patterns; defaults to
|
|
@@ -42,6 +42,7 @@ function currentPaths(paths) {
|
|
|
42
42
|
* @returns {boolean}
|
|
43
43
|
*/
|
|
44
44
|
export function isMutationCommand(argv, paths) {
|
|
45
|
+
if (isLocalPreview(argv)) return false;
|
|
45
46
|
const cmd = argv._ || [];
|
|
46
47
|
for (const pattern of currentPaths(paths)) {
|
|
47
48
|
if (pattern.length > cmd.length) continue;
|
package/src/output.js
CHANGED
|
@@ -160,6 +160,13 @@ 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;
|
|
164
|
+
if (e.context) resp.context = e.context;
|
|
165
|
+
if (e.status === 401 || e.status === 403) resp.status = e.status;
|
|
166
|
+
if (e.recovery) {
|
|
167
|
+
resp.recovery = e.recovery;
|
|
168
|
+
if (Object.hasOwn(e, 'profile')) resp.profile = e.profile;
|
|
169
|
+
}
|
|
163
170
|
switch (format) {
|
|
164
171
|
case FormatJSON:
|
|
165
172
|
case FormatQuiet:
|
|
@@ -282,7 +289,7 @@ export class OutputWriter {
|
|
|
282
289
|
writeStyled(data, opts) {
|
|
283
290
|
// ── The `_formatted` interface ──────────────────────────────────────
|
|
284
291
|
// Commands may ship a pre-rendered styled view as `data._formatted`
|
|
285
|
-
// (a plain string). The writer
|
|
292
|
+
// (a plain string). The writer ensures a trailing newline and suppresses both
|
|
286
293
|
// opts.summary and the records table / field-dump renderers — the
|
|
287
294
|
// curated visual IS the output. JSON/quiet/markdown/csv formats ignore
|
|
288
295
|
// `_formatted` entirely and serialize the full envelope as usual, so
|
|
@@ -309,7 +316,7 @@ export class OutputWriter {
|
|
|
309
316
|
}
|
|
310
317
|
} else if (data && typeof data === 'object') {
|
|
311
318
|
if (data._formatted) {
|
|
312
|
-
this.writer.write(data._formatted);
|
|
319
|
+
this.writer.write(data._formatted.endsWith('\n') ? data._formatted : data._formatted + '\n');
|
|
313
320
|
}
|
|
314
321
|
|
|
315
322
|
const { isRecord, tableName } = detectRecord(data);
|
|
@@ -508,6 +515,31 @@ export class OutputWriter {
|
|
|
508
515
|
this.writer.write('\n');
|
|
509
516
|
}
|
|
510
517
|
|
|
518
|
+
if (data._essential && typeof data._essential === 'object') {
|
|
519
|
+
for (const [name, related] of Object.entries(data._essential)) {
|
|
520
|
+
this.writer.write(`─ Essential: ${name} (${related.loaded} loaded${related.has_more ? ', more available' : ''}) ─\n`);
|
|
521
|
+
if (related.status !== 'loaded') this.writer.write(` Unavailable: ${related.error?.message || 'Read could not be completed.'}\n`);
|
|
522
|
+
for (const row of related.records || []) {
|
|
523
|
+
const fields = Object.entries(row).filter(([field]) => field !== 'sys_id').map(([field, value]) => `${field}: ${getDisplayValue(value)}`);
|
|
524
|
+
this.writer.write(` ${fields.join(' | ')}\n`);
|
|
525
|
+
}
|
|
526
|
+
this.writer.write(` ${related.visibility}\n`);
|
|
527
|
+
if (related.command) this.writer.write(` Retrieve: ${related.command}\n`);
|
|
528
|
+
this.writer.write('\n');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (data._related && typeof data._related === 'object') {
|
|
533
|
+
this.writer.write('─ Related lists ─\n');
|
|
534
|
+
for (const list of data._related.nonempty || []) {
|
|
535
|
+
this.writer.write(` ${list.label}: ${list.count}\n`);
|
|
536
|
+
if (list.command) this.writer.write(` Retrieve: ${list.command}\n`);
|
|
537
|
+
}
|
|
538
|
+
this.writer.write(` Coverage: ${data._related.coverage?.state || 'unknown'}; counts reflect aggregate API access and visible metadata.\n`);
|
|
539
|
+
for (const warning of data._related.coverage?.warnings || []) this.writer.write(` Unknown (${warning.source}): ${warning.reason}\n`);
|
|
540
|
+
this.writer.write('\n');
|
|
541
|
+
}
|
|
542
|
+
|
|
511
543
|
// ── Attachments ──
|
|
512
544
|
if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
|
|
513
545
|
this.writer.write('─ Attachments ─\n');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Edit table/ticket command defaults here. The key is the CLI command name.
|
|
2
|
+
// Identifier/output defaults and empty relationship policies live in record-profiles.js.
|
|
3
|
+
export default {
|
|
4
|
+
includes: { table: 'sys_script_include', aliases: ['include', 'si'], columns: ['name', 'api_name', 'active', 'sys_scope'],
|
|
5
|
+
options: { singular: 'script include', scopeValidation: true } },
|
|
6
|
+
rules: { table: 'sys_script', aliases: ['rule', 'br'], columns: ['name', 'collection', 'active', 'order', 'sys_scope'],
|
|
7
|
+
options: { singular: 'business rule', scopeValidation: true } },
|
|
8
|
+
clientscripts: { table: 'sys_script_client', aliases: ['clientscript', 'cs'], columns: ['name', 'table', 'active', 'type', 'sys_scope'],
|
|
9
|
+
options: { singular: 'client script', scopeValidation: true } },
|
|
10
|
+
uiactions: { table: 'sys_ui_action', aliases: ['uiaction', 'ua'], columns: ['name', 'table', 'active', 'order', 'sys_scope'],
|
|
11
|
+
options: { singular: 'UI action', scopeValidation: true } },
|
|
12
|
+
sppages: { table: 'sp_page', aliases: ['sp-pages'], columns: ['id', 'title', 'sys_scope'],
|
|
13
|
+
options: { singular: 'Service Portal page', readOnly: true } },
|
|
14
|
+
spwidgets: { table: 'sp_widget', aliases: ['sp-widget', 'widgets'], columns: ['id', 'name', 'sys_scope'],
|
|
15
|
+
options: { singular: 'Service Portal widget', readOnly: true } },
|
|
16
|
+
uipages: { table: 'sys_ui_page', aliases: ['ui-page', 'pages'], columns: ['name', 'sys_scope'],
|
|
17
|
+
options: { singular: 'UI page', scopeValidation: true } },
|
|
18
|
+
appmenu: { table: 'sys_app_application', aliases: ['app-menu', 'menu'], columns: ['name', 'active', 'sys_scope'],
|
|
19
|
+
options: { singular: 'application menu', scopeValidation: true } },
|
|
20
|
+
acls: { table: 'sys_security_acl', aliases: ['acl'], columns: ['name', 'operation', 'type', 'active', 'sys_scope'],
|
|
21
|
+
options: { singular: 'ACL', scopeValidation: true, help: 'Investigate table, field, wildcard and inherited ACL names for the relevant operation. Exact name=incident alone does not cover incident.caller_id, incident.*, *.caller_id or inherited-table ACLs. Roles, conditions and scripts affect effective access; rows alone do not prove access. See jsn columns --help for field investigation paths.' } },
|
|
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,93 @@
|
|
|
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
|
+
command.discovery.benefits.push(
|
|
88
|
+
`Reference output: ${profile.output.references}; list projection: ${profile.output.listProjection}`,
|
|
89
|
+
...profile.relationships.essential.map(relationship =>
|
|
90
|
+
`Essential ${relationship.name} records from ${relationship.table} on show (disable with --no-essential)`),
|
|
91
|
+
);
|
|
92
|
+
return command;
|
|
93
|
+
}
|
|
@@ -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,153 @@
|
|
|
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
|
+
}
|
|
148
|
+
|
|
149
|
+
export function relatedListOptions(y) {
|
|
150
|
+
return y.option('related', { type: 'boolean', default: false, describe: 'Count related lists under Aggregate API access; suggest commands without loading supplementary records' })
|
|
151
|
+
.option('view', { type: 'string', describe: 'Form view for --related metadata (default: Default view)' })
|
|
152
|
+
.option('related-limit', { type: 'number', describe: 'Max related-list descriptors to count with --related (default 50)' });
|
|
153
|
+
}
|