@jacebenson/jsn 4.0.2 → 4.0.4

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
@@ -14,11 +14,27 @@ Node.js 18+. macOS, Linux, Windows.
14
14
 
15
15
  The install also copies an AI agent skill file to `~/.agents/skills/servicenow/SKILL.md`.
16
16
 
17
+ ### Install from GitHub (test unreleased builds)
18
+
19
+ To test a branch or the freshly merged `main` before it's published to npm:
20
+
21
+ ```bash
22
+ npm install -g github:jacebenson/jsn#main # merged but not yet tagged
23
+ npm install -g github:jacebenson/jsn#<branch> # any PR/feature branch
24
+ ```
25
+
26
+ Go back to the released version with:
27
+
28
+ ```bash
29
+ npm install -g @jacebenson/jsn
30
+ ```
31
+
32
+ **Note:** git installs build `better-sqlite3` (docs search) from source, so the machine needs build tools (Python, make, a C++ compiler).
33
+
17
34
  ## Quick Start
18
35
 
19
36
  ```bash
20
- jsn setup # Interactive first-time setup
21
- jsn auth login https://dev12345.service-now.com
37
+ jsn setup # Interactive: add, switch, remove, modify instances
22
38
  jsn incidents list --query "priority=1" # Critical incidents
23
39
  ```
24
40
 
@@ -64,17 +80,23 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
64
80
 
65
81
  Every command supports `--json`, `--query`, and `--help`.
66
82
 
67
- ## Profiles (instances)
83
+ ## Instances
68
84
 
69
85
  Switch between instances without re-authenticating.
70
86
 
71
87
  ```bash
72
- jsn auth login https://dev12345.service-now.com
73
- jsn auth status
74
- jsn auth switch dev12345
88
+ jsn setup # Interactive: add, switch, remove, or modify instances
89
+ jsn auth status # Dashboard — auth state, read-only 🔒, skip-confirmations ⚡
75
90
  ```
76
91
 
77
- Add another instance with `jsn auth login <name-or-url>` — run bare for an interactive picker.
92
+ `jsn setup` is the human front door — one command, interactive menu. For scripts and CI, use the atomic commands:
93
+
94
+ ```bash
95
+ jsn auth login https://dev12345.service-now.com # Add + authenticate (scripted)
96
+ jsn auth refresh # Manually refresh the OAuth token
97
+ jsn auth switch dev12345 # Flip the active profile (scripted)
98
+ jsn auth modify dev12345 # Toggle read-only / skip confirmations
99
+ ```
78
100
 
79
101
  ## Output Formats
80
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
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": {
@@ -8,9 +8,9 @@
8
8
  },
9
9
  "main": "src/cli.js",
10
10
  "scripts": {
11
- "test": "NODE_OPTIONS=--require=./suppress-warnings.cjs node --test $(find test -name '*.test.js')",
11
+ "test": "node --experimental-test-module-mocks --test $(find test -name '*.test.js')",
12
12
  "lint": "npx eslint src/ bin/ test/",
13
- "start": "NODE_OPTIONS=--require=./suppress-warnings.cjs node bin/jsn.js",
13
+ "start": "node bin/jsn.js",
14
14
  "tag-reminder": "node scripts/tag-reminder.js",
15
15
  "release": "node scripts/release.js"
16
16
  },
@@ -42,7 +42,6 @@
42
42
  },
43
43
  "files": [
44
44
  "bin/jsn.js",
45
- "suppress-warnings.cjs",
46
45
  "src/",
47
46
  "skills/",
48
47
  "scripts/",
package/src/app.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { AuthManager } from './auth.js';
4
4
  import { SDKClient } from './sdk.js';
5
5
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled } from './output.js';
6
- import { getEffectiveInstance, normalizeInstanceURL } from './config.js';
6
+ import { getEffectiveInstance, normalizeInstanceURL, saveConfig } from './config.js';
7
7
  import { extractProfileName } from './helpers.js';
8
8
  import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
9
9
  import { errUsage, errAuth } from './errors.js';
@@ -20,7 +20,9 @@ export class App {
20
20
 
21
21
  const instance = getEffectiveInstance(cfg);
22
22
  if (instance) {
23
- this.sdk = new SDKClient(instance, this.auth);
23
+ this.sdk = new SDKClient(instance, this.auth, {
24
+ domain: this._profileDomain(cfg),
25
+ });
24
26
  }
25
27
 
26
28
  this.context = {
@@ -202,9 +204,29 @@ export class App {
202
204
  const normalizedUrl = normalizeInstanceURL(url);
203
205
  if (normalizedUrl) {
204
206
  this._overrideInstance = normalizedUrl;
205
- this.sdk = new SDKClient(normalizedUrl, this.auth);
207
+ this.sdk = new SDKClient(normalizedUrl, this.auth, {
208
+ domain: this._profileDomain(this.config),
209
+ });
206
210
  }
207
211
  }
212
+
213
+ /** Read the configured domain for the active profile (empty = no scoping). */
214
+ _profileDomain(cfg) {
215
+ const name = cfg.activeProfile || cfg.defaultProfile;
216
+ if (name && cfg.profiles[name]) {
217
+ return cfg.profiles[name].domain || '';
218
+ }
219
+ return '';
220
+ }
221
+
222
+ /** Set (or clear with '') the domain for the active profile. */
223
+ setDomain(domain) {
224
+ const name = this.config.activeProfile || this.config.defaultProfile;
225
+ if (!name) throw errUsage('No active profile to set a domain on');
226
+ if (!this.config.profiles[name]) this.config.profiles[name] = {};
227
+ this.config.profiles[name].domain = domain || '';
228
+ return saveConfig(this.config);
229
+ }
208
230
  }
209
231
 
210
232
  function resolveFormat(fmt) {
package/src/cli.js CHANGED
@@ -53,6 +53,7 @@ import { formsCmd } from './commands/dev/forms.js';
53
53
  import { listsCmd } from './commands/dev/lists.js';
54
54
  import { updateSetsCmd } from './commands/dev/updatesets.js';
55
55
  import { scopesCmd } from './commands/dev/scopes.js';
56
+ import { domainsCmd } from './commands/dev/domains.js';
56
57
  import { evalCmd } from './commands/dev/eval.js';
57
58
  import { restCmd } from './commands/dev/rest.js';
58
59
  import { logsCmd } from './commands/dev/logs.js';
@@ -287,6 +288,7 @@ export function buildCLI() {
287
288
  .command(authCmd(wrap))
288
289
  .command(updateSetsCmd(wrap))
289
290
  .command(scopesCmd(wrap))
291
+ .command(domainsCmd(wrap))
290
292
  // CORE
291
293
  .command(incidentsCmd(wrap))
292
294
  .command(changesCmd(wrap))
@@ -388,7 +390,7 @@ export function buildCLI() {
388
390
  }
389
391
  process.exit(0);
390
392
  }
391
- process.stdout.write(renderHelp() + '\n');
393
+ process.stdout.write(renderHelp(loadConfig()) + '\n');
392
394
  process.exit(0);
393
395
  }
394
396
  process.stderr.write(msg + '\n');
@@ -23,6 +23,37 @@ export function resolveInstanceArg(arg, cfg) {
23
23
  return normalizeInstanceURL(`${trimmed}.service-now.com`);
24
24
  }
25
25
 
26
+ /**
27
+ * Pick a profile by name — interactive search picker when ambiguous,
28
+ * auto-selects when there's exactly one, throws when none configured.
29
+ */
30
+ export async function pickProfile(app, message) {
31
+ const { search } = await import('@inquirer/prompts');
32
+ const profileNames = Object.keys(app.config.profiles || {});
33
+ if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
34
+ if (profileNames.length === 1 || !app.isInteractive()) return profileNames[0];
35
+ const choices = profileNames.map(name => ({
36
+ name: `${name} — ${app.config.profiles[name].instance_url}`,
37
+ value: name,
38
+ }));
39
+ return search({
40
+ message,
41
+ source: (input) => {
42
+ const filter = (input || '').toLowerCase();
43
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
44
+ },
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Resolve the instance for the wizard: only an EXPLICIT source (env var,
50
+ * --instance flag/override) pre-fills. The config's default profile must
51
+ * NOT — the wizard adds an instance, so it always asks otherwise.
52
+ */
53
+ export function resolveWizardInstance(app, argv = {}) {
54
+ return process.env.SERVICENOW_INSTANCE_URL || app._overrideInstance || argv.instance || '';
55
+ }
56
+
26
57
  /**
27
58
  * Interactive first-run wizard (formerly `jsn setup`).
28
59
  *
@@ -39,8 +70,15 @@ export async function loginWizard(app, argv = {}) {
39
70
  console.log('Welcome to JSN - ServiceNow CLI');
40
71
  console.log();
41
72
 
42
- let instance = getEffectiveInstance(app.config);
43
- if (!instance) {
73
+ // Ask for the instance URL unless one was explicitly provided (env var,
74
+ // --instance flag/override). NEVER pre-fill from the config's default
75
+ // profile — this wizard adds an instance, so it must always ask.
76
+ // (Regression: setup → Add skipped the URL ask when a default profile
77
+ // existed, silently re-targeting the default instance.)
78
+ let instance = resolveWizardInstance(app, argv);
79
+ if (instance) {
80
+ instance = normalizeInstanceURL(instance);
81
+ } else {
44
82
  instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
45
83
  instance = normalizeInstanceURL(instance);
46
84
  }
@@ -113,6 +151,36 @@ export async function loginWizard(app, argv = {}) {
113
151
  }
114
152
  console.log('Login successful!');
115
153
  loggedIn = true;
154
+
155
+ // Probe domain separation capability once and stamp the profile, so
156
+ // `jsn domains` (and its help entry) only appear on instances with it.
157
+ try {
158
+ const { SDKClient } = await import('../sdk.js');
159
+ const domainsMod = await import('./dev/domains.js');
160
+ const sdk = new SDKClient(instance, app.auth);
161
+ profile.domain_separation = await domainsMod.isDomainSeparationInstalled({ sdk });
162
+ await setProfile(app.config, profileName, profile);
163
+
164
+ // If domain separation is installed, offer to pick a default domain
165
+ // for the profile — every request will be scoped to it via X-Now-Domain.
166
+ if (profile.domain_separation) {
167
+ const { confirm } = await import('@inquirer/prompts');
168
+ const setDomainNow = await confirm({
169
+ message: 'Domain separation detected. Configure a domain for this instance?',
170
+ default: false,
171
+ });
172
+ if (setDomainNow) {
173
+ const domainSysID = await domainsMod.pickDomain({ ...app, sdk });
174
+ if (domainSysID) {
175
+ profile.domain = domainSysID;
176
+ await setProfile(app.config, profileName, profile);
177
+ console.log('Domain configured.');
178
+ }
179
+ }
180
+ }
181
+ } catch {
182
+ // Non-fatal — capability unknown; domains command falls back to probing
183
+ }
116
184
  }
117
185
  } else {
118
186
  rl.close();
@@ -165,6 +233,121 @@ async function pickLoginTarget(app) {
165
233
  return { addNew: false, name: selected };
166
234
  }
167
235
 
236
+ /** Per-profile configurable flags (extensible — any boolean on the profile). */
237
+ const PROFILE_FLAGS = [
238
+ { name: 'Read-only (blocks mutation commands)', value: 'read_only', icon: '🔒' },
239
+ { name: 'Skip confirmations (deletes run without prompting)', value: 'skip_confirmations', icon: '⚡' },
240
+ ];
241
+
242
+ /**
243
+ * Modify a profile: toggle a boolean flag, or set/clear the domain.
244
+ * Interactive picker when no flag passed; testable via argv.flag/argv.domain.
245
+ */
246
+ export async function modifyProfile(app, argv = {}) {
247
+ const name = argv.name || await pickProfile(app, 'Modify which instance?');
248
+ const profile = app.config.profiles?.[name];
249
+ if (!profile) throw new Error(`Profile not found: ${name}`);
250
+
251
+ // Domain setting path: --domain <name|sys_id|clear>
252
+ if (argv.domain !== undefined) {
253
+ if (!profile.domain_separation) {
254
+ throw new Error(`Domain separation is not installed on ${profile.instance_url} — no domain to configure.`);
255
+ }
256
+ if (argv.domain === 'clear' || argv.domain === 'none' || argv.domain === '') {
257
+ profile.domain = '';
258
+ await saveConfig(app.config);
259
+ app.ok({ profile: name, domain: '' }, { summary: `Domain cleared for ${name}` });
260
+ return;
261
+ }
262
+ const { isDomainSeparationInstalled } = await import('./dev/domains.js');
263
+ const { SDKClient } = await import('../sdk.js');
264
+ const sdk = new SDKClient(profile.instance_url, app.auth);
265
+ if (!(await isDomainSeparationInstalled({ sdk }))) {
266
+ throw new Error(`Domain separation is not installed on ${profile.instance_url}`);
267
+ }
268
+ const { resolveDomainSysId } = await import('./dev/domains.js');
269
+ const sysID = await resolveDomainSysId({ sdk }, argv.domain);
270
+ profile.domain = sysID;
271
+ await saveConfig(app.config);
272
+ app.ok({ profile: name, domain: sysID }, { summary: `Domain set for ${name}` });
273
+ return;
274
+ }
275
+
276
+ let flag = argv.flag;
277
+ if (!flag) {
278
+ const { select } = await import('@inquirer/prompts');
279
+ const choices = PROFILE_FLAGS.map(f => ({
280
+ name: `${f.icon} ${f.name}: ${profile[f.value] ? 'on' : 'off'}`,
281
+ value: f.value,
282
+ }));
283
+ if (profile.domain_separation) {
284
+ choices.push({
285
+ name: `🌐 Domain: ${profile.domain ? 'set' : 'not set'}`,
286
+ value: 'domain',
287
+ });
288
+ }
289
+ const { SDKClient } = await import('../sdk.js');
290
+ const sdk = new SDKClient(profile.instance_url, app.auth);
291
+ flag = await select({
292
+ message: `Modify ${name} (${profile.instance_url})`,
293
+ choices,
294
+ });
295
+ if (flag === 'domain') {
296
+ const domainsMod = await import('./dev/domains.js');
297
+ const installed = await domainsMod.isDomainSeparationInstalled({ sdk });
298
+ if (!installed) {
299
+ throw new Error(`Domain separation is not installed on ${profile.instance_url}`);
300
+ }
301
+ const { confirm } = await import('@inquirer/prompts');
302
+ const clear = profile.domain
303
+ ? await confirm({ message: `Domain is set. Clear it?`, default: false })
304
+ : false;
305
+ if (clear) {
306
+ profile.domain = '';
307
+ await saveConfig(app.config);
308
+ app.ok({ profile: name, domain: '' }, { summary: `Domain cleared for ${name}` });
309
+ return;
310
+ }
311
+ const sysID = await domainsMod.pickDomain({ ...app, sdk });
312
+ if (sysID) {
313
+ profile.domain = sysID;
314
+ await saveConfig(app.config);
315
+ app.ok({ profile: name, domain: sysID }, { summary: `Domain set for ${name}` });
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
321
+ profile[flag] = !profile[flag];
322
+ await saveConfig(app.config);
323
+ app.ok({ profile: name, [flag]: profile[flag] },
324
+ { summary: `${flag} is now ${profile[flag] ? 'ON' : 'off'} for ${name}` });
325
+ }
326
+
327
+ /**
328
+ * Delete an instance entry and its stored credentials (shared by the
329
+ * `auth remove` subcommand and the `jsn setup` hub).
330
+ */
331
+ export async function removeProfile(app, name) {
332
+ if (!app.config.profiles[name]) {
333
+ throw new Error(`Profile not found: ${name}`);
334
+ }
335
+ const instance = app.config.profiles[name].instance_url;
336
+ delete app.config.profiles[name];
337
+ if (app.config.defaultProfile === name) app.config.defaultProfile = '';
338
+ if (app.config.activeProfile === name) app.config.activeProfile = '';
339
+ saveConfig(app.config);
340
+
341
+ // Only clear credentials if no other profile uses this instance URL
342
+ const stillInUse = instance && Object.values(app.config.profiles || {})
343
+ .some(p => p.instance_url === instance);
344
+ if (instance && !stillInUse) {
345
+ app.auth.logout(instance);
346
+ }
347
+
348
+ app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
349
+ }
350
+
168
351
  export function authCmd(wrap) {
169
352
  return {
170
353
  command: 'auth [subcommand]',
@@ -463,6 +646,23 @@ Examples:
463
646
 
464
647
  const creds = await app.auth.getCredentialsFor(instanceURL);
465
648
  const refreshed = await app.auth.refreshToken(instanceURL, creds);
649
+
650
+ // Re-probe domain separation on refresh so the capability flag
651
+ // stays in sync if the plugin was installed/removed since setup.
652
+ try {
653
+ const { SDKClient } = await import('../sdk.js');
654
+ const { isDomainSeparationInstalled } = await import('./dev/domains.js');
655
+ const sdk = new SDKClient(instanceURL, app.auth);
656
+ const hasDS = await isDomainSeparationInstalled({ sdk });
657
+ const name = app.config.activeProfile || app.config.defaultProfile;
658
+ if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
659
+ app.config.profiles[name].domain_separation = hasDS;
660
+ await saveConfig(app.config);
661
+ }
662
+ } catch {
663
+ // Non-fatal — capability probe is best-effort
664
+ }
665
+
466
666
  app.ok({
467
667
  refreshed: true,
468
668
  instance: instanceURL,
@@ -507,6 +707,22 @@ Examples:
507
707
  app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
508
708
  }),
509
709
  })
710
+ .command({
711
+ command: 'modify [name]',
712
+ describe: 'Toggle instance configuration (read-only, skip confirmations, domain)',
713
+ builder: (sub) => sub
714
+ .positional('name', {
715
+ describe: 'Profile name to modify',
716
+ type: 'string',
717
+ })
718
+ .option('domain', {
719
+ type: 'string',
720
+ describe: 'Set the domain for this profile (name, sys_id, or "clear")',
721
+ }),
722
+ handler: wrap(async (argv, app) => {
723
+ await modifyProfile(app, argv);
724
+ }),
725
+ })
510
726
  .command({
511
727
  command: 'remove [name]',
512
728
  describe: 'Delete an instance entry and its stored credentials',
@@ -518,45 +734,9 @@ Examples:
518
734
  handler: wrap(async (argv, app) => {
519
735
  let name = argv.name;
520
736
  if (!name) {
521
- const profileNames = Object.keys(app.config.profiles || {});
522
- if (profileNames.length === 0) {
523
- throw errUsage('No profiles configured. Nothing to remove.');
524
- }
525
- if (profileNames.length > 1 && app.isInteractive()) {
526
- const { search } = await import('@inquirer/prompts');
527
- const choices = profileNames.map(profileName => ({
528
- name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
529
- value: profileName,
530
- }));
531
- const selected = await search({
532
- message: 'Remove which profile?',
533
- source: (input) => {
534
- const filter = (input || '').toLowerCase();
535
- return choices.filter(c => c.name.toLowerCase().includes(filter));
536
- },
537
- });
538
- name = selected;
539
- } else {
540
- name = profileNames[0];
541
- }
737
+ name = await pickProfile(app, 'Remove which instance?');
542
738
  }
543
- if (!app.config.profiles[name]) {
544
- throw new Error(`Profile not found: ${name}`);
545
- }
546
- const instance = app.config.profiles[name].instance_url;
547
- delete app.config.profiles[name];
548
- if (app.config.defaultProfile === name) app.config.defaultProfile = '';
549
- if (app.config.activeProfile === name) app.config.activeProfile = '';
550
- saveConfig(app.config);
551
-
552
- // Only clear credentials if no other profile uses this instance URL
553
- const stillInUse = instance && Object.values(app.config.profiles || {})
554
- .some(p => p.instance_url === instance);
555
- if (instance && !stillInUse) {
556
- app.auth.logout(instance);
557
- }
558
-
559
- app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
739
+ await removeProfile(app, name);
560
740
  }),
561
741
  });
562
742
  },
@@ -565,15 +745,11 @@ Examples:
565
745
  console.log('');
566
746
  console.log('Available subcommands:');
567
747
  console.log(' login <url|name> Log in / add an instance (picker when run bare)');
568
- console.log(' status Show auth status for all profiles');
569
- console.log(' switch <name> Switch the active profile');
570
748
  console.log(' refresh Refresh the access token');
571
- console.log(' logout Clear credentials (keeps the instance entry)');
572
- console.log(' remove <name> Delete an instance entry and its credentials');
749
+ console.log(' status Show auth status for all profiles');
573
750
  console.log('');
751
+ console.log('Tip: "jsn setup" is the interactive manager — add, switch, remove, or modify instances.');
574
752
  console.log('Run "jsn auth <command> --help" for details.');
575
- console.log('');
576
- console.log('Tip: "jsn auth login" is the only command you need to add an instance.');
577
753
  },
578
754
  };
579
755
  }