@jacebenson/jsn 4.3.12-dev.70 → 4.3.13-dev.100.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +55 -0
  2. package/package.json +2 -1
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +12 -0
  9. package/src/cli.js +39 -9
  10. package/src/command-audience.js +35 -0
  11. package/src/commands/_generic.js +45 -28
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +24 -20
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/changes.js +2 -4
  18. package/src/commands/columns.js +2 -1
  19. package/src/commands/customizations.js +13 -7
  20. package/src/commands/discover.js +54 -0
  21. package/src/commands/docs/db.js +9 -1
  22. package/src/commands/docs/docs.js +25 -2
  23. package/src/commands/docs/ingest.js +20 -3
  24. package/src/commands/docs/install.js +86 -0
  25. package/src/commands/docs/locks.js +34 -0
  26. package/src/commands/docs/refresh.js +22 -2
  27. package/src/commands/docs/replace.js +30 -0
  28. package/src/commands/docs/snapshot-check.js +11 -0
  29. package/src/commands/docs/snapshot.js +130 -0
  30. package/src/commands/docs/sync.js +16 -6
  31. package/src/commands/forms.js +22 -97
  32. package/src/commands/history.js +57 -0
  33. package/src/commands/incidents.js +2 -4
  34. package/src/commands/midservers.js +117 -10
  35. package/src/commands/records.js +54 -104
  36. package/src/commands/requests.js +9 -15
  37. package/src/commands/rest.js +15 -2
  38. package/src/commands/scopes.js +1 -1
  39. package/src/commands/setup.js +46 -9
  40. package/src/commands/skill.js +72 -76
  41. package/src/commands/tasks.js +2 -4
  42. package/src/commands/tickets.js +20 -19
  43. package/src/commands/uipolicies.js +2 -27
  44. package/src/commands/updatesets.js +65 -13
  45. package/src/commands/version.js +14 -8
  46. package/src/constrained-query.js +20 -0
  47. package/src/context.js +6 -6
  48. package/src/customization-guidance.js +31 -0
  49. package/src/customizations-report.js +6 -0
  50. package/src/customizations.js +28 -13
  51. package/src/errors.js +23 -1
  52. package/src/essential-records.js +40 -0
  53. package/src/field-behavior.js +15 -0
  54. package/src/help.js +10 -2
  55. package/src/metadata/records.js +249 -0
  56. package/src/midserver-linux-remote.js +210 -0
  57. package/src/midserver-remote-recipe.js +49 -0
  58. package/src/midserver-remote.js +221 -0
  59. package/src/midserver-windows-remote.js +318 -0
  60. package/src/midserver-zip.js +2 -2
  61. package/src/mutation-context.js +74 -0
  62. package/src/mutation-history.js +75 -0
  63. package/src/mutations.js +3 -2
  64. package/src/output.js +34 -2
  65. package/src/record-command-profiles.js +70 -0
  66. package/src/record-profiles.js +93 -0
  67. package/src/records-engine.js +57 -0
  68. package/src/related-lists.js +153 -0
  69. package/src/resolve-record.js +50 -28
  70. package/src/sdk-batch.js +130 -0
  71. package/src/sdk.js +157 -18
  72. package/src/update-set-xml.js +89 -0
package/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { visibleCommandRegistry } from './command-audience.js';
1
2
  // Root CLI using the local yargs-compatible adapter
2
3
 
3
4
  import yargs, { hideBin } from './cli-adapter.js';
@@ -7,17 +8,19 @@ import fs from 'node:fs';
7
8
  import { loadConfig, globalConfigDir } from './config.js';
8
9
  import { App } from './app.js';
9
10
  import { resolveSession, applySession } from './session.js';
10
- import { renderHelp } from './help.js';
11
+ import { renderHelp, renderTips } from './help.js';
11
12
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
13
  import { skippedUpdatesCmd } from './commands/skipped-updates.js';
13
- import { noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
14
+ import { isLocalPreview, noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
14
15
  import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
15
16
  import { formatCommandList } from './helpers.js';
16
17
 
17
18
  // Command modules
19
+ import { discoverCmd } from './commands/discover.js';
18
20
  import { setupCmd } from './commands/setup.js';
19
21
  import { authCmd } from './commands/auth.js';
20
22
  import { recordsCmd } from './commands/records.js';
23
+ import { attachmentsCmd } from './commands/attachments.js';
21
24
  import { cmdbCmd } from './commands/cmdb.js';
22
25
  import { snippetsCmd } from './commands/snippets.js';
23
26
  import { catalogCmd } from './commands/catalog.js';
@@ -36,7 +39,7 @@ import { platformCmd } from './commands/platform.js';
36
39
  import { codesearchCmd } from './commands/codesearch.js';
37
40
  import { versionCmd } from './commands/version.js';
38
41
  import { skillCmd, checkSkill } from './commands/skill.js';
39
- import { getVersion, checkLatest } from './commands/version.js';
42
+ import { getVersion, checkLatest, getReleaseChannel, getInstallCommand } from './commands/version.js';
40
43
  import { docsCmd } from './commands/docs/docs.js';
41
44
 
42
45
  // Table and platform commands registered at root level for progressive disclosure
@@ -70,6 +73,7 @@ import { scrapiCmd } from './commands/scrapi.js';
70
73
  import { b4rulesCmd } from './commands/b4rules.js';
71
74
  import { atfCmd } from './commands/atf.js';
72
75
  import { approvalsCmd } from './commands/approvals.js';
76
+ import { historyCmd } from './commands/history.js';
73
77
  import { perfCmd } from './commands/perf.js';
74
78
  import { customizationsCmd } from './commands/customizations.js';
75
79
  import { midserversCmd } from './commands/midservers.js';
@@ -116,6 +120,7 @@ function wrap(handler) {
116
120
  process.stderr.write('Error: App context not initialized.\n');
117
121
  process.exit(1);
118
122
  }
123
+ await app.guardMutationContext(argv);
119
124
  await handler(argv, app);
120
125
  } catch (err) {
121
126
  // All error rendering goes through the unified renderer in
@@ -144,6 +149,13 @@ export function buildCLI() {
144
149
  },
145
150
  ])
146
151
  .options({
152
+ 'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
153
+ reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
154
+
155
+ force: {
156
+ type: 'boolean',
157
+ description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
158
+ },
147
159
  instance: {
148
160
  type: 'string',
149
161
  description: 'ServiceNow instance URL',
@@ -213,6 +225,7 @@ export function buildCLI() {
213
225
  hint: 'Run "jsn auth status" to list profiles.',
214
226
  });
215
227
  }
228
+ app._authArgv = argv;
216
229
  applySession(app, session);
217
230
  } catch (err) {
218
231
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
@@ -248,6 +261,7 @@ export function buildCLI() {
248
261
  .middleware(async (argv) => {
249
262
  // Session (incl. --instance/--profile overrides) was already applied
250
263
  // by the middleware above — nothing to re-apply here.
264
+ if (isLocalPreview(argv)) return;
251
265
  const cmd = (argv._[0] || '').toString();
252
266
  // Daily-check + context-header skip-list is derived from command
253
267
  // capability declarations (skipDailyChecks: true).
@@ -290,7 +304,7 @@ export function buildCLI() {
290
304
  }
291
305
  }
292
306
  if (!skipNpmCheck) {
293
- const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
307
+ const cacheFile = path.join(globalConfigDir(), `.last-npm-check-${getReleaseChannel()}`);
294
308
  let shouldCheck = true;
295
309
  try {
296
310
  const mtime = fs.statSync(cacheFile).mtimeMs;
@@ -303,9 +317,9 @@ export function buildCLI() {
303
317
 
304
318
  if (shouldCheck) {
305
319
  const version = getVersion();
306
- checkLatest({ background: true }).then(latest => {
320
+ checkLatest({ background: true, version }).then(latest => {
307
321
  if (latest && version !== latest) {
308
- process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
322
+ process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "${getInstallCommand(version)}" to update\n\n`);
309
323
  }
310
324
  }).catch(() => {});
311
325
 
@@ -398,6 +412,7 @@ export function buildCLI() {
398
412
  // DATA
399
413
  .command(cmdbCmd(wrap))
400
414
  .command(recordsCmd(wrap))
415
+ .command(attachmentsCmd(wrap))
401
416
  .command(transactionsCmd(wrap))
402
417
 
403
418
  .command(platformCmd(wrap))
@@ -409,6 +424,7 @@ export function buildCLI() {
409
424
  .command(approvalsCmd(wrap))
410
425
  .command(skippedUpdatesCmd(wrap))
411
426
  .command(perfCmd(wrap))
427
+ .command(historyCmd(wrap))
412
428
  .command(customizationsCmd(wrap))
413
429
  .command(midserversCmd(wrap))
414
430
  .command(diffCmd(wrap))
@@ -418,6 +434,7 @@ export function buildCLI() {
418
434
  .command(skillCmd(wrap))
419
435
  .command(docsCmd(wrap))
420
436
  .command(versionCmd(wrap))
437
+ .command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
421
438
  .demandCommand(1, 'You must specify a command')
422
439
  // Completion plumbing under a hidden command name, so the explicit
423
440
  // 'completion' command below owns the UX. (#176)
@@ -437,6 +454,12 @@ export function buildCLI() {
437
454
  if (current && !current.startsWith('-') && depth === 0) {
438
455
  out = [...new Set([...out, ...rootCommands])];
439
456
  }
457
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
458
+ const visible = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(options), options);
459
+ if (!depth) {
460
+ const names = new Set(visible.commands.flatMap(command => [command.name, ...command.aliases]));
461
+ out = out.filter(value => value.startsWith('-') || names.has(value));
462
+ }
440
463
  done([...new Set(out)]);
441
464
  });
442
465
  })
@@ -473,7 +496,7 @@ export function buildCLI() {
473
496
  })
474
497
  .onParentDiscovery((argv, { path, subcommands }) => {
475
498
  // Reached only for a parent command that declares no default handler of
476
- // its own (records attachments, docs community, and any future parent) —
499
+ // its own (attachments, docs community, and any future parent) —
477
500
  // the adapter used to fall through and exit 0 having printed nothing.
478
501
  // Children come from the registry, description included, so this list
479
502
  // cannot drift from the registrations the way a hand-written one did.
@@ -498,6 +521,7 @@ export function buildCLI() {
498
521
  }, { summary });
499
522
  })(argv);
500
523
  })
524
+ .epilogue(renderTips())
501
525
  .help('help', 'Show help')
502
526
  .version(false)
503
527
  .strictCommands()
@@ -541,7 +565,7 @@ export function buildCLI() {
541
565
  if (typeof formatArgv?.get === 'string' && formatArgv.get) {
542
566
  app.output.setJqFilter(formatArgv.get);
543
567
  }
544
- const { commands } = cliInstance.commandRegistry();
568
+ const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
545
569
  try {
546
570
  app.ok({ commands }, {
547
571
  summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
@@ -551,7 +575,7 @@ export function buildCLI() {
551
575
  }
552
576
  process.exit(0);
553
577
  }
554
- process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
578
+ process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
555
579
  process.exit(0);
556
580
  }
557
581
  // Every other failure here (unknown command, missing required argument,
@@ -571,6 +595,12 @@ export function buildCLI() {
571
595
  exitWithError(new AppError(CodeUsage, msg, hint), { app });
572
596
  });
573
597
 
598
+ cliInstance.rootHelpRenderer = () => {
599
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
600
+ const config = loadConfig(options);
601
+ return renderHelp(config, visibleCommandRegistry(cliInstance.commandRegistry(), config, options));
602
+ };
603
+
574
604
  // Snapshot the root command names (plus aliases) for the completion
575
605
  // filter. Done before any parse — command builders mutate the registry.
576
606
  rootCommands = cliInstance.getInternalMethods().getCommandInstance().getCommands();
@@ -0,0 +1,35 @@
1
+ // Presentation policy only. ServiceNow ACLs still authorize every request.
2
+ // Explicit command paths and --all-commands remain available to every account.
3
+ const BASIC_COMMANDS = new Set([
4
+ 'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
5
+ 'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
6
+ 'version', 'docs', 'history', 'discover', 'completion',
7
+ ]);
8
+
9
+ export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
10
+ if (!username || typeof sdk?.listStrict !== 'function') return undefined;
11
+ try {
12
+ const roles = await sdk.listStrict('sys_user_has_role', {
13
+ sysparm_query: 'user=javascript:gs.getUserID()^role.name=admin',
14
+ sysparm_fields: 'sys_id', sysparm_limit: '1',
15
+ }, { touchLastSeen: false });
16
+ return { username, instance_url: instanceURL, mode: roles.length ? 'admin' : 'basic', role_check: 'verified' };
17
+ } catch {
18
+ // Missing role access must not expand help. This is a presentation fallback,
19
+ // not a claim that the role query succeeded or an authorization boundary.
20
+ return { username, instance_url: instanceURL, mode: 'basic', role_check: 'unavailable' };
21
+ }
22
+ }
23
+
24
+ export function commandAudience(config, { instance, allCommands = false } = {}) {
25
+ if (allCommands || instance) return 'all';
26
+ const name = config?.activeProfile || config?.defaultProfile;
27
+ const profile = config?.profiles?.[name];
28
+ const cached = profile?.command_audience;
29
+ return cached?.username === profile?.username && cached?.instance_url === profile?.instance_url && cached?.mode === 'basic' ? 'basic' : 'all';
30
+ }
31
+
32
+ export function visibleCommandRegistry(registry, config, options = {}) {
33
+ if (commandAudience(config, options) !== 'basic') return registry;
34
+ return { ...registry, commands: registry.commands.filter(command => BASIC_COMMANDS.has(command.name)) };
35
+ }
@@ -1,7 +1,10 @@
1
+ import { constrainQuery, constrainedQueryHelp } from '../constrained-query.js';
2
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
1
3
  // Generic table-based CRUD command builder
2
4
 
3
5
  import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
- import { resolveRecord, unwrapSysId } from '../resolve-record.js';
6
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
7
+ import { unwrapSysId } from '../resolve-record.js';
5
8
  import { getCurrentUser, getCurrentApplication } from '../context.js';
6
9
  import { FormatAuto } from '../output.js';
7
10
  import { declareCapabilities } from '../capabilities.js';
@@ -120,19 +123,24 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
120
123
  command: 'list',
121
124
  aliases: ['ls'],
122
125
  describe: `List ${name}`,
123
- builder: (y) => y
124
- .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
125
- .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
126
- .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
126
+ builder: (y) => {
127
+ const command = recordOptions(y)
128
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
129
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
130
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' });
131
+ const help = [opts.help, extraQuery ? constrainedQueryHelp(extraQuery) : ''].filter(Boolean).join('\n');
132
+ return help ? command.epilogue(help) : command;
133
+ },
127
134
  handler: wrap(async (argv, app) => {
128
135
  const query = argv.query || '';
136
+ const constrainedQuery = constrainQuery(query, extraQuery);
129
137
  const columns = argv.columns ? argv.columns.split(',') : defaultColumns;
130
138
  const limit = argv.limit;
131
139
 
132
140
  // Interactive picker with pagination
133
141
  if (canPrompt() && app.output.getFormat() === FormatAuto && !query) {
134
142
  const picked = await interactiveList({
135
- app, table, singular, columns: ['name', 'sys_scope'], limit, query: extraQuery,
143
+ app, table, singular, columns: ['name', 'sys_scope'], limit, query: constrainedQuery,
136
144
  labelField: 'name',
137
145
  message: `Select ${vowelArticle(singular)} ${singular}`,
138
146
  formatLabel: (r) => {
@@ -147,7 +155,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
147
155
  const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
148
156
  record._context = { instance_url: app.getEffectiveInstance(), table };
149
157
  if (opts.onShow) {
150
- await opts.onShow(record, app);
158
+ await opts.onShow(record, app, argv);
151
159
  }
152
160
  app.ok(record, {
153
161
  summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
@@ -160,19 +168,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
160
168
  }
161
169
 
162
170
  // 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
- const baseQ = extraQuery ? extraQuery + '^' : '';
168
- 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);
171
+ const q = `${constrainedQuery}${constrainedQuery ? '^' : ''}ORDERBYDESCsys_updated_on`;
172
+ const params = recordListParams({ limit, fields: ['sys_id', ...columns], query: q });
173
+ const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
171
174
  app.ok({
172
175
  table,
173
176
  count: records.length,
174
177
  columns,
175
- records: records.map(r => formatRecordForDisplay(r, columns)),
178
+ records: argv.displayValues || opts.listProjection === 'full' ? records : records.map(r => formatRecordForDisplay(r, columns)),
176
179
  context: { instance_url: app.getEffectiveInstance() },
177
180
  }, {
178
181
  summary: `${records.length} ${name}(s)`,
@@ -184,6 +187,11 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
184
187
  command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
185
188
  aliases: ['get'],
186
189
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
190
+ builder: y => {
191
+ const command = relatedListOptions(recordOptions(y));
192
+ return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true,
193
+ describe: 'Fetch profile-declared essential children; --no-essential skips them. Supplementary lists remain lazy.' }) : command;
194
+ },
187
195
  handler: wrap(async (argv, app) => {
188
196
  const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
189
197
  if (id === undefined) return;
@@ -192,16 +200,17 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
192
200
  const fields = (showFields && showFields.length > 0)
193
201
  ? [...new Set(['sys_id', ...showFields])]
194
202
  : undefined;
195
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular, fields });
203
+ 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
204
  record._context = {
197
205
  instance_url: app.getEffectiveInstance(),
198
206
  table,
199
207
  };
200
208
 
201
209
  if (opts.onShow) {
202
- await opts.onShow(record, app);
210
+ await opts.onShow(record, app, argv);
203
211
  }
204
212
 
213
+ await enrichRelatedLists(record, { table, argv, app });
205
214
  const summary = typeof showSummary === 'function' ? showSummary(record, id) : showSummary;
206
215
  const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(record, id) : showBreadcrumbs;
207
216
 
@@ -214,14 +223,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
214
223
  .command({
215
224
  command: 'create',
216
225
  describe: `Create a new ${singular}`,
217
- builder: (y) => y
226
+ builder: (y) => recordOptions(y, false)
218
227
  .option('data', { type: 'string', describe: 'JSON fields' })
219
228
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
220
229
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
221
230
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
222
231
  handler: wrap(async (argv, app) => {
223
232
  const recordData = buildRecordPayload(argv);
224
- const record = await app.sdk.create(table, recordData);
233
+ const record = await createRecord(app.sdk, { table, data: recordData });
225
234
  app.ok(record, {
226
235
  summary: `Created ${singular}`,
227
236
  breadcrumbs: [
@@ -233,31 +242,31 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
233
242
  .command({
234
243
  command: 'update <identifier>',
235
244
  describe: `Update ${vowelArticle(singular)} ${singular}`,
236
- builder: (y) => y
245
+ builder: (y) => recordOptions(y, false)
237
246
  .option('data', { type: 'string', describe: 'JSON fields' })
238
247
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
239
248
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
240
249
  .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
241
250
  handler: wrap(async (argv, app) => {
242
251
  const id = argv.identifier;
243
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
252
+ const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
244
253
  if (scopeValidation) {
245
254
  await guardScope(app.sdk, record);
246
255
  }
247
256
 
248
257
  const sysID = unwrapSysId(record);
249
258
  const recordData = buildRecordPayload(argv);
250
- const updated = await app.sdk.update(table, sysID, recordData);
259
+ const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
251
260
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
252
261
  }),
253
262
  })
254
263
  .command({
255
264
  command: 'delete <identifier>',
256
265
  describe: `Delete ${vowelArticle(singular)} ${singular}`,
257
- builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
266
+ builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
258
267
  handler: wrap(async (argv, app) => {
259
268
  const id = argv.identifier;
260
- const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
269
+ const record = await getRecord(app.sdk, { table, identifier: id, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'name', resource: singular });
261
270
  if (scopeValidation) {
262
271
  await guardScope(app.sdk, record);
263
272
  }
@@ -267,19 +276,27 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
267
276
 
268
277
  await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
269
278
 
270
- await app.sdk.delete(table, sysID);
279
+ await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
271
280
  app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
272
281
  }),
273
282
  });
274
283
  }
275
284
 
276
- return y;
285
+ const help = [opts.help, extraQuery ? constrainedQueryHelp(extraQuery) : ''].filter(Boolean).join('\n');
286
+ return help ? y.epilogue(help) : y;
277
287
  };
278
288
 
279
289
  return {
280
290
  command: `${name} [subcommand]`,
281
291
  aliases: aliases || [],
282
- describe: `Manage ${name} (e.g. "${name} list --query nameLIKEincident")`,
292
+ describe: opts.describe || `Manage ${name} (e.g. "${name} list --query nameLIKEincident")`,
293
+ discovery: {
294
+ table,
295
+ defaultColumns,
296
+ benefits: ['Configured list columns', 'Lookup by name or sys_id',
297
+ ...(showFields ? ['Configured show fields'] : []),
298
+ ...(opts.onShow ? ['Additional table-specific show processing'] : [])],
299
+ },
283
300
  builder,
284
301
  handler: (_argv) => {
285
302
  // This handler only runs when no subcommand is matched (the default case).
@@ -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);