@jacebenson/jsn 4.3.13-dev.83.1 → 4.3.13-dev.85.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.83.1",
3
+ "version": "4.3.13-dev.85.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,9 @@
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
1
2
  // Generic table-based CRUD command builder
2
3
 
3
4
  import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
- import { resolveRecord, unwrapSysId } from '../resolve-record.js';
5
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
6
+ import { unwrapSysId } from '../resolve-record.js';
5
7
  import { getCurrentUser, getCurrentApplication } from '../context.js';
6
8
  import { FormatAuto } from '../output.js';
7
9
  import { declareCapabilities } from '../capabilities.js';
@@ -120,7 +122,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
120
122
  command: 'list',
121
123
  aliases: ['ls'],
122
124
  describe: `List ${name}`,
123
- builder: (y) => y
125
+ builder: (y) => recordOptions(y)
124
126
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
125
127
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
126
128
  .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
@@ -147,7 +149,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
147
149
  const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
148
150
  record._context = { instance_url: app.getEffectiveInstance(), table };
149
151
  if (opts.onShow) {
150
- await opts.onShow(record, app);
152
+ await opts.onShow(record, app, argv);
151
153
  }
152
154
  app.ok(record, {
153
155
  summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
@@ -160,19 +162,15 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
160
162
  }
161
163
 
162
164
  // Non-interactive: text/table output
163
- const params = new URLSearchParams();
164
- params.set('sysparm_limit', String(limit));
165
- params.set('sysparm_display_value', 'all');
166
- params.set('sysparm_fields', ['sys_id', ...columns].join(','));
167
165
  const baseQ = extraQuery ? extraQuery + '^' : '';
168
166
  const q = query ? query + '^ORDERBYDESCsys_updated_on' : (baseQ + 'ORDERBYDESCsys_updated_on');
169
- params.set('sysparm_query', q);
170
- const records = await app.sdk.list(table, params);
167
+ const params = recordListParams({ limit, fields: ['sys_id', ...columns], query: q });
168
+ const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
171
169
  app.ok({
172
170
  table,
173
171
  count: records.length,
174
172
  columns,
175
- records: records.map(r => formatRecordForDisplay(r, columns)),
173
+ records: argv.displayValues || opts.listProjection === 'full' ? records : records.map(r => formatRecordForDisplay(r, columns)),
176
174
  context: { instance_url: app.getEffectiveInstance() },
177
175
  }, {
178
176
  summary: `${records.length} ${name}(s)`,
@@ -184,6 +182,11 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
184
182
  command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
185
183
  aliases: ['get'],
186
184
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
185
+ builder: y => {
186
+ const command = relatedListOptions(recordOptions(y));
187
+ return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true,
188
+ describe: 'Fetch profile-declared essential children; --no-essential skips them. Supplementary lists remain lazy.' }) : command;
189
+ },
187
190
  handler: wrap(async (argv, app) => {
188
191
  const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
189
192
  if (id === undefined) return;
@@ -192,16 +195,17 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
192
195
  const fields = (showFields && showFields.length > 0)
193
196
  ? [...new Set(['sys_id', ...showFields])]
194
197
  : undefined;
195
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular, fields });
198
+ const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular, fields });
196
199
  record._context = {
197
200
  instance_url: app.getEffectiveInstance(),
198
201
  table,
199
202
  };
200
203
 
201
204
  if (opts.onShow) {
202
- await opts.onShow(record, app);
205
+ await opts.onShow(record, app, argv);
203
206
  }
204
207
 
208
+ await enrichRelatedLists(record, { table, argv, app });
205
209
  const summary = typeof showSummary === 'function' ? showSummary(record, id) : showSummary;
206
210
  const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(record, id) : showBreadcrumbs;
207
211
 
@@ -214,14 +218,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
214
218
  .command({
215
219
  command: 'create',
216
220
  describe: `Create a new ${singular}`,
217
- builder: (y) => y
221
+ builder: (y) => recordOptions(y, false)
218
222
  .option('data', { type: 'string', describe: 'JSON fields' })
219
223
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
220
224
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
221
225
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
222
226
  handler: wrap(async (argv, app) => {
223
227
  const recordData = buildRecordPayload(argv);
224
- const record = await app.sdk.create(table, recordData);
228
+ const record = await createRecord(app.sdk, { table, data: recordData });
225
229
  app.ok(record, {
226
230
  summary: `Created ${singular}`,
227
231
  breadcrumbs: [
@@ -233,31 +237,31 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
233
237
  .command({
234
238
  command: 'update <identifier>',
235
239
  describe: `Update ${vowelArticle(singular)} ${singular}`,
236
- builder: (y) => y
240
+ builder: (y) => recordOptions(y, false)
237
241
  .option('data', { type: 'string', describe: 'JSON fields' })
238
242
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
239
243
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
240
244
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
241
245
  handler: wrap(async (argv, app) => {
242
246
  const id = argv.identifier;
243
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
247
+ const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
244
248
  if (scopeValidation) {
245
249
  await guardScope(app.sdk, record);
246
250
  }
247
251
 
248
252
  const sysID = unwrapSysId(record);
249
253
  const recordData = buildRecordPayload(argv);
250
- const updated = await app.sdk.update(table, sysID, recordData);
254
+ const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
251
255
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
252
256
  }),
253
257
  })
254
258
  .command({
255
259
  command: 'delete <identifier>',
256
260
  describe: `Delete ${vowelArticle(singular)} ${singular}`,
257
- builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
261
+ builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
258
262
  handler: wrap(async (argv, app) => {
259
263
  const id = argv.identifier;
260
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
264
+ const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
261
265
  if (scopeValidation) {
262
266
  await guardScope(app.sdk, record);
263
267
  }
@@ -267,7 +271,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
267
271
 
268
272
  await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
269
273
 
270
- await app.sdk.delete(table, sysID);
274
+ await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
271
275
  app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
272
276
  }),
273
277
  });
@@ -1,4 +1,4 @@
1
- import { buildDevCmd } from './_generic.js';
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) => buildDevCmd('includes', 'sys_script_include', ['include', 'si'], ['name', 'api_name', 'active', 'sys_scope'], wrap, { singular: 'script include', scopeValidation: true });
50
+ export const includesCmd = (wrap) => buildProfileCommand('includes', wrap);
51
51
 
52
- export const rulesCmd = (wrap) => buildDevCmd('rules', 'sys_script', ['rule', 'br'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'business rule', scopeValidation: true });
52
+ export const rulesCmd = (wrap) => buildProfileCommand('rules', wrap);
53
53
 
54
- export const clientScriptsCmd = (wrap) => buildDevCmd('clientscripts', 'sys_script_client', ['clientscript', 'cs'], ['name', 'table', 'active', 'type', 'sys_scope'], wrap, { singular: 'client script', scopeValidation: true });
54
+ export const clientScriptsCmd = (wrap) => buildProfileCommand('clientscripts', wrap);
55
55
 
56
- export const uiActionsCmd = (wrap) => buildDevCmd('uiactions', 'sys_ui_action', ['uiaction', 'ua'], ['name', 'table', 'active', 'order', 'sys_scope'], wrap, { singular: 'UI action', scopeValidation: true });
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) => buildDevCmd('tables', 'sys_db_object', ['table', 't'], ['name', 'label', 'super_class', 'create_access_controls'], 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) => buildDevCmd('sppages', 'sp_page', ['sp-pages'], ['id', 'title', 'sys_scope'], wrap, { singular: 'Service Portal page', readOnly: true });
85
- export const spWidgetsCmd = (wrap) => buildDevCmd('spwidgets', 'sp_widget', ['sp-widget', 'widgets'], ['id', 'name', 'sys_scope'], wrap, { singular: 'Service Portal widget', readOnly: true });
86
- export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', scopeValidation: true });
87
- export const appMenuCmd = (wrap) => buildDevCmd('appmenu', 'sys_app_application', ['app-menu', 'menu'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application menu', scopeValidation: true });
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) => buildDevCmd('acls', 'sys_security_acl', ['acl'], ['name', 'operation', 'type', 'active', 'sys_scope'], wrap, { singular: 'ACL', scopeValidation: true });
91
- export const rolesCmd = (wrap) => buildDevCmd('roles', 'sys_user_role', ['role', 'r'], ['name', 'description', 'elevated_privilege', 'sys_scope'], wrap, { singular: 'role', scopeValidation: true });
92
- export const propertiesCmd = (wrap) => buildDevCmd('properties', 'sys_properties', ['property', 'prop'], ['name', 'value', 'description', 'sys_scope'], wrap, { singular: 'property', scopeValidation: true });
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) => buildDevCmd('relationships', 'sys_relationship', ['relationship', 'rel'], ['name', 'sys_scope'], wrap, { singular: 'relationship', scopeValidation: true });
96
- export const appmodulesCmd = (wrap) => buildDevCmd('appmodules', 'sys_app_module', ['appmodule', 'am'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application module', scopeValidation: true });
97
- export const listcontrolsCmd = (wrap) => buildDevCmd('listcontrols', 'sys_ui_list_control', ['listcontrol', 'lc'], ['name', 'active', 'sys_scope'], wrap, { singular: 'list control', scopeValidation: true });
98
- export const viewsCmd = (wrap) => buildDevCmd('views', 'sys_ui_view', ['view', 'vw'], ['name', 'title', 'sys_scope'], wrap, { singular: 'view', scopeValidation: true });
99
- export const privilegesCmd = (wrap) => buildDevCmd('privileges', 'sys_scope_privilege', ['privilege', 'priv'], ['source_scope', 'target_scope', 'target_name', 'operation', 'status'], wrap, { singular: 'privilege', scopeValidation: true });
100
- export const uxscriptsCmd = (wrap) => buildDevCmd('uxscripts', 'sys_ux_lib_source_script', ['uxscript', 'ux'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UX script', scopeValidation: true });
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) => buildDevCmd('catalogscripts', 'catalog_script_client', ['catalogscript'], ['name', 'table', 'active', 'sys_scope'], wrap, { singular: 'catalog script', scopeValidation: true });
99
+ export const catalogscriptsCmd = (wrap) => buildProfileCommand('catalogscripts', wrap);
103
100
 
104
101
  // ── Automation: Async ──
105
- export const scriptactionsCmd = (wrap) => buildDevCmd('scriptactions', 'sysevent_script_action', ['scriptaction'], ['name', 'active', 'sys_scope'], wrap, { singular: 'script action', scopeValidation: true });
106
- export const scheduledjobsCmd = (wrap) => buildDevCmd('scheduledjobs', 'sysauto_script', ['scheduledjob'], ['name', 'active', 'run_type', 'sys_scope'], wrap, { singular: 'scheduled job', scopeValidation: true });
107
- export const asyncrulesCmd = (wrap) => buildDevCmd('asyncrules', 'sys_script', ['asyncrule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'async business rule', scopeValidation: true, extraQuery: 'when=async' });
108
- export const triggersCmd = (wrap) => buildDevCmd('triggers', 'sysevent_register', ['trigger'], ['name', 'event_name', 'active', 'sys_scope'], wrap, { singular: 'event trigger', scopeValidation: true });
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) => buildDevCmd('assignments', 'sysrule_assignment', ['assignment'], ['name', 'active', 'sys_scope'], wrap, { singular: 'assignment rule', scopeValidation: true, readOnly: true });
109
+ export const assignmentsCmd = (wrap) => buildProfileCommand('assignments', wrap);
113
110
 
114
111
  // ── Automation: Inbound ──
115
- export const emailCmd = (wrap) => buildDevCmd('email', 'sysevent_in_email_action', ['emails'], ['name', 'active', 'type', 'sys_scope'], wrap, { singular: 'inbound email action', scopeValidation: true });
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) => buildDevCmd('uimacros', 'sys_ui_macro', ['uimacro'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UI macro', scopeValidation: true });
119
+ export const uimacrosCmd = (wrap) => buildProfileCommand('uimacros', wrap);
123
120
 
124
121
  // ── UX: Workspaces ──
125
- export const uxlistsCmd = (wrap) => buildDevCmd('uxlists', 'sys_ux_list', ['uxlist'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace list', scopeValidation: true, readOnly: true });
126
- export const uxapplicabilityCmd = (wrap) => buildDevCmd('uxapplicability', 'sys_ux_applicability_m2m', ['uxapp'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace applicability', scopeValidation: true, readOnly: true });
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) => buildDevCmd('cataloguipolicies', 'catalog_ui_policy', ['cataloguipolicy', 'cup'], ['short_description', 'catalog_item', 'variable_set', 'active', 'sys_scope'], wrap, { singular: 'catalog UI policy', scopeValidation: true });
125
+ export const cataloguipoliciesCmd = (wrap) => buildProfileCommand('cataloguipolicies', wrap);
@@ -1,12 +1,14 @@
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
1
2
  // Generic command builder for CRUD operations on a ServiceNow table
2
3
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
4
 
4
5
  import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
5
- import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
7
+ import { resolveSysId } from '../resolve-record.js';
6
8
  import { FormatAuto } from '../output.js';
7
9
  import { declareCapabilities } from '../capabilities.js';
8
10
 
9
- export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
11
+ export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap, opts = {}) {
10
12
  // Ticket-style CRUD: list/show are reads; create/update/delete mutate.
11
13
  // Root-level command only.
12
14
  declareCapabilities(displayName, { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -21,7 +23,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
21
23
  command: 'list',
22
24
  aliases: ['ls'],
23
25
  describe: `List ${table}`,
24
- builder: (y) => y
26
+ builder: (y) => recordOptions(y)
25
27
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
26
28
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
27
29
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
@@ -81,17 +83,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
81
83
  return;
82
84
  }
83
85
 
84
- const params = new URLSearchParams();
85
- params.set('sysparm_limit', String(limit));
86
- params.set('sysparm_offset', String(offset));
87
- params.set('sysparm_display_value', 'all');
88
86
  const fields = resolveFieldsParam(columns);
89
- if (fields) params.set('sysparm_fields', fields);
90
87
  const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
91
- params.set('sysparm_query', q);
88
+ const params = recordListParams({ limit, offset, fields, query: q });
92
89
 
93
- const records = await app.sdk.list(table, params);
94
- const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
90
+ const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
91
+ const displayRecords = fields && !argv.displayValues && opts.listProjection !== 'full' ? records.map(r => formatRecordForDisplay(r, columns)) : records;
95
92
 
96
93
  const breadcrumbs = [
97
94
  { action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
@@ -130,9 +127,15 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
130
127
  command: 'show <number>',
131
128
  aliases: ['get'],
132
129
  describe: `Show a specific ${displayName}`,
130
+ builder: y => {
131
+ const command = relatedListOptions(recordOptions(y));
132
+ return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true, describe: 'Fetch declared essential children; use --no-essential to skip' }) : command;
133
+ },
133
134
  handler: wrap(async (argv, app) => {
134
135
  const number = argv.number;
135
- const record = await resolveRecord(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
136
+ const record = await getRecord(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
137
+ if (opts.onShow) await opts.onShow(record, app, argv);
138
+ await enrichRelatedLists(record, { table, argv, app });
136
139
  record._context = {
137
140
  instance_url: app.getEffectiveInstance(),
138
141
  table,
@@ -149,7 +152,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
149
152
  .command({
150
153
  command: 'create',
151
154
  describe: `Create a new ${displayName}`,
152
- builder: (y) => y
155
+ builder: (y) => recordOptions(y, false)
153
156
  .option('description', { alias: 'd', type: 'string', describe: 'Short description' })
154
157
  .option('priority', { type: 'string', describe: 'Priority (1-5)' })
155
158
  .option('data', { type: 'string', describe: 'JSON data for additional fields' })
@@ -163,7 +166,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
163
166
  if (!recordData.short_description) {
164
167
  throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
165
168
  }
166
- const record = await app.sdk.create(table, recordData);
169
+ const record = await createRecord(app.sdk, { table, data: recordData });
167
170
  app.ok(record, {
168
171
  summary: `Created ${displayName} ${getStringField(record, 'number')}`,
169
172
  breadcrumbs: [
@@ -175,7 +178,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
175
178
  .command({
176
179
  command: 'update <number>',
177
180
  describe: `Update a ${displayName}`,
178
- builder: (y) => y
181
+ builder: (y) => recordOptions(y, false)
179
182
  .option('data', { type: 'string', describe: 'JSON data to update' })
180
183
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
181
184
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
@@ -183,8 +186,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
183
186
  handler: wrap(async (argv, app) => {
184
187
  const number = argv.number;
185
188
  const recordData = buildRecordPayload(argv);
186
- const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
187
- const updated = await app.sdk.update(table, sysID, recordData);
189
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
190
+ const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
188
191
  app.ok(updated, {
189
192
  summary: `Updated ${displayName} ${number}`,
190
193
  breadcrumbs: [
@@ -196,12 +199,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
196
199
  .command({
197
200
  command: 'delete <number>',
198
201
  describe: `Delete a ${displayName}`,
199
- builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
202
+ builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
200
203
  handler: wrap(async (argv, app) => {
201
204
  const number = argv.number;
202
205
  await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
203
- const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
204
- await app.sdk.delete(table, sysID);
206
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
207
+ await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
205
208
  app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
206
209
  summary: `Deleted ${displayName} ${number}`,
207
210
  });
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
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 buildTicketCommands('change_request', 'changes', 'chg', changeDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('changes', wrap);
7
5
  }
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
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 buildTicketCommands('incident', 'incidents', 'inc', incidentDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('incidents', wrap);
7
5
  }
@@ -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, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
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
- if (fields) params.set('sysparm_fields', fields);
139
- if (query) params.set('sysparm_query', query);
140
- const records = await app.sdk.list(table, params);
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 a single record by sys_id',
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', demandOption: true, describe: 'Record sys_id' })
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('Example: jsn records get --table incident --sys-id <sys_id> --json'),
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
- assertSafeExactMatch(argv['sys-id']);
202
- const params = new URLSearchParams();
203
- params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
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(argv['sys-id']);
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.create(argv.table, recordData);
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', demandOption: true, describe: 'Record sys_id' })
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 record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
274
- const mismatches = await verifyWriteBack(app, argv.table, argv['sys-id'], recordData);
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', demandOption: true, describe: 'Record sys_id' })
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
- await confirmDelete(app, argv, `Delete record from ${argv.table} (${argv['sys-id']})`);
293
- await app.sdk.delete(argv.table, argv['sys-id']);
294
- app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
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' })
@@ -1,11 +1,12 @@
1
- import { buildTicketCommands } from './_ticket.js';
2
- import { getStringField, assertSafeExactMatch } from '../helpers.js';
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
2
+ import { getRecord, recordOptions } from '../records-engine.js';
3
+ import { unwrapSysId } from '../resolve-record.js';
4
+ import { buildProfileCommand } from '../record-profiles.js';
3
5
 
4
- const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
5
6
 
6
7
  export function requestsCmd(wrap) {
7
8
  // Get the base command definition from the shared ticket builder
8
- const baseCmd = buildTicketCommands('sc_req_item', 'requests', 'req', requestDefaultColumns, {}, null, wrap);
9
+ const baseCmd = buildProfileCommand('requests', wrap);
9
10
 
10
11
  // Override the builder to inject our enriched show handler.
11
12
  // yargs allows re-registration — the last .command() call wins the slot.
@@ -16,6 +17,7 @@ export function requestsCmd(wrap) {
16
17
  command: 'show <number>',
17
18
  aliases: ['get'],
18
19
  describe: 'Show a specific request with attachments and catalog variables',
20
+ builder: y => relatedListOptions(recordOptions(y)),
19
21
  handler: wrap(enrichedShowHandler),
20
22
  });
21
23
  };
@@ -25,17 +27,8 @@ export function requestsCmd(wrap) {
25
27
 
26
28
  async function enrichedShowHandler(argv, app) {
27
29
  const number = argv.number;
28
- assertSafeExactMatch(number);
29
- const params = new URLSearchParams();
30
- params.set('sysparm_query', `number=${number}`);
31
- params.set('sysparm_display_value', 'all');
32
- params.set('sysparm_limit', '1');
33
- const records = await app.sdk.list('sc_req_item', params);
34
- if (records.length === 0) {
35
- throw new Error(`Request not found: ${number}`);
36
- }
37
- const record = records[0];
38
- const sysID = getStringField(record, 'sys_id');
30
+ const record = await getRecord(app.sdk, { table: 'sc_req_item', identifier: number, matchField: 'number', identifierField: argv.identifierField, displayValues: argv.displayValues, resource: 'Request' });
31
+ const sysID = unwrapSysId(record);
39
32
 
40
33
  // Fetch attachments and catalog variables in parallel
41
34
  const [attachments, variables] = await Promise.all([
@@ -50,6 +43,7 @@ async function enrichedShowHandler(argv, app) {
50
43
  table: 'sc_req_item',
51
44
  };
52
45
 
46
+ await enrichRelatedLists(record, { table: 'sc_req_item', argv, app });
53
47
  app.ok(record, {
54
48
  summary: `Request ${number}`,
55
49
  breadcrumbs: [
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
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 buildTicketCommands('sc_task', 'tasks', 'task', taskDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('tasks', wrap);
7
5
  }
@@ -1,5 +1,7 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
- import { resolveRecord, resolveSysId } from '../resolve-record.js';
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
2
+ import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload, confirmDelete } from '../helpers.js';
3
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
4
+ import { resolveSysId } from '../resolve-record.js';
3
5
  import { declareCapabilities } from '../capabilities.js';
4
6
 
5
7
  declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -15,23 +17,18 @@ export function ticketsCmd(wrap) {
15
17
  command: 'list',
16
18
  aliases: ['ls'],
17
19
  describe: 'List tickets',
18
- builder: (y) => y
20
+ builder: (y) => recordOptions(y)
19
21
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
20
22
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
21
23
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
22
24
  .option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
23
25
  handler: wrap(async (argv, app) => {
24
26
  const columns = argv.columns ? argv.columns.split(',') : ['number', 'short_description', 'state', 'assigned_to'];
25
- const params = new URLSearchParams();
26
- params.set('sysparm_limit', String(argv.limit));
27
- params.set('sysparm_offset', String(argv.offset));
28
- params.set('sysparm_display_value', 'all');
29
27
  const fields = resolveFieldsParam(columns);
30
- if (fields) params.set('sysparm_fields', fields);
31
28
  const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
32
- params.set('sysparm_query', q);
33
- const records = await app.sdk.list('task', params);
34
- const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
29
+ const params = recordListParams({ limit: argv.limit, offset: argv.offset, fields, query: q });
30
+ const records = await listRecords(app.sdk, { table: 'task', params, displayValues: argv.displayValues });
31
+ const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
35
32
  const breadcrumbs = [
36
33
  { action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
37
34
  ];
@@ -63,46 +60,50 @@ export function ticketsCmd(wrap) {
63
60
  command: 'show <number>',
64
61
  aliases: ['get'],
65
62
  describe: 'Show a ticket',
63
+ builder: y => relatedListOptions(recordOptions(y)),
66
64
  handler: wrap(async (argv, app) => {
67
- const record = await resolveRecord(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
65
+ const record = await getRecord(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
66
+ await enrichRelatedLists(record, { table: 'task', argv, app });
68
67
  app.ok(record, { summary: `Ticket ${argv.number}` });
69
68
  }),
70
69
  })
71
70
  .command({
72
71
  command: 'create',
73
72
  describe: 'Create a new ticket',
74
- builder: (y) => y
73
+ builder: (y) => recordOptions(y, false)
75
74
  .option('data', { type: 'string', describe: 'JSON fields' })
76
75
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
77
76
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
78
77
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
79
78
  handler: wrap(async (argv, app) => {
80
79
  const recordData = buildRecordPayload(argv);
81
- const record = await app.sdk.create('task', recordData);
80
+ const record = await createRecord(app.sdk, { table: 'task', data: recordData });
82
81
  app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
83
82
  }),
84
83
  })
85
84
  .command({
86
85
  command: 'update <number>',
87
86
  describe: 'Update a ticket',
88
- builder: (y) => y
87
+ builder: (y) => recordOptions(y, false)
89
88
  .option('data', { type: 'string', describe: 'JSON fields' })
90
89
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
91
90
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
92
91
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
93
92
  handler: wrap(async (argv, app) => {
94
93
  const recordData = buildRecordPayload(argv);
95
- const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
96
- const updated = await app.sdk.update('task', sysID, recordData);
94
+ const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
95
+ const updated = await updateRecord(app.sdk, { table: 'task', record: { sys_id: sysID }, data: recordData });
97
96
  app.ok(updated, { summary: `Updated ticket ${argv.number}` });
98
97
  }),
99
98
  })
100
99
  .command({
101
100
  command: 'delete <number>',
102
101
  describe: 'Delete a ticket',
102
+ builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
103
103
  handler: wrap(async (argv, app) => {
104
- const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
105
- await app.sdk.delete('task', sysID);
104
+ await confirmDelete(app, argv, `Delete ticket ${argv.number}`);
105
+ const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
106
+ await deleteRecord(app.sdk, { table: 'task', record: { sys_id: sysID } });
106
107
  app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
107
108
  }),
108
109
  })
@@ -1,28 +1,3 @@
1
- import { buildDevCmd } from './_generic.js';
2
- import { getStringField } from '../helpers.js';
1
+ import { buildProfileCommand } from '../record-profiles.js';
3
2
 
4
- export const uipoliciesCmd = (wrap) => buildDevCmd('uipolicies', 'sys_ui_policy', ['uipolicy', 'up'],
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
+ }
@@ -237,7 +237,11 @@ function buildRelationships({ entries, dictionary, relationships, sourceTable, n
237
237
  const existing = result.get(id);
238
238
  const descriptor = existing || { id, kind: 'unresolved', table: '', reference_field: '', query_status: 'unavailable',
239
239
  reason: 'Configured related list metadata is absent, unsupported, or not visible' };
240
- result.set(id, { ...descriptor, configured: true, source_table: sourceTable, position: position(entry),
240
+ const filter = entry.filter;
241
+ const filterAvailable = Boolean(Object.hasOwn(entry, 'filter') && (filter === null || typeof filter === 'string'
242
+ || (filter && typeof filter === 'object' && Object.hasOwn(filter, 'value') && (filter.value === null || typeof filter.value === 'string'))));
243
+ if (!filterAvailable) warnings.push({ source: 'sys_ui_related_list_entry', reason: 'A configured filter is absent or not visible; do not assume an unfiltered list' });
244
+ result.set(id, { ...descriptor, configured: true, filter_available: filterAvailable, source_table: sourceTable, position: position(entry),
241
245
  filter: field(entry, 'filter'), order_by: field(entry, 'order_by') });
242
246
  if (!existing) warnings.push({ source: 'sys_ui_related_list_entry', reason: descriptor.reason });
243
247
  }
package/src/output.js CHANGED
@@ -160,6 +160,7 @@ export class OutputWriter {
160
160
  const format = this.effectiveFormat();
161
161
  const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
162
162
  const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
163
+ if (e.resolution) resp.resolution = e.resolution;
163
164
  if (e.context) resp.context = e.context;
164
165
  if (e.status === 401 || e.status === 403) resp.status = e.status;
165
166
  if (e.recovery) {
@@ -514,6 +515,31 @@ export class OutputWriter {
514
515
  this.writer.write('\n');
515
516
  }
516
517
 
518
+ if (data._essential && typeof data._essential === 'object') {
519
+ for (const [name, related] of Object.entries(data._essential)) {
520
+ this.writer.write(`─ Essential: ${name} (${related.loaded} loaded${related.has_more ? ', more available' : ''}) ─\n`);
521
+ if (related.status !== 'loaded') this.writer.write(` Unavailable: ${related.error?.message || 'Read could not be completed.'}\n`);
522
+ for (const row of related.records || []) {
523
+ const fields = Object.entries(row).filter(([field]) => field !== 'sys_id').map(([field, value]) => `${field}: ${getDisplayValue(value)}`);
524
+ this.writer.write(` ${fields.join(' | ')}\n`);
525
+ }
526
+ this.writer.write(` ${related.visibility}\n`);
527
+ if (related.command) this.writer.write(` Retrieve: ${related.command}\n`);
528
+ this.writer.write('\n');
529
+ }
530
+ }
531
+
532
+ if (data._related && typeof data._related === 'object') {
533
+ this.writer.write('─ Related lists ─\n');
534
+ for (const list of data._related.nonempty || []) {
535
+ this.writer.write(` ${list.label}: ${list.count}\n`);
536
+ if (list.command) this.writer.write(` Retrieve: ${list.command}\n`);
537
+ }
538
+ this.writer.write(` Coverage: ${data._related.coverage?.state || 'unknown'}; counts reflect aggregate API access and visible metadata.\n`);
539
+ for (const warning of data._related.coverage?.warnings || []) this.writer.write(` Unknown (${warning.source}): ${warning.reason}\n`);
540
+ this.writer.write('\n');
541
+ }
542
+
517
543
  // ── Attachments ──
518
544
  if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
519
545
  this.writer.write('─ Attachments ─\n');
@@ -0,0 +1,70 @@
1
+ // Edit table/ticket command defaults here. The key is the CLI command name.
2
+ // Identifier/output defaults and empty relationship policies live in record-profiles.js.
3
+ export default {
4
+ includes: { table: 'sys_script_include', aliases: ['include', 'si'], columns: ['name', 'api_name', 'active', 'sys_scope'],
5
+ options: { singular: 'script include', scopeValidation: true } },
6
+ rules: { table: 'sys_script', aliases: ['rule', 'br'], columns: ['name', 'collection', 'active', 'order', 'sys_scope'],
7
+ options: { singular: 'business rule', scopeValidation: true } },
8
+ clientscripts: { table: 'sys_script_client', aliases: ['clientscript', 'cs'], columns: ['name', 'table', 'active', 'type', 'sys_scope'],
9
+ options: { singular: 'client script', scopeValidation: true } },
10
+ uiactions: { table: 'sys_ui_action', aliases: ['uiaction', 'ua'], columns: ['name', 'table', 'active', 'order', 'sys_scope'],
11
+ options: { singular: 'UI action', scopeValidation: true } },
12
+ sppages: { table: 'sp_page', aliases: ['sp-pages'], columns: ['id', 'title', 'sys_scope'],
13
+ options: { singular: 'Service Portal page', readOnly: true } },
14
+ spwidgets: { table: 'sp_widget', aliases: ['sp-widget', 'widgets'], columns: ['id', 'name', 'sys_scope'],
15
+ options: { singular: 'Service Portal widget', readOnly: true } },
16
+ uipages: { table: 'sys_ui_page', aliases: ['ui-page', 'pages'], columns: ['name', 'sys_scope'],
17
+ options: { singular: 'UI page', scopeValidation: true } },
18
+ appmenu: { table: 'sys_app_application', aliases: ['app-menu', 'menu'], columns: ['name', 'active', 'sys_scope'],
19
+ options: { singular: 'application menu', scopeValidation: true } },
20
+ acls: { table: 'sys_security_acl', aliases: ['acl'], columns: ['name', 'operation', 'type', 'active', 'sys_scope'],
21
+ options: { singular: 'ACL', scopeValidation: true } },
22
+ roles: { table: 'sys_user_role', aliases: ['role', 'r'], columns: ['name', 'description', 'elevated_privilege', 'sys_scope'],
23
+ options: { singular: 'role', scopeValidation: true } },
24
+ properties: { table: 'sys_properties', aliases: ['property', 'prop'], columns: ['name', 'value', 'description', 'sys_scope'],
25
+ options: { singular: 'property', scopeValidation: true } },
26
+ relationships: { table: 'sys_relationship', aliases: ['relationship', 'rel'], columns: ['name', 'sys_scope'],
27
+ options: { singular: 'relationship', scopeValidation: true } },
28
+ appmodules: { table: 'sys_app_module', aliases: ['appmodule', 'am'], columns: ['name', 'active', 'sys_scope'],
29
+ options: { singular: 'application module', scopeValidation: true } },
30
+ listcontrols: { table: 'sys_ui_list_control', aliases: ['listcontrol', 'lc'], columns: ['name', 'active', 'sys_scope'],
31
+ options: { singular: 'list control', scopeValidation: true } },
32
+ views: { table: 'sys_ui_view', aliases: ['view', 'vw'], columns: ['name', 'title', 'sys_scope'],
33
+ options: { singular: 'view', scopeValidation: true } },
34
+ privileges: { table: 'sys_scope_privilege', aliases: ['privilege', 'priv'], columns: ['source_scope', 'target_scope', 'target_name', 'operation', 'status'],
35
+ options: { singular: 'privilege', scopeValidation: true } },
36
+ uxscripts: { table: 'sys_ux_lib_source_script', aliases: ['uxscript', 'ux'], columns: ['name', 'active', 'sys_scope'],
37
+ options: { singular: 'UX script', scopeValidation: true } },
38
+ catalogscripts: { table: 'catalog_script_client', aliases: ['catalogscript'], columns: ['name', 'table', 'active', 'sys_scope'],
39
+ options: { singular: 'catalog script', scopeValidation: true } },
40
+ scriptactions: { table: 'sysevent_script_action', aliases: ['scriptaction'], columns: ['name', 'active', 'sys_scope'],
41
+ options: { singular: 'script action', scopeValidation: true } },
42
+ scheduledjobs: { table: 'sysauto_script', aliases: ['scheduledjob'], columns: ['name', 'active', 'run_type', 'sys_scope'],
43
+ options: { singular: 'scheduled job', scopeValidation: true } },
44
+ asyncrules: { table: 'sys_script', aliases: ['asyncrule'], columns: ['name', 'collection', 'active', 'order', 'sys_scope'],
45
+ options: { singular: 'async business rule', scopeValidation: true, extraQuery: 'when=async' } },
46
+ triggers: { table: 'sysevent_register', aliases: ['trigger'], columns: ['name', 'event_name', 'active', 'sys_scope'],
47
+ options: { singular: 'event trigger', scopeValidation: true } },
48
+ assignments: { table: 'sysrule_assignment', aliases: ['assignment'], columns: ['name', 'active', 'sys_scope'],
49
+ options: { singular: 'assignment rule', scopeValidation: true, readOnly: true } },
50
+ email: { table: 'sysevent_in_email_action', aliases: ['emails'], columns: ['name', 'active', 'type', 'sys_scope'],
51
+ options: { singular: 'inbound email action', scopeValidation: true } },
52
+ uimacros: { table: 'sys_ui_macro', aliases: ['uimacro'], columns: ['name', 'active', 'sys_scope'],
53
+ options: { singular: 'UI macro', scopeValidation: true } },
54
+ uxlists: { table: 'sys_ux_list', aliases: ['uxlist'], columns: ['name', 'active', 'sys_scope'],
55
+ options: { singular: 'workspace list', scopeValidation: true, readOnly: true } },
56
+ uxapplicability: { table: 'sys_ux_applicability_m2m', aliases: ['uxapp'], columns: ['name', 'active', 'sys_scope'],
57
+ options: { singular: 'workspace applicability', scopeValidation: true, readOnly: true } },
58
+ cataloguipolicies: { table: 'catalog_ui_policy', aliases: ['cataloguipolicy', 'cup'], columns: ['short_description', 'catalog_item', 'variable_set', 'active', 'sys_scope'],
59
+ options: { singular: 'catalog UI policy', scopeValidation: true } },
60
+ tables: { table: 'sys_db_object', aliases: ['table', 't'], columns: ['name', 'label', 'super_class', 'create_access_controls'],
61
+ options: { singular: 'table', scopeValidation: true, showFields: ['name', 'label', 'super_class', 'create_access_controls', 'sys_scope', 'sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'is_extendable'] } },
62
+ incidents: { table: 'incident', aliases: ['incident', 'inc'], columns: ['number', 'short_description', 'priority', 'state', 'assigned_to'], kind: 'ticket' },
63
+ changes: { table: 'change_request', aliases: ['change_request', 'chg'], columns: ['number', 'short_description', 'risk', 'state', 'assigned_to'], kind: 'ticket' },
64
+ requests: { table: 'sc_req_item', aliases: ['sc_req_item', 'req'], columns: ['number', 'short_description', 'stage', 'assigned_to'], kind: 'ticket' },
65
+ tasks: { table: 'sc_task', aliases: ['sc_task', 'task'], columns: ['number', 'short_description', 'state', 'assigned_to'], kind: 'ticket' },
66
+ uipolicies: { table: 'sys_ui_policy', aliases: ['uipolicy', 'up'], columns: ['short_description', 'table', 'sys_class_name', 'active'],
67
+ identifierField: 'short_description', options: { singular: 'UI policy', scopeValidation: true },
68
+ relationships: { essential: [{ name: 'actions', table: 'sys_ui_policy_action', foreignKey: 'ui_policy', limit: 100, orderBy: 'order',
69
+ columns: ['name', 'table', 'field_name', 'read_only', 'mandatory', 'visible'] }] } },
70
+ };
@@ -0,0 +1,88 @@
1
+ // Declarative command metadata is consumed by the actual command factories.
2
+ // Hooks stay executable code; the compact registry owns aliases/table/defaults/relationship policy.
3
+ import definitions from './record-command-profiles.js';
4
+ import { enrichEssentialRecords } from './essential-records.js';
5
+ import { buildDevCmd } from './commands/_generic.js';
6
+ import { buildTicketCommands } from './commands/_ticket.js';
7
+
8
+ const namePattern = /^[a-z][a-z0-9_-]*$/i;
9
+ const fieldPattern = /^[a-z_][a-z0-9_]*$/i;
10
+ function freeze(value) {
11
+ if (value && typeof value === 'object') {
12
+ for (const entry of Object.values(value)) freeze(entry);
13
+ Object.freeze(value);
14
+ }
15
+ return value;
16
+ }
17
+ export function validateRecordProfiles(profiles) {
18
+ if (!Array.isArray(profiles)) throw new Error('Record profiles must be an array.');
19
+ const names = new Set();
20
+ return profiles.map(definition => {
21
+ for (const key of ['options', 'output', 'relationships']) {
22
+ if (definition[key] !== undefined && (!definition[key] || typeof definition[key] !== 'object' || Array.isArray(definition[key]))) throw new Error(`Invalid ${key} for ${definition.name}.`);
23
+ }
24
+ const profile = { ...definition,
25
+ identifierField: definition.identifierField ?? (definition.kind === 'ticket' ? 'number' : 'name'),
26
+ options: { ...definition.options },
27
+ output: { references: 'both', listProjection: 'display', ...definition.output },
28
+ relationships: { essential: [], supplementary: [], ...definition.relationships },
29
+ };
30
+ if (!namePattern.test(profile.name || '') || names.has(profile.name)) throw new Error('Invalid or duplicate record profile name.');
31
+ names.add(profile.name);
32
+ if (!fieldPattern.test(profile.table || '') || !fieldPattern.test(profile.identifierField || '')) throw new Error(`Invalid table/identifier field for ${profile.name}.`);
33
+ if (!Array.isArray(profile.aliases) || !profile.aliases.every(alias => namePattern.test(alias))) throw new Error(`Invalid aliases for ${profile.name}.`);
34
+ if (!Array.isArray(profile.columns) || !profile.columns.length || !profile.columns.every(field => fieldPattern.test(field))) throw new Error(`Invalid columns for ${profile.name}.`);
35
+ if (!['dev', 'ticket'].includes(profile.kind || 'dev')) throw new Error(`Invalid factory kind for ${profile.name}.`);
36
+ if (!['raw', 'display', 'both'].includes(profile.output?.references) || !['display', 'full'].includes(profile.output?.listProjection)) throw new Error(`Invalid output preferences for ${profile.name}.`);
37
+ if (!profile.options || typeof profile.options !== 'object' || Array.isArray(profile.options)) throw new Error(`Invalid factory options for ${profile.name}.`);
38
+ for (const key of ['readOnly', 'scopeValidation']) {
39
+ if (profile.options[key] !== undefined && typeof profile.options[key] !== 'boolean') throw new Error(`Invalid ${key} option for ${profile.name}.`);
40
+ }
41
+ for (const kind of ['essential', 'supplementary']) {
42
+ if (!Array.isArray(profile.relationships?.[kind])) throw new Error(`Missing ${kind} relationship policy for ${profile.name}.`);
43
+ const relationshipNames = new Set();
44
+ for (const relationship of profile.relationships[kind]) {
45
+ if (relationshipNames.has(relationship.name)) throw new Error(`Duplicate relationship for ${profile.name}.`);
46
+ relationshipNames.add(relationship.name);
47
+ if (relationship.limit !== undefined && (!Number.isInteger(relationship.limit) || relationship.limit < 1 || relationship.limit > 1000)) throw new Error(`Invalid relationship limit for ${profile.name}.`);
48
+ if (relationship.orderBy !== undefined && !fieldPattern.test(relationship.orderBy)) throw new Error(`Invalid relationship ordering for ${profile.name}.`);
49
+ if (relationship.columns !== undefined && (!Array.isArray(relationship.columns) || !relationship.columns.every(field => fieldPattern.test(field)))) throw new Error(`Invalid relationship columns for ${profile.name}.`);
50
+ if (!namePattern.test(relationship.name || '') || !fieldPattern.test(relationship.table || '') || !fieldPattern.test(relationship.foreignKey || '')) throw new Error(`Invalid relationship for ${profile.name}.`);
51
+ }
52
+ }
53
+ return freeze(structuredClone(profile));
54
+ });
55
+ }
56
+ const profiles = freeze(validateRecordProfiles(Object.entries(definitions).map(([name, profile]) => ({ name, ...profile }))));
57
+ const index = new Map(profiles.map(profile => [profile.name, profile]));
58
+ export const recordProfiles = () => profiles;
59
+ export function getRecordProfile(name) {
60
+ const profile = index.get(name);
61
+ if (!profile) throw new Error(`Unknown record command profile: ${name}`);
62
+ return profile;
63
+ }
64
+ export function buildProfileCommand(name, wrap, hooks = {}) {
65
+ return buildRecordProfile(getRecordProfile(name), wrap, hooks);
66
+ }
67
+
68
+ export function buildRecordProfile(definition, wrap, hooks = {}) {
69
+ const [profile] = validateRecordProfiles([definition]);
70
+ const options = { ...profile.options, identifierField: profile.identifierField,
71
+ referenceMode: profile.output.references, listProjection: profile.output.listProjection,
72
+ relationships: profile.relationships, ...hooks };
73
+ if (profile.relationships.essential.length) {
74
+ const hook = options.onShow;
75
+ options.onShow = async (record, app, argv = {}) => {
76
+ if (hook) await hook(record, app, argv);
77
+ if (argv.essential !== false) await enrichEssentialRecords(record, { sdk: app.sdk,
78
+ relationships: profile.relationships.essential, displayValues: argv.displayValues ?? profile.output.references,
79
+ profileName: app.session?.profileName, instance: app.getEffectiveInstance?.() });
80
+ };
81
+ }
82
+ const command = profile.kind === 'ticket'
83
+ ? buildTicketCommands(profile.table, profile.name, profile.aliases.find(alias => alias !== profile.table), profile.columns, {}, null, wrap, options)
84
+ : buildDevCmd(profile.name, profile.table, profile.aliases, profile.columns, wrap, options);
85
+ command.aliases = [...profile.aliases];
86
+ command.recordProfile = profile;
87
+ return command;
88
+ }
@@ -0,0 +1,57 @@
1
+ // In-process CRUD transport shared by generic and specialized commands.
2
+ // Policy (confirmation, scope, payload and output shape) stays with callers.
3
+ import { resolveRecord, unwrapSysId } from './resolve-record.js';
4
+ import { errUsage } from './errors.js';
5
+
6
+ export function displayValue(mode, fallback = 'all') {
7
+ if (mode === undefined) return fallback;
8
+ const values = { raw: 'false', display: 'true', both: 'all' };
9
+ if (!Object.hasOwn(values, mode)) throw errUsage('display-values must be raw, display or both');
10
+ return values[mode];
11
+ }
12
+
13
+ export function recordListParams({ limit, offset, query, fields, displayValue: representation = 'all' } = {}) {
14
+ const params = new URLSearchParams({ sysparm_display_value: representation });
15
+ if (limit !== undefined) params.set('sysparm_limit', String(limit));
16
+ if (offset !== undefined) params.set('sysparm_offset', String(offset));
17
+ if (query) params.set('sysparm_query', query);
18
+ if (fields) params.set('sysparm_fields', Array.isArray(fields) ? fields.join(',') : fields);
19
+ return params;
20
+ }
21
+
22
+ export function listRecords(sdk, { table, params, displayValues, ...options }) {
23
+ const query = params ? new URLSearchParams(params) : recordListParams(options);
24
+ if (displayValues !== undefined) query.set('sysparm_display_value', displayValue(displayValues));
25
+ return sdk.list(table, query);
26
+ }
27
+
28
+ export function getRecord(sdk, options) {
29
+ return resolveRecord(sdk, { ...options, displayValue: displayValue(options.displayValues, options.displayValue || 'all') });
30
+ }
31
+
32
+ export function createRecord(sdk, { table, data }) {
33
+ return sdk.create(table, data);
34
+ }
35
+
36
+ function mutationId(record) {
37
+ const id = unwrapSysId(record);
38
+ if (!id) throw errUsage('The matched record has no readable sys_id; check field ACLs before retrying.');
39
+ return id;
40
+ }
41
+
42
+ export async function updateRecord(sdk, { table, data, record, ...lookup }) {
43
+ const resolved = record || await getRecord(sdk, { table, ...lookup });
44
+ return sdk.update(table, mutationId(resolved), data);
45
+ }
46
+
47
+ export async function deleteRecord(sdk, { table, record, ...lookup }) {
48
+ const resolved = record || await getRecord(sdk, { table, ...lookup });
49
+ const id = mutationId(resolved);
50
+ await sdk.delete(table, id);
51
+ return id;
52
+ }
53
+
54
+ export function recordOptions(y, display = true) {
55
+ const command = y.option('identifier-field', { type: 'string', describe: 'Exact field for disambiguation (sys_id, number, name, etc); no human-field partial matches' });
56
+ return display ? command.option('display-values', { type: 'string', choices: ['raw', 'display', 'both'], describe: 'Reference values: raw, display or both; omitted preserves existing output' }) : command;
57
+ }
@@ -145,3 +145,9 @@ export async function enrichRelatedLists(record, { table, argv = {}, app }) {
145
145
  instance: app.getEffectiveInstance?.() || '' });
146
146
  return record;
147
147
  }
148
+
149
+ export function relatedListOptions(y) {
150
+ return y.option('related', { type: 'boolean', default: false, describe: 'Count related lists under Aggregate API access; suggest commands without loading supplementary records' })
151
+ .option('view', { type: 'string', describe: 'Form view for --related metadata (default: Default view)' })
152
+ .option('related-limit', { type: 'number', describe: 'Max related-list descriptors to count with --related (default 50)' });
153
+ }
@@ -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 || sysId.display_value || '';
28
+ if (sysId && typeof sysId === 'object') return sysId.value || '';
29
29
  return sysId || '';
30
30
  }
31
31
 
32
32
  /**
33
- * Resolve a single record by human identifier.
34
- *
35
- * @param {object} sdk — SDK handle (uses sdk.list)
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, resource, fields }) {
37
+ export async function resolveRecord(sdk, { table, identifier, matchField = 'sys_id', identifierField,
38
+ resource, fields, displayValue = 'all', partial = true }) {
48
39
  assertSafeExactMatch(identifier);
49
- const queryField = isSysId(identifier) ? 'sys_id' : matchField;
50
- const params = new URLSearchParams();
51
- params.set('sysparm_query', `${queryField}=${identifier}`);
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 records = await sdk.list(table, params);
58
- if (records.length === 0) {
59
- throw errNotFound(resource || matchField, identifier);
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
- return records[0];
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
- return unwrapSysId(record);
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
  }