@jacebenson/jsn 4.3.13 → 4.4.0-dev.113.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 (80) hide show
  1. package/README.md +137 -0
  2. package/package.json +4 -3
  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 +45 -9
  10. package/src/command-audience.js +38 -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/catalog-order.js +93 -0
  18. package/src/commands/catalog.js +8 -10
  19. package/src/commands/changes.js +2 -4
  20. package/src/commands/cmdb-search.js +38 -0
  21. package/src/commands/cmdb.js +2 -0
  22. package/src/commands/columns.js +2 -1
  23. package/src/commands/customizations.js +13 -7
  24. package/src/commands/discover.js +54 -0
  25. package/src/commands/docs/db.js +9 -1
  26. package/src/commands/docs/docs.js +25 -2
  27. package/src/commands/docs/ingest.js +20 -3
  28. package/src/commands/docs/install.js +86 -0
  29. package/src/commands/docs/locks.js +34 -0
  30. package/src/commands/docs/refresh.js +22 -2
  31. package/src/commands/docs/replace.js +30 -0
  32. package/src/commands/docs/snapshot-check.js +11 -0
  33. package/src/commands/docs/snapshot.js +130 -0
  34. package/src/commands/docs/sync.js +16 -6
  35. package/src/commands/forms.js +22 -97
  36. package/src/commands/history.js +57 -0
  37. package/src/commands/incidents.js +2 -4
  38. package/src/commands/knowledge.js +75 -0
  39. package/src/commands/midservers.js +117 -10
  40. package/src/commands/records.js +57 -104
  41. package/src/commands/requests.js +9 -15
  42. package/src/commands/rest.js +15 -2
  43. package/src/commands/scan.js +234 -0
  44. package/src/commands/scopes.js +1 -1
  45. package/src/commands/setup.js +46 -9
  46. package/src/commands/skill.js +73 -76
  47. package/src/commands/tasks.js +2 -4
  48. package/src/commands/tickets.js +20 -19
  49. package/src/commands/uipolicies.js +2 -27
  50. package/src/commands/updatesets.js +66 -13
  51. package/src/commands/upgrade.js +69 -0
  52. package/src/commands/version.js +14 -8
  53. package/src/constrained-query.js +20 -0
  54. package/src/context.js +6 -6
  55. package/src/customization-guidance.js +31 -0
  56. package/src/customizations-report.js +6 -0
  57. package/src/customizations.js +28 -13
  58. package/src/errors.js +23 -1
  59. package/src/essential-records.js +40 -0
  60. package/src/field-behavior.js +15 -0
  61. package/src/help.js +12 -2
  62. package/src/metadata/records.js +249 -0
  63. package/src/midserver-linux-remote.js +210 -0
  64. package/src/midserver-remote-recipe.js +49 -0
  65. package/src/midserver-remote.js +221 -0
  66. package/src/midserver-windows-remote.js +318 -0
  67. package/src/midserver-zip.js +2 -2
  68. package/src/mutation-context.js +74 -0
  69. package/src/mutation-history.js +75 -0
  70. package/src/mutations.js +3 -2
  71. package/src/output.js +34 -2
  72. package/src/record-command-profiles.js +70 -0
  73. package/src/record-profiles.js +93 -0
  74. package/src/records-engine.js +57 -0
  75. package/src/related-lists.js +153 -0
  76. package/src/resolve-record.js +50 -28
  77. package/src/scan.js +249 -0
  78. package/src/sdk-batch.js +130 -0
  79. package/src/sdk.js +157 -18
  80. package/src/update-set-xml.js +89 -0
@@ -1,12 +1,14 @@
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
1
2
  // Generic command builder for CRUD operations on a ServiceNow table
2
3
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
4
 
4
5
  import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
5
- import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
7
+ import { resolveSysId } from '../resolve-record.js';
6
8
  import { FormatAuto } from '../output.js';
7
9
  import { declareCapabilities } from '../capabilities.js';
8
10
 
9
- export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
11
+ export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap, opts = {}) {
10
12
  // Ticket-style CRUD: list/show are reads; create/update/delete mutate.
11
13
  // Root-level command only.
12
14
  declareCapabilities(displayName, { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -14,13 +16,14 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
14
16
  command: `${displayName} [subcommand]`,
15
17
  aliases: [table, alias],
16
18
  describe: `Manage ${displayName} (e.g. "${displayName} list --query priority=1")`,
19
+ discovery: { table, defaultColumns, benefits: ['Configured list columns', 'Ticket-specific create/update flags'] },
17
20
  builder: (yargs) => {
18
21
  return yargs
19
22
  .command({
20
23
  command: 'list',
21
24
  aliases: ['ls'],
22
25
  describe: `List ${table}`,
23
- builder: (y) => y
26
+ builder: (y) => recordOptions(y)
24
27
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
25
28
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
26
29
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
@@ -80,17 +83,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
80
83
  return;
81
84
  }
82
85
 
83
- const params = new URLSearchParams();
84
- params.set('sysparm_limit', String(limit));
85
- params.set('sysparm_offset', String(offset));
86
- params.set('sysparm_display_value', 'all');
87
86
  const fields = resolveFieldsParam(columns);
88
- if (fields) params.set('sysparm_fields', fields);
89
87
  const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
90
- params.set('sysparm_query', q);
88
+ const params = recordListParams({ limit, offset, fields, query: q });
91
89
 
92
- const records = await app.sdk.list(table, params);
93
- const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
90
+ const records = await listRecords(app.sdk, { table, params, displayValues: argv.displayValues ?? opts.referenceMode });
91
+ const displayRecords = fields && !argv.displayValues && opts.listProjection !== 'full' ? records.map(r => formatRecordForDisplay(r, columns)) : records;
94
92
 
95
93
  const breadcrumbs = [
96
94
  { action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
@@ -129,9 +127,15 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
129
127
  command: 'show <number>',
130
128
  aliases: ['get'],
131
129
  describe: `Show a specific ${displayName}`,
130
+ builder: y => {
131
+ const command = relatedListOptions(recordOptions(y));
132
+ return opts.relationships?.essential?.length ? command.option('essential', { type: 'boolean', default: true, describe: 'Fetch declared essential children; use --no-essential to skip' }) : command;
133
+ },
132
134
  handler: wrap(async (argv, app) => {
133
135
  const number = argv.number;
134
- const record = await resolveRecord(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
136
+ const record = await getRecord(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
137
+ if (opts.onShow) await opts.onShow(record, app, argv);
138
+ await enrichRelatedLists(record, { table, argv, app });
135
139
  record._context = {
136
140
  instance_url: app.getEffectiveInstance(),
137
141
  table,
@@ -148,7 +152,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
148
152
  .command({
149
153
  command: 'create',
150
154
  describe: `Create a new ${displayName}`,
151
- builder: (y) => y
155
+ builder: (y) => recordOptions(y, false)
152
156
  .option('description', { alias: 'd', type: 'string', describe: 'Short description' })
153
157
  .option('priority', { type: 'string', describe: 'Priority (1-5)' })
154
158
  .option('data', { type: 'string', describe: 'JSON data for additional fields' })
@@ -162,7 +166,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
162
166
  if (!recordData.short_description) {
163
167
  throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
164
168
  }
165
- const record = await app.sdk.create(table, recordData);
169
+ const record = await createRecord(app.sdk, { table, data: recordData });
166
170
  app.ok(record, {
167
171
  summary: `Created ${displayName} ${getStringField(record, 'number')}`,
168
172
  breadcrumbs: [
@@ -174,7 +178,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
174
178
  .command({
175
179
  command: 'update <number>',
176
180
  describe: `Update a ${displayName}`,
177
- builder: (y) => y
181
+ builder: (y) => recordOptions(y, false)
178
182
  .option('data', { type: 'string', describe: 'JSON data to update' })
179
183
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
180
184
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
@@ -182,8 +186,8 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
182
186
  handler: wrap(async (argv, app) => {
183
187
  const number = argv.number;
184
188
  const recordData = buildRecordPayload(argv);
185
- const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
186
- const updated = await app.sdk.update(table, sysID, recordData);
189
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
190
+ const updated = await updateRecord(app.sdk, { table, record: { sys_id: sysID }, data: recordData });
187
191
  app.ok(updated, {
188
192
  summary: `Updated ${displayName} ${number}`,
189
193
  breadcrumbs: [
@@ -195,12 +199,12 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
195
199
  .command({
196
200
  command: 'delete <number>',
197
201
  describe: `Delete a ${displayName}`,
198
- builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
202
+ builder: (y) => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
199
203
  handler: wrap(async (argv, app) => {
200
204
  const number = argv.number;
201
205
  await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
202
- const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
203
- await app.sdk.delete(table, sysID);
206
+ const sysID = await resolveSysId(app.sdk, { table, identifier: number, identifierField: argv.identifierField, displayValues: argv.displayValues ?? opts.referenceMode, matchField: opts.identifierField || 'number', resource: displayName });
207
+ await deleteRecord(app.sdk, { table, record: { sys_id: sysID } });
204
208
  app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
205
209
  summary: `Deleted ${displayName} ${number}`,
206
210
  });
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { confirmDelete, getStringField } from '../helpers.js';
4
+ import { errUsage } from '../errors.js';
5
+ import { declareCapabilities } from '../capabilities.js';
6
+
7
+ declareCapabilities('attachments', { mutationSubcommands: ['upload', 'add'] });
8
+ const identifier = value => {
9
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw errUsage('Supply a safe record or attachment sys_id.');
10
+ return value;
11
+ };
12
+
13
+ // A server-provided filename may never choose directories or platform devices.
14
+ export function attachmentFilename(value, id) {
15
+ const name = path.posix.basename(String(value || '').replaceAll('\\', '/'))
16
+ .replace(/[<>:"|?*\p{Cc}]/gu, '_').replace(/[. ]+$/g, '');
17
+ return !name || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name) ? `${id}.bin` : name;
18
+ }
19
+
20
+ export const attachmentsCmd = wrap => ({
21
+ command: 'attachments [subcommand]',
22
+ aliases: ['attachment'],
23
+ describe: 'List, download and upload files attached to any record',
24
+ builder: y => y
25
+ .epilogue([
26
+ 'List files: jsn attachments list --sys-id <record-id>',
27
+ 'Download by attachment ID: jsn attachments download <attachment-id> --out ./file.bin',
28
+ 'Upload: jsn attachments upload ./file.txt --table incident --sys-id <record-id>',
29
+ 'download/get and upload/add are aliases. The former records attachments subtree has moved here.',
30
+ 'Downloads use a safe basename and refuse to overwrite existing local files unless --overwrite is supplied.',
31
+ ].join('\n'))
32
+ .command({
33
+ command: 'list',
34
+ describe: 'List attachment metadata for a parent record',
35
+ builder: y => y
36
+ .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
37
+ .option('table', { type: 'string', describe: 'Parent table (output context only)' }),
38
+ handler: wrap(async (argv, app) => {
39
+ const id = identifier(argv['sys-id']);
40
+ app.requireInstance();
41
+ const attachments = await app.sdk.listAttachments(id);
42
+ app.ok({ sys_id: id, table: argv.table || '', count: attachments.length, attachments,
43
+ context: { instance_url: app.getEffectiveInstance() } }, { summary: `${attachments.length} attachment(s) on record ${id}` });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'download <attachment-id>',
48
+ aliases: ['get'],
49
+ describe: 'Download file bytes by attachment ID; no parent record ID needed',
50
+ builder: y => y
51
+ .positional('attachment-id', { type: 'string', describe: 'Attachment sys_id' })
52
+ .option('out', { alias: 'o', type: 'string', describe: 'Local output path (default: safe attachment filename in cwd)' })
53
+ .option('overwrite', { type: 'boolean', default: false, describe: 'Replace an existing local file' }),
54
+ handler: wrap(async (argv, app) => {
55
+ const id = identifier(argv.attachmentId ?? argv['attachment-id']);
56
+ app.requireInstance();
57
+ let out = argv.out;
58
+ if (!out) {
59
+ const metadata = await app.sdk.request(`${app.sdk.baseURL}/api/now/attachment/${id}`, { method: 'GET' });
60
+ out = attachmentFilename(getStringField(metadata?.result, 'file_name'), id);
61
+ }
62
+ const bytes = await app.sdk.getAttachment(id);
63
+ await fs.writeFile(out, bytes, { flag: argv.overwrite ? 'w' : 'wx' });
64
+ app.ok({ sys_id: id, out, bytes: bytes.length }, { summary: `Wrote ${bytes.length} bytes to ${out}` });
65
+ }),
66
+ })
67
+ .command({
68
+ command: 'upload <file>',
69
+ aliases: ['add'],
70
+ describe: 'Upload a local file to a record (requires confirmation)',
71
+ builder: y => y
72
+ .positional('file', { type: 'string', describe: 'Local file to upload' })
73
+ .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
74
+ .option('table', { type: 'string', demandOption: true, describe: 'Parent table, for example incident' })
75
+ .option('name', { type: 'string', describe: 'Attachment filename (default: local basename)' })
76
+ .option('force', { type: 'boolean', default: false, describe: 'Skip upload confirmation after approval' }),
77
+ handler: wrap(async (argv, app) => {
78
+ const id = identifier(argv['sys-id']);
79
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(argv.table)) throw errUsage('Supply a valid parent table name.');
80
+ app.requireInstance();
81
+ await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${id}?`);
82
+ const content = await fs.readFile(argv.file);
83
+ const fileName = argv.name || path.basename(argv.file);
84
+ const created = await app.sdk.addAttachment(argv.table, id, content, fileName);
85
+ app.ok({ sys_id: getStringField(created, 'sys_id'), file_name: fileName, table: argv.table, recordsys_id: id }, { summary: `Attached ${fileName}` });
86
+ }),
87
+ }),
88
+ });
@@ -1,8 +1,10 @@
1
+ import { detectCommandAudience } from '../command-audience.js';
1
2
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
- import { errUsage, errUsageHint } from '../errors.js';
3
+ import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
3
4
  import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
4
5
  import { declareCapabilities } from '../capabilities.js';
5
6
  import process from 'node:process';
7
+ import { readFile } from 'node:fs/promises';
6
8
  import { refreshSession } from '../session.js';
7
9
 
8
10
  function syncSession(app) {
@@ -139,7 +141,7 @@ export function resolveTargetProfile(argv = {}, cfg = {}, instanceURL = '') {
139
141
  */
140
142
  export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
141
143
  if (argv.basic || argv.password) return true;
142
- if (argv.oauth || argv.code || argv.printUrl || argv.browser || argv.gck || argv.headers) return false;
144
+ if (argv.oauth || argv.code || argv.printUrl || argv.browser || argv.gck || argv.headers || argv.headersFile !== undefined) return false;
143
145
  if (targetProfile) return targetProfile.auth_method === 'basic';
144
146
  return Object.values(cfg.profiles || {}).some(profile =>
145
147
  profile.instance_url === instanceURL && profile.auth_method === 'basic'
@@ -147,7 +149,7 @@ export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', target
147
149
  }
148
150
 
149
151
  export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
150
- if (argv.gck || argv.headers || argv.browser) return true;
152
+ if (argv.gck || argv.headers || argv.browser || argv.headersFile !== undefined) return true;
151
153
  if (argv.basic || argv.password || argv.oauth || argv.code || argv.printUrl) return false;
152
154
  if (targetProfile) return targetProfile.auth_method === 'gck';
153
155
  return Object.values(cfg.profiles || {}).some(profile =>
@@ -157,10 +159,11 @@ export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetPr
157
159
 
158
160
  export function validateLoginSelectors(argv = {}, targetProfile = null) {
159
161
  const basic = argv.basic === true || argv.password === true;
160
- const gck = argv.gck === true || argv.headers !== undefined || argv.browser === true;
162
+ const gck = argv.gck === true || argv.headers !== undefined || argv.headersFile !== undefined || argv.browser === true;
161
163
  const oauth = argv.oauth === true || argv.code !== undefined || argv.printUrl === true || argv.waitFile !== undefined;
162
164
  if (oauth && (basic || gck)) throw errUsage('Choose only one authentication method: --oauth, --basic, or --browser/--gck.');
163
165
  if (argv.waitFile && !argv.printUrl) throw errUsage('--wait-file requires --print-url.');
166
+ if (argv.headers !== undefined && argv.headersFile !== undefined) throw errUsage('Choose --headers-file or --headers, not both.');
164
167
  if (argv.code !== undefined && argv.printUrl) throw errUsage('--code cannot be combined with --print-url.');
165
168
  if (argv.browserUrl !== undefined && !argv.browser) {
166
169
  throw errUsage('--browser-url requires --browser.');
@@ -168,8 +171,8 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
168
171
  if (argv.browserName !== undefined && (!argv.browser || argv.browserUrl !== undefined)) {
169
172
  throw errUsage('--browser-name requires --browser and cannot be combined with --browser-url.');
170
173
  }
171
- if (argv.browser && (argv.headers !== undefined || argv.code !== undefined || argv.printUrl || argv.waitFile)) {
172
- throw errUsage('--browser cannot be combined with --headers, --code, --print-url, or --wait-file.');
174
+ if (argv.browser && (argv.headers !== undefined || argv.headersFile !== undefined || argv.code !== undefined || argv.printUrl || argv.waitFile)) {
175
+ throw errUsage('--browser cannot be combined with --headers-file, --headers, --code, --print-url, or --wait-file.');
173
176
  }
174
177
  if (basic && gck) {
175
178
  throw errUsage('Basic and browser-session selectors are mutually exclusive; choose --basic/--password or --gck/--headers.');
@@ -278,6 +281,22 @@ export async function readBrowserSessionInput() {
278
281
  });
279
282
  }
280
283
 
284
+ /** Read captured session data without putting it in argv or printing a prompt. */
285
+ async function readBrowserSessionFile(file) {
286
+ let content;
287
+ if (file === '-') {
288
+ if (process.stdin.isTTY) throw errUsageHint('--headers-file - requires piped or redirected input.', 'Pipe captured headers into this command, or pass a file path.');
289
+ const chunks = [];
290
+ for await (const chunk of process.stdin) chunks.push(chunk);
291
+ content = Buffer.concat(chunks).toString('utf8');
292
+ } else {
293
+ try { content = await readFile(file, 'utf8'); }
294
+ catch { throw errUsageHint('Could not read browser-session input file.', 'Check the --headers-file path and file permissions.'); }
295
+ }
296
+ if (!content.trim()) throw errUsage('Browser-session input is empty. Supply captured headers or a cURL request.');
297
+ return content;
298
+ }
299
+
281
300
  /** Collect human answers, then call the same login operation as auth login. */
282
301
  export async function loginWizard(app, argv = {}) {
283
302
  const { input, select, confirm, password } = await import('../prompts.js');
@@ -382,13 +401,18 @@ export async function modifyProfile(app, argv = {}) {
382
401
  const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
383
402
  const profile = app.config.profiles?.[name];
384
403
  if (!profile) throw new Error(`Profile not found: ${name}`);
385
- const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts'].some(flag => argv[flag] !== undefined);
404
+ if (argv.reauth !== undefined && !['auto', 'confirm', 'never'].includes(argv.reauth)) {
405
+ throw errUsage('--reauth must be auto, confirm, or never.');
406
+ }
407
+ const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts', 'reauth'].some(flag => argv[flag] !== undefined);
386
408
  if (explicitSettings) {
387
409
  if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
388
410
  const updated = await configureProfileOptions({ ...profile }, argv);
411
+ if (argv.reauth !== undefined) updated.reauth = argv.reauth;
389
412
  app.config.profiles[name] = updated;
390
413
  await saveConfig(app.config);
391
- app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false },
414
+ app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false,
415
+ ...(argv.reauth !== undefined ? { reauth: updated.reauth } : {}) },
392
416
  { summary: `Settings saved for ${name}` });
393
417
  return;
394
418
  }
@@ -588,8 +612,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
588
612
  const useBasic = shouldUseBasicAuth(argv, methodConfig, instanceURL, targetProfile);
589
613
  const useGck = shouldUseGckAuth(argv, methodConfig, instanceURL, targetProfile);
590
614
  const interactive = canUsePicker(app, argv);
591
- if (useGck && !argv.browser && !argv.headers && !interactive) {
592
- throw errUsage('Browser sign-in needs a human. Pass --browser to explicitly open a login window, or supply a captured session.');
615
+ const capturedHeaders = argv.headers !== undefined || argv.headersFile !== undefined;
616
+ if (useGck && !argv.browser && !capturedHeaders && !interactive) {
617
+ throw errUsage('Browser sign-in needs a human. Pass --browser to explicitly open a login window, or supply a captured session with --headers-file <path|->.');
593
618
  }
594
619
  if (!useGck && !useBasic && !argv.printUrl && !argv.code && !interactive) {
595
620
  throw errUsage('OAuth login requires --print-url followed by --code <code> when noninteractive.');
@@ -606,9 +631,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
606
631
  // --basic (with --password as a compatibility alias): authenticate
607
632
  // with basic auth from env vars and persist it for this profile.
608
633
  if (useGck) {
609
- const credentials = (argv.browser || (!argv.gck && !argv.headers))
634
+ const credentials = (argv.browser || (!argv.gck && !capturedHeaders))
610
635
  ? await loginUsingBrowser(app, instanceURL, argv, targetUsername || null)
611
- : await app.auth.loginWithGck(instanceURL, argv.headers || await readBrowserSessionInput(), targetUsername || null);
636
+ : await app.auth.loginWithGck(instanceURL, argv.headersFile !== undefined ? await readBrowserSessionFile(argv.headersFile) : argv.headers || await readBrowserSessionInput(), targetUsername || null);
612
637
  targetUsername = credentials?.username || targetUsername;
613
638
  } else if (useBasic) {
614
639
  let credentials;
@@ -686,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
686
711
  // profile exists; preserve it when verification is unavailable.
687
712
  username = username || targetUsername;
688
713
 
714
+ const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
715
+
689
716
  // Save to profiles — deduplicate by instance URL
690
717
  if (!app.config.profiles) {
691
718
  app.config.profiles = {};
@@ -716,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
716
743
  read_only: preferences.read_only,
717
744
  skip_confirmations: preferences.skip_confirmations,
718
745
  include_counts: preferences.include_counts,
746
+ ...(commandAudience ? { command_audience: commandAudience } : {}),
719
747
  };
748
+ if (!commandAudience) delete app.config.profiles[profileName].command_audience;
720
749
  if (!targetProfile && interactive) {
721
750
  try {
722
751
  const domains = await import('./domains.js');
@@ -803,10 +832,33 @@ Examples:
803
832
  }
804
833
  const target = resolveTargetProfile(argv, app.config, instanceURL);
805
834
  const username = target.profile?.username ?? null;
806
- if (username || canLogoutBareProfile(app, instanceURL, target.profile, !!argv.profile)) {
807
- app.auth.logout(instanceURL, username);
835
+ if (!username && !canLogoutBareProfile(app, instanceURL, target.profile, !!target.name)) {
836
+ throw errUsageHint('Cannot identify which stored credentials to clear.', 'Choose an unambiguous identity with --profile <name>.');
837
+ }
838
+ const selector = target.name ? `--profile ${quoteShellArg(target.name)}` : quoteShellArg(instanceURL);
839
+ const rerun = `jsn auth logout ${selector}`;
840
+ const warning = `Clear locally stored credentials for ${target.name ? `profile "${target.name}" at ` : ''}${instanceURL}${username ? ` (user ${username})` : ''}? You will need to authenticate again. The profile is retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`;
841
+ if (!argv.force) {
842
+ if (!canUsePicker(app, argv)) throw errConfirmationRequired(warning, rerun);
843
+ const { confirm } = await import('../prompts.js');
844
+ if (!await confirm({ message: warning, default: false })) {
845
+ app.ok({ logged_out: false, credentials_cleared: false, profile: target.name, instance: instanceURL },
846
+ { summary: 'Logout cancelled; stored credentials were retained.' });
847
+ return;
848
+ }
808
849
  }
809
- app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
850
+ const cleared = app.auth.logout(instanceURL, username) === true;
851
+ const nextActions = cleared ? [
852
+ { action: 'Authenticate again', cmd: `jsn auth login ${selector}`, description: 'Restore locally stored authentication' },
853
+ ...(target.name ? [{ action: 'Remove retained profile', cmd: `jsn auth remove ${quoteShellArg(target.name)}`, description: 'Remove this connection if no longer needed' }] : []),
854
+ ] : [];
855
+ app.ok({ logged_out: cleared, credentials_cleared: cleared, profile: target.name, instance: instanceURL,
856
+ profile_retained: !!target.name, environment_credentials_affected: false, remote_session_terminated: false,
857
+ next_actions: nextActions }, {
858
+ summary: cleared ? `✓ Cleared local credentials for ${target.name || instanceURL}. Profile retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`
859
+ : `No stored credentials were cleared for ${target.name || instanceURL}. Environment credentials and ServiceNow/Chrome sessions are unaffected.`,
860
+ breadcrumbs: nextActions,
861
+ });
810
862
  }
811
863
 
812
864
  export async function showAuthStatus(argv, app) {
@@ -966,10 +1018,16 @@ Examples:
966
1018
 
967
1019
  const target = resolveTargetProfile(argv, app.config, instanceURL);
968
1020
  const targetProfile = target.profile;
1021
+ const browser = argv.browser || (targetProfile?.auth_method === 'gck'
1022
+ && argv.headers === undefined && argv.headersFile === undefined && canUsePicker(app, argv));
1023
+ validateLoginSelectors({ ...argv, browser }, targetProfile);
1024
+ if (targetProfile?.auth_method !== 'gck' && (argv.headersFile !== undefined || argv.headers !== undefined || argv.browser)) {
1025
+ throw errUsage('Browser-session input requires a browser-session profile. Select it with --profile, or add a separate connection with auth login --name <name>.');
1026
+ }
969
1027
  if (targetProfile?.auth_method === 'gck') {
970
1028
  return loginProfile({
971
1029
  ...argv, instance: instanceURL, profile: target.name,
972
- browser: argv.browser || (!argv.headers && canUsePicker(app, argv)),
1030
+ browser,
973
1031
  }, app);
974
1032
  }
975
1033
  const targetAuthOptions = {
@@ -997,12 +1055,22 @@ Examples:
997
1055
  const name = target.name || app.config.activeProfile || app.config.defaultProfile;
998
1056
  if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
999
1057
  app.config.profiles[name].domain_separation = hasDS;
1058
+
1000
1059
  await saveConfig(app.config);
1001
1060
  }
1002
1061
  } catch {
1003
1062
  // Non-fatal — capability probe is best-effort
1004
1063
  }
1005
1064
 
1065
+ const audienceName = target.name || app.config.activeProfile || app.config.defaultProfile;
1066
+ const audienceProfile = app.config.profiles?.[audienceName];
1067
+ if (audienceProfile?.instance_url === instanceURL) {
1068
+ const sdk = app.getSDKForProfile
1069
+ ? app.getSDKForProfile(instanceURL, { ...targetAuthOptions, profile: audienceProfile }) : app.sdk;
1070
+ audienceProfile.command_audience = await detectCommandAudience(sdk, audienceProfile.username, instanceURL);
1071
+ await saveConfig(app.config);
1072
+ }
1073
+
1006
1074
  app.ok({
1007
1075
  refreshed: true,
1008
1076
  instance: instanceURL,
@@ -1055,7 +1123,11 @@ export function authCmd(wrap) {
1055
1123
  type: 'boolean',
1056
1124
  })
1057
1125
  .option('headers', {
1058
- describe: 'Browser request headers or cURL command for browser-session auth',
1126
+ describe: 'Inline browser headers (compatibility); prefer --headers-file to keep secrets out of arguments',
1127
+ type: 'string',
1128
+ })
1129
+ .option('headers-file', {
1130
+ describe: 'Read browser headers/cURL from a file, or - for stdin; no secrets in arguments',
1059
1131
  type: 'string',
1060
1132
  })
1061
1133
  .option('browser', {
@@ -1094,7 +1166,8 @@ export function authCmd(wrap) {
1094
1166
  })
1095
1167
  .command({
1096
1168
  command: 'logout [instance]',
1097
- describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
1169
+ describe: 'Clear local credentials after approval; retains the profile. Unnecessary after ordinary operations; does not sign out of ServiceNow/Chrome or clear environment credentials.',
1170
+ builder: sub => sub.option('force', { type: 'boolean', describe: 'Clear credentials only after explicit approval for this exact logout' }),
1098
1171
  handler: wrap(logoutProfile),
1099
1172
  })
1100
1173
  .command({
@@ -1114,7 +1187,8 @@ export function authCmd(wrap) {
1114
1187
  .option('browser', { type: 'boolean', describe: 'Explicitly open a browser to renew the session' })
1115
1188
  .option('browser-name', { type: 'string', describe: 'Browser to launch: chromium, chrome, or edge' })
1116
1189
  .option('browser-url', { type: 'string', describe: 'Advanced: local browser debugging URL (requires --browser)' })
1117
- .option('headers', { type: 'string', describe: 'Captured browser request headers or cURL command' }),
1190
+ .option('headers', { type: 'string', describe: 'Inline captured headers (compatibility); prefer --headers-file' })
1191
+ .option('headers-file', { type: 'string', describe: 'Captured headers/cURL file, or - for stdin; keeps secrets out of arguments' }),
1118
1192
  handler: wrap(refreshProfile),
1119
1193
  })
1120
1194
  .command({
@@ -1134,6 +1208,7 @@ export function authCmd(wrap) {
1134
1208
  .option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
1135
1209
  .option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
1136
1210
  .option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
1211
+ .option('reauth', { type: 'string', choices: ['auto', 'confirm', 'never'], describe: 'Browser recovery policy: auto, confirm (default), or never; interactive only' })
1137
1212
  .option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
1138
1213
  .positional('name', {
1139
1214
  describe: 'Profile name to modify',
@@ -1,5 +1,10 @@
1
1
  import { buildDevCmd } from './_generic.js';
2
2
 
3
- // Before-query business rules — applies filters to GlideRecord queries before they execute.
4
- // These are sys_script records with order < 100.
5
- export const b4rulesCmd = (wrap) => buildDevCmd('b4rules', 'sys_script', ['b4rule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'before-query business rule', scopeValidation: true, extraQuery: 'when=before' });
3
+ // Query rules are explicitly marked; their order is not a classification rule.
4
+ export const b4rulesCmd = wrap => buildDevCmd('b4rules', 'sys_script', ['b4rule'],
5
+ ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, {
6
+ singular: 'before-query business rule', scopeValidation: true,
7
+ extraQuery: 'when=before^action_query=true',
8
+ describe: 'Inspect before-query business rules that can filter record reads',
9
+ help: 'Before-query business rules use when=before and the Boolean action_query=true. Filter collection and active status for the affected table. For field behavior, see jsn columns --help; do not bypass server rules or ACLs.',
10
+ });
@@ -0,0 +1,93 @@
1
+ import { parseDataArg, confirmDelete, quoteShellArg } from '../helpers.js';
2
+ import { errAPI, errUsage } from '../errors.js';
3
+
4
+ const ID = /^[a-f0-9]{32}$/i;
5
+ const API = '/api/sn_sc/servicecatalog/items';
6
+ const object = v => v && typeof v === 'object' && !Array.isArray(v);
7
+
8
+ function emit(app, argv, data, summary, breadcrumbs = []) {
9
+ const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
10
+ app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
11
+ }
12
+ function requireId(id) { if (!ID.test(id || '')) throw errUsage('Use the full catalog item sys_id; find it with catalogitems search.'); }
13
+ async function describe(app, id) {
14
+ requireId(id);
15
+ const response = await app.sdk.request(`${app.sdk.baseURL}${API}/${id}`, { method: 'GET' });
16
+ if (!object(response?.result) || response.result.sys_id !== id) throw errAPI(200, 'Catalog API returned no readable item with the requested identity.');
17
+ return response.result;
18
+ }
19
+ function itemHints(id) {
20
+ return [{ action: 'describe', cmd: `jsn catalogitems describe ${id}`, description: 'Inspect catalog inputs and pricing' },
21
+ { action: 'preview', cmd: `jsn catalogitems order ${id} --dry-run --variables-file variables.json`, description: 'Preview an order without submitting' }];
22
+ }
23
+
24
+ export function catalogOrderCommands(wrap) {
25
+ return [
26
+ { command: 'search <text>', describe: 'Find requestable items through the Service Catalog API',
27
+ builder: y => y.positional('text', { type: 'string' }).option('limit', { type: 'number', default: 20, describe: 'Maximum items (1–100)' }),
28
+ handler: wrap(async (argv, app) => {
29
+ if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty catalog search text.');
30
+ if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
31
+ const params = new URLSearchParams({ sysparm_text: argv.text.trim(), sysparm_limit: String(argv.limit) });
32
+ const response = await app.sdk.request(`${app.sdk.baseURL}${API}?${params}`, { method: 'GET' });
33
+ if (!Array.isArray(response?.result) || response.result.some(i => !object(i) || !ID.test(i.sys_id || ''))) throw errAPI(200, 'Catalog API returned no valid item list.');
34
+ emit(app, argv, { items: response.result, count: response.result.length, limit: argv.limit,
35
+ possibly_truncated: response.result.length === argv.limit }, `${response.result.length} catalog item(s)`,
36
+ response.result.flatMap(i => itemHints(i.sys_id).slice(0, 1)));
37
+ }) },
38
+ { command: 'describe <id>', aliases: ['inputs'], describe: 'Read requestable item details, pricing and variable definitions',
39
+ builder: y => y.positional('id', { type: 'string' }),
40
+ handler: wrap(async (argv, app) => {
41
+ const item = await describe(app, argv.id);
42
+ emit(app, argv, item, item.name || item.short_description || argv.id, itemHints(argv.id));
43
+ }) },
44
+ { command: 'order <id>', describe: 'Submit a catalog order with explicit confirmation',
45
+ builder: y => y.positional('id', { type: 'string' })
46
+ .option('variables', { type: 'string', describe: 'JSON object of catalog variable names/values' })
47
+ .option('variables-file', { type: 'string', describe: 'Read the variables object from a JSON file' })
48
+ .option('quantity', { type: 'number', default: 1, describe: 'Positive integer quantity (max 1000)' })
49
+ .option('requested-for', { type: 'string', describe: 'User sys_id; instance delegation rules still apply' })
50
+ .option('dry-run', { type: 'boolean', default: false, describe: 'Read item details and preview variable names; no order is submitted' })
51
+ .epilogue('Use describe to inspect variables first. Server-side rules validate the order; a preview does not prove submission will succeed. Existing create/list/show commands manage definitions. Orders require a write-enabled profile, including dry runs. No application/update-set selection is needed.'),
52
+ handler: wrap(async (argv, app) => {
53
+ requireId(argv.id);
54
+ if (!Number.isInteger(argv.quantity) || argv.quantity < 1 || argv.quantity > 1000) throw errUsage('--quantity must be an integer from 1 to 1000.');
55
+ const requestedFor = argv.requestedFor ?? argv['requested-for'];
56
+ if (requestedFor !== undefined && !ID.test(requestedFor)) throw errUsage('--requested-for requires a user sys_id.');
57
+ const file = argv.variablesFile ?? argv['variables-file'];
58
+ if (argv.variables !== undefined && file !== undefined) throw errUsage('Choose --variables or --variables-file, not both.');
59
+ const variables = argv.variables !== undefined || file !== undefined
60
+ ? parseDataArg({ data: argv.variables, 'data-file': file }) : {};
61
+ if (!object(variables)) throw errUsage('Catalog variables must be a JSON object.');
62
+ const item = await describe(app, argv.id);
63
+ if (item.sys_class_name && item.sys_class_name !== 'sc_cat_item') throw errUsage('order supports standard catalog items; record producers and order guides need their own submission workflows.');
64
+ if (item.active === false || item.active === 'false' || item.can_order === false) throw errUsage('This item is not currently orderable.');
65
+ const plan = { item_id: argv.id, name: item.name || item.short_description, quantity: argv.quantity,
66
+ requested_for: requestedFor || null, variable_names: Object.keys(variables) };
67
+ if (argv.dryRun ?? argv['dry-run']) return emit(app, argv, { ...plan, submitted: false, server_validation: 'not performed' }, 'Catalog order preview; nothing submitted', itemHints(argv.id));
68
+ const rerun = ['catalogitems', 'order', argv.id, '--quantity', String(argv.quantity),
69
+ ...(requestedFor ? ['--requested-for', requestedFor] : []),
70
+ ...(file ? ['--variables-file', file] : Object.keys(variables).length ? ['--variables-file', '<variables.json>'] : [])];
71
+ try {
72
+ await confirmDelete(app, argv, `Order ${argv.quantity} × ${plan.name || argv.id}${requestedFor ? ` for ${requestedFor}` : ''}?`, { rerun });
73
+ } catch (error) {
74
+ if (error.message === 'Deletion cancelled') throw errUsage('Catalog order cancelled.');
75
+ throw error;
76
+ }
77
+ const body = { sysparm_quantity: argv.quantity, variables, ...(requestedFor ? { sysparm_requested_for: requestedFor } : {}) };
78
+ let response;
79
+ try {
80
+ response = await app.sdk.request(`${app.sdk.baseURL}${API}/${argv.id}/order_now`, { method: 'POST', body: JSON.stringify(body) });
81
+ } catch (error) {
82
+ if (error.status >= 400 && error.status < 500 && error.status !== 408) throw errAPI(error.status, 'Catalog order was rejected. Check item access, required variables and requested-for eligibility.');
83
+ throw errAPI(error.status || 0, 'Catalog submission did not return a confirmed order. Inspect your requests before retrying; the order may have been placed.');
84
+ }
85
+ const result = response?.result;
86
+ const requestId = result?.request_id || result?.sys_id, number = result?.request_number || result?.number;
87
+ if (!object(result) || result.error || !ID.test(requestId || '') || typeof number !== 'string' || !number.trim()) throw errAPI(200, 'Catalog response did not confirm an order identity. Inspect your requests before retrying; do not resubmit automatically.');
88
+ emit(app, argv, { item_id: argv.id, quantity: argv.quantity, request_id: requestId, request_number: number, submitted: true },
89
+ `Ordered: ${number}`, [{ action: 'request', cmd: `jsn records get --table sc_request --sys-id ${requestId}`, description: 'Read the request header' },
90
+ { action: 'items', cmd: `jsn requests list --query ${quoteShellArg(`request=${requestId}`)}`, description: 'Track the requested items' }]);
91
+ }) },
92
+ ];
93
+ }
@@ -1,9 +1,10 @@
1
- import { getStringField, interactiveList, resolveItemOptionType } from '../helpers.js';
1
+ import { getStringField, interactiveList, resolveItemOptionType, formatCommandList, quoteShellArg } from '../helpers.js';
2
+ import { catalogOrderCommands } from './catalog-order.js';
2
3
  import { resolveSysId } from '../resolve-record.js';
3
4
  import { declareCapabilities } from '../capabilities.js';
4
5
  import { enrichCatalogItem, buildCatalogFormatted } from './catalog/enrich.js';
5
6
 
6
- declareCapabilities('catalogitems', { mutationSubcommands: ['create'] });
7
+ declareCapabilities('catalogitems', { mutationSubcommands: ['create', 'order'], mutationContextExemptSubcommands: ['order'], localPreviewSubcommands: ['order'] });
7
8
 
8
9
  export function catalogCmd(wrap) {
9
10
  return {
@@ -11,6 +12,7 @@ export function catalogCmd(wrap) {
11
12
  aliases: ['ci'],
12
13
  describe: 'Manage Service Catalog items',
13
14
  builder: (yargs) => {
15
+ for (const command of catalogOrderCommands(wrap)) yargs.command(command);
14
16
  return yargs
15
17
  .command({
16
18
  command: 'create',
@@ -148,14 +150,10 @@ export function catalogCmd(wrap) {
148
150
  }),
149
151
  });
150
152
  },
151
- handler: () => {
152
- console.log('Service Catalog management.');
153
- console.log('');
154
- console.log('Commands:');
155
- console.log(' create Create a catalog item');
156
- console.log(' list Browse catalog items');
157
- console.log(' show <id> Show catalog item');
158
- },
153
+ handler: wrap(async (argv, app) => app.ok({ subcommands: argv.subcommands || [],
154
+ _formatted: formatCommandList({ title: 'Service Catalog', commands: argv.subcommands || [] }) },
155
+ { summary: 'Service Catalog: search, describe and order items; manage definitions with create/list/show',
156
+ breadcrumbs: [{ action: 'search', cmd: 'jsn catalogitems search <text>' + (argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : ''), description: 'Find requestable items' }] })),
159
157
  };
160
158
  }
161
159
 
@@ -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
  }