@jacebenson/jsn 4.3.13-dev.92.1 → 4.3.13-dev.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,3 +78,17 @@ npm test
78
78
  ## License
79
79
 
80
80
  MIT
81
+
82
+
83
+ ### Commands for the connected account
84
+
85
+ Login and OAuth refresh check for the current account's `admin` role. Profiles
86
+ without that role show a compact command list focused on tickets, generic
87
+ records, people/groups, documentation, and connection management. If the role
88
+ check is unavailable, the profile also uses compact help; this does not assert
89
+ that the account lacks admin. Existing profiles keep full help until renewed.
90
+
91
+ This applies to root help, root JSON, `discover`, and root shell completion.
92
+ Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
93
+ every registered command. Explicit command paths remain available and ServiceNow
94
+ ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.92.1",
3
+ "version": "4.3.13-dev.94.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -332,6 +332,10 @@ export class LocalCLI {
332
332
  }
333
333
 
334
334
  _help(node, path = []) {
335
+ if (!path.length && this.rootHelpRenderer) {
336
+ process.stdout.write(this.rootHelpRenderer() + "\n");
337
+ return;
338
+ }
335
339
  const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
336
340
  if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
337
341
  if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
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';
@@ -19,6 +20,7 @@ import { discoverCmd } from './commands/discover.js';
19
20
  import { setupCmd } from './commands/setup.js';
20
21
  import { authCmd } from './commands/auth.js';
21
22
  import { recordsCmd } from './commands/records.js';
23
+ import { attachmentsCmd } from './commands/attachments.js';
22
24
  import { cmdbCmd } from './commands/cmdb.js';
23
25
  import { snippetsCmd } from './commands/snippets.js';
24
26
  import { catalogCmd } from './commands/catalog.js';
@@ -147,6 +149,7 @@ export function buildCLI() {
147
149
  },
148
150
  ])
149
151
  .options({
152
+ 'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
150
153
  reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
151
154
 
152
155
  force: {
@@ -409,6 +412,7 @@ export function buildCLI() {
409
412
  // DATA
410
413
  .command(cmdbCmd(wrap))
411
414
  .command(recordsCmd(wrap))
415
+ .command(attachmentsCmd(wrap))
412
416
  .command(transactionsCmd(wrap))
413
417
 
414
418
  .command(platformCmd(wrap))
@@ -430,7 +434,7 @@ export function buildCLI() {
430
434
  .command(skillCmd(wrap))
431
435
  .command(docsCmd(wrap))
432
436
  .command(versionCmd(wrap))
433
- .command(discoverCmd(wrap, () => cliInstance.commandRegistry()))
437
+ .command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
434
438
  .demandCommand(1, 'You must specify a command')
435
439
  // Completion plumbing under a hidden command name, so the explicit
436
440
  // 'completion' command below owns the UX. (#176)
@@ -450,6 +454,12 @@ export function buildCLI() {
450
454
  if (current && !current.startsWith('-') && depth === 0) {
451
455
  out = [...new Set([...out, ...rootCommands])];
452
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
+ }
453
463
  done([...new Set(out)]);
454
464
  });
455
465
  })
@@ -486,7 +496,7 @@ export function buildCLI() {
486
496
  })
487
497
  .onParentDiscovery((argv, { path, subcommands }) => {
488
498
  // Reached only for a parent command that declares no default handler of
489
- // its own (records attachments, docs community, and any future parent) —
499
+ // its own (attachments, docs community, and any future parent) —
490
500
  // the adapter used to fall through and exit 0 having printed nothing.
491
501
  // Children come from the registry, description included, so this list
492
502
  // cannot drift from the registrations the way a hand-written one did.
@@ -555,7 +565,7 @@ export function buildCLI() {
555
565
  if (typeof formatArgv?.get === 'string' && formatArgv.get) {
556
566
  app.output.setJqFilter(formatArgv.get);
557
567
  }
558
- const { commands } = cliInstance.commandRegistry();
568
+ const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
559
569
  try {
560
570
  app.ok({ commands }, {
561
571
  summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
@@ -565,7 +575,7 @@ export function buildCLI() {
565
575
  }
566
576
  process.exit(0);
567
577
  }
568
- process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
578
+ process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
569
579
  process.exit(0);
570
580
  }
571
581
  // Every other failure here (unknown command, missing required argument,
@@ -585,6 +595,12 @@ export function buildCLI() {
585
595
  exitWithError(new AppError(CodeUsage, msg, hint), { app });
586
596
  });
587
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
+
588
604
  // Snapshot the root command names (plus aliases) for the completion
589
605
  // filter. Done before any parse — command builders mutate the registry.
590
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
+ }
@@ -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,3 +1,4 @@
1
+ import { detectCommandAudience } from '../command-audience.js';
1
2
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
3
  import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
3
4
  import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
@@ -710,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
710
711
  // profile exists; preserve it when verification is unavailable.
711
712
  username = username || targetUsername;
712
713
 
714
+ const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
715
+
713
716
  // Save to profiles — deduplicate by instance URL
714
717
  if (!app.config.profiles) {
715
718
  app.config.profiles = {};
@@ -740,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
740
743
  read_only: preferences.read_only,
741
744
  skip_confirmations: preferences.skip_confirmations,
742
745
  include_counts: preferences.include_counts,
746
+ ...(commandAudience ? { command_audience: commandAudience } : {}),
743
747
  };
748
+ if (!commandAudience) delete app.config.profiles[profileName].command_audience;
744
749
  if (!targetProfile && interactive) {
745
750
  try {
746
751
  const domains = await import('./domains.js');
@@ -1050,12 +1055,22 @@ Examples:
1050
1055
  const name = target.name || app.config.activeProfile || app.config.defaultProfile;
1051
1056
  if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
1052
1057
  app.config.profiles[name].domain_separation = hasDS;
1058
+
1053
1059
  await saveConfig(app.config);
1054
1060
  }
1055
1061
  } catch {
1056
1062
  // Non-fatal — capability probe is best-effort
1057
1063
  }
1058
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
+
1059
1074
  app.ok({
1060
1075
  refreshed: true,
1061
1076
  instance: instanceURL,
@@ -35,7 +35,7 @@ export function discoverCmd(wrap, getRegistry) {
35
35
  'Use --json for structured results or --json --get data.commands for only matches.',
36
36
  ].join('\n')),
37
37
  handler: wrap(async (argv, app) => {
38
- const commands = discoverCommands(getRegistry(), { query: argv.query, table: argv.table });
38
+ const commands = discoverCommands(getRegistry(argv), { query: argv.query, table: argv.table });
39
39
  const lines = commands.flatMap(command => [
40
40
  `${command.name}: ${command.description}`,
41
41
  ...(command.discovery ? [
@@ -1,5 +1,3 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, verifyWriteBack } from '../helpers.js';
4
2
  import { listRecords, recordListParams, getRecord, createRecord, updateRecord, deleteRecord } from '../records-engine.js';
5
3
  import { unwrapSysId } from '../resolve-record.js';
@@ -7,7 +5,7 @@ import { errUsage } from '../errors.js';
7
5
  import { declareCapabilities } from '../capabilities.js';
8
6
 
9
7
  declareCapabilities('records', {
10
- mutationSubcommands: ['create', 'update', 'delete', 'bulk', 'attachments add'],
8
+ mutationSubcommands: ['create', 'update', 'delete', 'bulk'],
11
9
  });
12
10
 
13
11
  const tableDefaultColumns = {
@@ -304,74 +302,6 @@ export function recordsCmd(wrap) {
304
302
  app.ok({ message: 'Record deleted', table: argv.table, sys_id: sysID }, { summary: `Deleted record from ${argv.table}` });
305
303
  }),
306
304
  })
307
- .command({
308
- command: 'attachments',
309
- describe: 'Work with attachments on a record',
310
- builder: (y) => y
311
- .command({
312
- command: 'list',
313
- describe: 'List attachments on a record',
314
- builder: (yy) => yy
315
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
316
- .option('table', { type: 'string', describe: 'Parent table name (context only)' }),
317
- handler: wrap(async (argv, app) => {
318
- app.requireInstance();
319
- const attachments = await app.sdk.listAttachments(argv['sys-id']);
320
- app.ok({
321
- sys_id: argv['sys-id'],
322
- table: argv.table || '',
323
- count: attachments.length,
324
- attachments,
325
- context: { instance_url: app.getEffectiveInstance() },
326
- }, { summary: `${attachments.length} attachment(s) on record ${argv['sys-id']}` });
327
- }),
328
- })
329
- .command({
330
- command: 'get <attachment-id>',
331
- describe: 'Download an attachment to a local file',
332
- builder: (yy) => yy
333
- .positional('attachment-id', { describe: 'Attachment sys_id', type: 'string' })
334
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id (used to resolve the file name)' })
335
- .option('out', { alias: 'o', type: 'string', describe: 'Output file path (default: attachment file name in cwd)' }),
336
- handler: wrap(async (argv, app) => {
337
- app.requireInstance();
338
- const buf = await app.sdk.getAttachment(argv['attachment-id']);
339
- let name = argv.out;
340
- if (!name) {
341
- // Best-effort: derive the file name from the attachment row.
342
- let fileName = `${argv['attachment-id']}.bin`;
343
- try {
344
- const rows = await app.sdk.listAttachments(argv['sys-id']);
345
- const hit = (rows || []).find((a) => getStringField(a, 'sys_id') === argv['attachment-id']);
346
- const fn = getStringField(hit, 'file_name');
347
- if (fn) fileName = fn;
348
- } catch { /* keep default */ }
349
- name = fileName;
350
- }
351
- await fs.promises.writeFile(name, buf);
352
- app.ok({ sys_id: argv['attachment-id'], out: name, bytes: buf.length }, { summary: `Wrote ${buf.length} bytes to ${name}` });
353
- }),
354
- })
355
- .command({
356
- command: 'add <file>',
357
- describe: 'Upload a file as a new attachment on a record (mutation)',
358
- builder: (yy) => yy
359
- .positional('file', { describe: 'Local file path to upload', type: 'string' })
360
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
361
- .option('table', { type: 'string', demandOption: true, describe: 'Parent table for the record (e.g. incident)' })
362
- .option('name', { type: 'string', describe: 'Display file name (default: basename of --file)' })
363
- .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
364
- handler: wrap(async (argv, app) => {
365
- app.requireInstance();
366
- await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${argv['sys-id']}?`);
367
- const content = await fs.promises.readFile(argv.file);
368
- const fileName = argv.name || path.basename(argv.file);
369
- const created = await app.sdk.addAttachment(argv.table, argv['sys-id'], content, fileName);
370
- app.ok({ sys_id: created?.sys_id, file_name: fileName, table: argv.table, recordsys_id: argv['sys-id'] }, { summary: `Attached ${fileName}` });
371
- }),
372
- })
373
- .demandCommand(1, 'Specify an attachment action: list, get, or add'),
374
- })
375
305
  .command({
376
306
  command: 'aggregate',
377
307
  describe: 'Run ServiceNow Stats API aggregation with optional grouping',
package/src/help.js CHANGED
@@ -203,6 +203,8 @@ function renderFlags(options) {
203
203
  export function renderTips() {
204
204
  return `
205
205
  TIPS
206
+ Show every command, including admin and developer tools: "jsn --all-commands --help"
207
+ Command visibility follows the selected profile at authentication; explicit commands still use ServiceNow ACLs.
206
208
  Find commands by task words: "jsn discover catalog"; by configured table: "jsn discover --table incident"
207
209
  Cross-table task searches and custom tables: "jsn records list --help"
208
210
  Specialized commands offer configured columns, lookup, and table-specific views; inspect discovery results for details
package/src/mutations.js CHANGED
@@ -33,7 +33,7 @@ function currentPaths(paths) {
33
33
  * Prefix match: a command whose argv._ path begins with a mutation pattern
34
34
  * IS that mutation (trailing positionals like a sys_id or file path don't
35
35
  * change the intent). This lets positional mutation subcommands (e.g.
36
- * `records attachments <id> add <file>`) be gated on read-only profiles.
36
+ * `attachments upload <file>`) be gated on read-only profiles.
37
37
  *
38
38
  * @param {object} argv — yargs parsed argv with `_` array
39
39
  * @param {Array<string[]>} [paths] — mutation path patterns; defaults to