@jacebenson/jsn 4.3.12 → 4.3.13-dev.101.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 (77) hide show
  1. package/README.md +95 -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 +24 -4
  9. package/src/cli.js +41 -9
  10. package/src/command-audience.js +35 -0
  11. package/src/commands/_generic.js +58 -37
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +38 -31
  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/groups.js +11 -8
  33. package/src/commands/history.js +57 -0
  34. package/src/commands/incidents.js +2 -4
  35. package/src/commands/midservers.js +117 -10
  36. package/src/commands/records.js +64 -119
  37. package/src/commands/requests.js +10 -16
  38. package/src/commands/rest.js +15 -2
  39. package/src/commands/scan.js +234 -0
  40. package/src/commands/scopes.js +1 -1
  41. package/src/commands/setup.js +46 -9
  42. package/src/commands/skill.js +72 -76
  43. package/src/commands/tasks.js +2 -4
  44. package/src/commands/tickets.js +31 -22
  45. package/src/commands/uipolicies.js +2 -27
  46. package/src/commands/updatesets.js +66 -13
  47. package/src/commands/users.js +11 -8
  48. package/src/commands/version.js +14 -8
  49. package/src/constrained-query.js +20 -0
  50. package/src/context.js +6 -6
  51. package/src/customization-guidance.js +31 -0
  52. package/src/customizations-report.js +6 -0
  53. package/src/customizations.js +28 -13
  54. package/src/errors.js +23 -1
  55. package/src/essential-records.js +40 -0
  56. package/src/field-behavior.js +15 -0
  57. package/src/help.js +11 -2
  58. package/src/helpers.js +44 -0
  59. package/src/metadata/records.js +249 -0
  60. package/src/midserver-linux-remote.js +210 -0
  61. package/src/midserver-remote-recipe.js +49 -0
  62. package/src/midserver-remote.js +221 -0
  63. package/src/midserver-windows-remote.js +318 -0
  64. package/src/midserver-zip.js +2 -2
  65. package/src/mutation-context.js +74 -0
  66. package/src/mutation-history.js +75 -0
  67. package/src/mutations.js +3 -2
  68. package/src/output.js +34 -2
  69. package/src/record-command-profiles.js +70 -0
  70. package/src/record-profiles.js +93 -0
  71. package/src/records-engine.js +57 -0
  72. package/src/related-lists.js +153 -0
  73. package/src/resolve-record.js +50 -28
  74. package/src/scan.js +249 -0
  75. package/src/sdk-batch.js +130 -0
  76. package/src/sdk.js +157 -18
  77. 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
@@ -69,7 +72,9 @@ import { logsCmd } from './commands/logs.js';
69
72
  import { scrapiCmd } from './commands/scrapi.js';
70
73
  import { b4rulesCmd } from './commands/b4rules.js';
71
74
  import { atfCmd } from './commands/atf.js';
75
+ import { scanCmd } from './commands/scan.js';
72
76
  import { approvalsCmd } from './commands/approvals.js';
77
+ import { historyCmd } from './commands/history.js';
73
78
  import { perfCmd } from './commands/perf.js';
74
79
  import { customizationsCmd } from './commands/customizations.js';
75
80
  import { midserversCmd } from './commands/midservers.js';
@@ -116,6 +121,7 @@ function wrap(handler) {
116
121
  process.stderr.write('Error: App context not initialized.\n');
117
122
  process.exit(1);
118
123
  }
124
+ await app.guardMutationContext(argv);
119
125
  await handler(argv, app);
120
126
  } catch (err) {
121
127
  // All error rendering goes through the unified renderer in
@@ -144,6 +150,13 @@ export function buildCLI() {
144
150
  },
145
151
  ])
146
152
  .options({
153
+ 'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
154
+ reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
155
+
156
+ force: {
157
+ type: 'boolean',
158
+ description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
159
+ },
147
160
  instance: {
148
161
  type: 'string',
149
162
  description: 'ServiceNow instance URL',
@@ -213,6 +226,7 @@ export function buildCLI() {
213
226
  hint: 'Run "jsn auth status" to list profiles.',
214
227
  });
215
228
  }
229
+ app._authArgv = argv;
216
230
  applySession(app, session);
217
231
  } catch (err) {
218
232
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
@@ -248,6 +262,7 @@ export function buildCLI() {
248
262
  .middleware(async (argv) => {
249
263
  // Session (incl. --instance/--profile overrides) was already applied
250
264
  // by the middleware above — nothing to re-apply here.
265
+ if (isLocalPreview(argv)) return;
251
266
  const cmd = (argv._[0] || '').toString();
252
267
  // Daily-check + context-header skip-list is derived from command
253
268
  // capability declarations (skipDailyChecks: true).
@@ -290,7 +305,7 @@ export function buildCLI() {
290
305
  }
291
306
  }
292
307
  if (!skipNpmCheck) {
293
- const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
308
+ const cacheFile = path.join(globalConfigDir(), `.last-npm-check-${getReleaseChannel()}`);
294
309
  let shouldCheck = true;
295
310
  try {
296
311
  const mtime = fs.statSync(cacheFile).mtimeMs;
@@ -303,9 +318,9 @@ export function buildCLI() {
303
318
 
304
319
  if (shouldCheck) {
305
320
  const version = getVersion();
306
- checkLatest({ background: true }).then(latest => {
321
+ checkLatest({ background: true, version }).then(latest => {
307
322
  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`);
323
+ process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "${getInstallCommand(version)}" to update\n\n`);
309
324
  }
310
325
  }).catch(() => {});
311
326
 
@@ -398,6 +413,7 @@ export function buildCLI() {
398
413
  // DATA
399
414
  .command(cmdbCmd(wrap))
400
415
  .command(recordsCmd(wrap))
416
+ .command(attachmentsCmd(wrap))
401
417
  .command(transactionsCmd(wrap))
402
418
 
403
419
  .command(platformCmd(wrap))
@@ -406,9 +422,11 @@ export function buildCLI() {
406
422
  .command(codesearchCmd(wrap))
407
423
  // DEVELOPER
408
424
  .command(atfCmd(wrap))
425
+ .command(scanCmd(wrap))
409
426
  .command(approvalsCmd(wrap))
410
427
  .command(skippedUpdatesCmd(wrap))
411
428
  .command(perfCmd(wrap))
429
+ .command(historyCmd(wrap))
412
430
  .command(customizationsCmd(wrap))
413
431
  .command(midserversCmd(wrap))
414
432
  .command(diffCmd(wrap))
@@ -418,6 +436,7 @@ export function buildCLI() {
418
436
  .command(skillCmd(wrap))
419
437
  .command(docsCmd(wrap))
420
438
  .command(versionCmd(wrap))
439
+ .command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
421
440
  .demandCommand(1, 'You must specify a command')
422
441
  // Completion plumbing under a hidden command name, so the explicit
423
442
  // 'completion' command below owns the UX. (#176)
@@ -437,6 +456,12 @@ export function buildCLI() {
437
456
  if (current && !current.startsWith('-') && depth === 0) {
438
457
  out = [...new Set([...out, ...rootCommands])];
439
458
  }
459
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
460
+ const visible = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(options), options);
461
+ if (!depth) {
462
+ const names = new Set(visible.commands.flatMap(command => [command.name, ...command.aliases]));
463
+ out = out.filter(value => value.startsWith('-') || names.has(value));
464
+ }
440
465
  done([...new Set(out)]);
441
466
  });
442
467
  })
@@ -473,7 +498,7 @@ export function buildCLI() {
473
498
  })
474
499
  .onParentDiscovery((argv, { path, subcommands }) => {
475
500
  // Reached only for a parent command that declares no default handler of
476
- // its own (records attachments, docs community, and any future parent) —
501
+ // its own (attachments, docs community, and any future parent) —
477
502
  // the adapter used to fall through and exit 0 having printed nothing.
478
503
  // Children come from the registry, description included, so this list
479
504
  // cannot drift from the registrations the way a hand-written one did.
@@ -498,6 +523,7 @@ export function buildCLI() {
498
523
  }, { summary });
499
524
  })(argv);
500
525
  })
526
+ .epilogue(renderTips())
501
527
  .help('help', 'Show help')
502
528
  .version(false)
503
529
  .strictCommands()
@@ -541,7 +567,7 @@ export function buildCLI() {
541
567
  if (typeof formatArgv?.get === 'string' && formatArgv.get) {
542
568
  app.output.setJqFilter(formatArgv.get);
543
569
  }
544
- const { commands } = cliInstance.commandRegistry();
570
+ const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
545
571
  try {
546
572
  app.ok({ commands }, {
547
573
  summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
@@ -551,7 +577,7 @@ export function buildCLI() {
551
577
  }
552
578
  process.exit(0);
553
579
  }
554
- process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
580
+ process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
555
581
  process.exit(0);
556
582
  }
557
583
  // Every other failure here (unknown command, missing required argument,
@@ -571,6 +597,12 @@ export function buildCLI() {
571
597
  exitWithError(new AppError(CodeUsage, msg, hint), { app });
572
598
  });
573
599
 
600
+ cliInstance.rootHelpRenderer = () => {
601
+ const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
602
+ const config = loadConfig(options);
603
+ return renderHelp(config, visibleCommandRegistry(cliInstance.commandRegistry(), config, options));
604
+ };
605
+
574
606
  // Snapshot the root command names (plus aliases) for the completion
575
607
  // filter. Done before any parse — command builders mutate the registry.
576
608
  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
- import { formatRecordForDisplay, getStringField, parseDataArg, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
- import { resolveRecord, unwrapSysId } from '../resolve-record.js';
5
+ import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.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';
@@ -108,7 +111,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
108
111
  if (!readOnly) {
109
112
  crumbs.unshift(
110
113
  { action: 'delete', cmd: `jsn ${name} delete ${id}`, description: `Delete this ${singular}` },
111
- { action: 'update', cmd: `jsn ${name} update ${id} --data '{...}'`, description: `Update this ${singular}` }
114
+ { action: 'update', cmd: `jsn ${name} update ${id} -f name=value`, description: `Update this ${singular}` }
112
115
  );
113
116
  }
114
117
  return crumbs;
@@ -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,12 +155,12 @@ 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}`,
154
162
  breadcrumbs: [
155
- ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} --data '{...}'`, description: `Update this ${singular}` }]),
163
+ ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} -f name=value`, description: `Update this ${singular}` }]),
156
164
  { action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
157
165
  ],
158
166
  });
@@ -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,12 +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
218
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
219
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
226
+ builder: (y) => recordOptions(y, false)
227
+ .option('data', { type: 'string', describe: 'JSON fields' })
228
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
229
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
230
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
220
231
  handler: wrap(async (argv, app) => {
221
- const recordData = parseDataArg(argv);
222
- const record = await app.sdk.create(table, recordData);
232
+ const recordData = buildRecordPayload(argv);
233
+ const record = await createRecord(app.sdk, { table, data: recordData });
223
234
  app.ok(record, {
224
235
  summary: `Created ${singular}`,
225
236
  breadcrumbs: [
@@ -231,29 +242,31 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
231
242
  .command({
232
243
  command: 'update <identifier>',
233
244
  describe: `Update ${vowelArticle(singular)} ${singular}`,
234
- builder: (y) => y
235
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
236
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
245
+ builder: (y) => recordOptions(y, false)
246
+ .option('data', { type: 'string', describe: 'JSON fields' })
247
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
248
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
249
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
237
250
  handler: wrap(async (argv, app) => {
238
251
  const id = argv.identifier;
239
- 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 });
240
253
  if (scopeValidation) {
241
254
  await guardScope(app.sdk, record);
242
255
  }
243
256
 
244
257
  const sysID = unwrapSysId(record);
245
- const recordData = parseDataArg(argv);
246
- const updated = await app.sdk.update(table, sysID, recordData);
258
+ const recordData = buildRecordPayload(argv);
259
+ const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
247
260
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
248
261
  }),
249
262
  })
250
263
  .command({
251
264
  command: 'delete <identifier>',
252
265
  describe: `Delete ${vowelArticle(singular)} ${singular}`,
253
- 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' }),
254
267
  handler: wrap(async (argv, app) => {
255
268
  const id = argv.identifier;
256
- 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 });
257
270
  if (scopeValidation) {
258
271
  await guardScope(app.sdk, record);
259
272
  }
@@ -263,19 +276,27 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
263
276
 
264
277
  await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
265
278
 
266
- await app.sdk.delete(table, sysID);
279
+ await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
267
280
  app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
268
281
  }),
269
282
  });
270
283
  }
271
284
 
272
- return y;
285
+ const help = [opts.help, extraQuery ? constrainedQueryHelp(extraQuery) : ''].filter(Boolean).join('\n');
286
+ return help ? y.epilogue(help) : y;
273
287
  };
274
288
 
275
289
  return {
276
290
  command: `${name} [subcommand]`,
277
291
  aliases: aliases || [],
278
- 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
+ },
279
300
  builder,
280
301
  handler: (_argv) => {
281
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);