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

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