@jacebenson/jsn 4.0.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.0.3",
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": {
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');
@@ -151,6 +151,36 @@ export async function loginWizard(app, argv = {}) {
151
151
  }
152
152
  console.log('Login successful!');
153
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
+ }
154
184
  }
155
185
  } else {
156
186
  rl.close();
@@ -210,24 +240,82 @@ const PROFILE_FLAGS = [
210
240
  ];
211
241
 
212
242
  /**
213
- * Toggle a per-profile configuration flag (read_only / skip_confirmations).
214
- * Interactive flag picker when no flag passed; testable via argv.flag.
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.
215
245
  */
216
246
  export async function modifyProfile(app, argv = {}) {
217
247
  const name = argv.name || await pickProfile(app, 'Modify which instance?');
218
248
  const profile = app.config.profiles?.[name];
219
249
  if (!profile) throw new Error(`Profile not found: ${name}`);
220
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
+
221
276
  let flag = argv.flag;
222
277
  if (!flag) {
223
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);
224
291
  flag = await select({
225
292
  message: `Modify ${name} (${profile.instance_url})`,
226
- choices: PROFILE_FLAGS.map(f => ({
227
- name: `${f.icon} ${f.name}: ${profile[f.value] ? 'on' : 'off'}`,
228
- value: f.value,
229
- })),
293
+ choices,
230
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
+ }
231
319
  }
232
320
 
233
321
  profile[flag] = !profile[flag];
@@ -558,6 +646,23 @@ Examples:
558
646
 
559
647
  const creds = await app.auth.getCredentialsFor(instanceURL);
560
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
+
561
666
  app.ok({
562
667
  refreshed: true,
563
668
  instance: instanceURL,
@@ -604,11 +709,15 @@ Examples:
604
709
  })
605
710
  .command({
606
711
  command: 'modify [name]',
607
- describe: 'Toggle instance configuration (read-only, skip confirmations)',
712
+ describe: 'Toggle instance configuration (read-only, skip confirmations, domain)',
608
713
  builder: (sub) => sub
609
714
  .positional('name', {
610
715
  describe: 'Profile name to modify',
611
716
  type: 'string',
717
+ })
718
+ .option('domain', {
719
+ type: 'string',
720
+ describe: 'Set the domain for this profile (name, sys_id, or "clear")',
612
721
  }),
613
722
  handler: wrap(async (argv, app) => {
614
723
  await modifyProfile(app, argv);
@@ -0,0 +1,292 @@
1
+ // Domain separation commands — list/show/current/set
2
+ //
3
+ // Domain separation (plugin) adds a `domain` table holding the domain
4
+ // hierarchy. Each domain-separated table's rows carry `sys_domain` +
5
+ // `sys_domain_path`, and queries are auto-filtered to the session domain
6
+ // plus its children. There is NO durable platform-side "current domain"
7
+ // preference a CLI can write (the picker sets the in-memory session only,
8
+ // and `sys_user.domain` is write-protected even for admin). So jsn stores
9
+ // the chosen domain in the active profile and sends it on every request
10
+ // via the `X-Now-Domain` header — the same scoping the platform applies
11
+ // to its own picker sessions.
12
+
13
+ import { getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
14
+
15
+ const DETAIL_FIELDS = 'sys_id,name,parent,sys_domain_path,description,active,sys_created_on,sys_updated_on';
16
+
17
+ /**
18
+ * Gate: domain separation must be installed. The `domain` table only
19
+ * exists on instances with the plugin. A single cheap query detects it;
20
+ * on non-DS instances the API returns "Invalid table domain".
21
+ */
22
+ export async function isDomainSeparationInstalled(app) {
23
+ try {
24
+ const params = new URLSearchParams();
25
+ params.set('sysparm_limit', '1');
26
+ params.set('sysparm_fields', 'sys_id,name');
27
+ await app.sdk.list('domain', params);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function requireDomainSeparation(app) {
35
+ if (!(await isDomainSeparationInstalled(app))) {
36
+ const e = new Error('Domain separation is not installed on this instance — the `domain` table does not exist. Install the Domain Separation plugin to use these commands.');
37
+ e.name = 'NotSupported';
38
+ throw e;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve a domain name or sys_id to its sys_id. Throws when not found.
44
+ */
45
+ export async function resolveDomainSysId(app, domainArg) {
46
+ assertSafeExactMatch(domainArg);
47
+ const params = new URLSearchParams();
48
+ params.set('sysparm_query', `name=${domainArg}`);
49
+ params.set('sysparm_limit', '1');
50
+ params.set('sysparm_fields', 'sys_id,name');
51
+ let records = await app.sdk.list('domain', params);
52
+ if (records.length === 0) {
53
+ const p2 = new URLSearchParams();
54
+ p2.set('sysparm_query', `sys_id=${domainArg}`);
55
+ p2.set('sysparm_limit', '1');
56
+ p2.set('sysparm_fields', 'sys_id,name');
57
+ records = await app.sdk.list('domain', p2);
58
+ }
59
+ if (records.length === 0) {
60
+ throw new Error(`Domain not found: ${domainArg}`);
61
+ }
62
+ return getStringField(records[0], 'sys_id');
63
+ }
64
+
65
+ /**
66
+ * Interactive domain picker — returns the chosen domain's sys_id, or null
67
+ * when the user cancels. Used by `domains set` and the setup/modify flows.
68
+ */
69
+ export async function pickDomain(app) {
70
+ const picked = await interactiveList({
71
+ app, table: 'domain', singular: 'domain', columns: ['name', 'sys_domain_path'], labelField: 'name',
72
+ formatLabel: formatDomainLabel,
73
+ });
74
+ if (!picked) return null;
75
+ return getStringField(picked, 'sys_id');
76
+ }
77
+
78
+ /** Format a picker label: "Name (path)". */
79
+ export function formatDomainLabel(r) {
80
+ const path = getStringField(r, 'sys_domain_path');
81
+ return `${getStringField(r, 'name')}${path ? ` (${path})` : ''}`;
82
+ }
83
+
84
+ /** Build a rich detail view for a domain record. */
85
+ export async function formatDomainDetail(app, record) {
86
+ const sysID = getStringField(record, 'sys_id');
87
+ const name = getStringField(record, 'name');
88
+ const instance = app.getEffectiveInstance();
89
+ const link = `${instance}/domain.do?sys_id=${sysID}`;
90
+
91
+ // Fetch full record for fields not present on the picked row
92
+ const params = new URLSearchParams();
93
+ params.set('sysparm_query', `sys_id=${sysID}`);
94
+ params.set('sysparm_limit', '1');
95
+ params.set('sysparm_display_value', 'all');
96
+ params.set('sysparm_fields', DETAIL_FIELDS);
97
+ const full = await app.sdk.list('domain', params);
98
+ const rec = full[0] || record;
99
+
100
+ const parent = getStringField(rec, 'parent');
101
+ const path = getStringField(rec, 'sys_domain_path');
102
+
103
+ const lines = [
104
+ `Domain: ${name}`,
105
+ `Path: ${path || '(root)'}`,
106
+ `Parent: ${parent || '(none)'}`,
107
+ `Active: ${getStringField(rec, 'active') || 'true'}`,
108
+ `Created: ${getStringField(rec, 'sys_created_on')}`,
109
+ `Updated: ${getStringField(rec, 'sys_updated_on')}`,
110
+ `Link: ${link}`,
111
+ ];
112
+
113
+ const desc = getStringField(rec, 'description');
114
+ if (desc) lines.splice(1, 0, `Desc: ${desc}`);
115
+
116
+ return {
117
+ sys_id: sysID,
118
+ name,
119
+ parent,
120
+ sys_domain_path: path,
121
+ _formatted: lines.join('\n'),
122
+ };
123
+ }
124
+
125
+ export function domainsCmd(wrap) {
126
+ return {
127
+ command: 'domains [subcommand]',
128
+ aliases: ['domain', 'dom'],
129
+ describe: 'Manage domains (domain separation)',
130
+ builder: (yargs) => {
131
+ return yargs
132
+ .command({
133
+ command: 'list',
134
+ aliases: ['ls'],
135
+ describe: 'List domains (hierarchy + paths)',
136
+ builder: (y) => y
137
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEACME")' })
138
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
139
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
140
+ handler: wrap(async (argv, app) => {
141
+ await requireDomainSeparation(app);
142
+ const columns = argv.columns ? argv.columns.split(',') : ['name', 'parent', 'sys_domain_path'];
143
+ const query = argv.query || '';
144
+
145
+ const picked = await interactiveList({
146
+ app, table: 'domain', singular: 'domain', columns, limit: argv.limit, query, labelField: 'name',
147
+ formatLabel: formatDomainLabel,
148
+ });
149
+ if (picked === undefined) return; // user cancelled
150
+ if (picked) {
151
+ const detail = await formatDomainDetail(app, picked);
152
+ return app.ok(detail, { summary: `Domain: ${getStringField(picked, 'name')}` });
153
+ }
154
+
155
+ const params = new URLSearchParams();
156
+ params.set('sysparm_limit', String(argv.limit));
157
+ params.set('sysparm_display_value', 'all');
158
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
159
+ const q = argv.query ? argv.query + '^ORDERBYsys_domain_path' : 'ORDERBYsys_domain_path';
160
+ params.set('sysparm_query', q);
161
+ const records = await app.sdk.list('domain', params);
162
+ app.ok({
163
+ table: 'domain',
164
+ count: records.length,
165
+ columns,
166
+ records: records.map(r => formatDomainListRecord(r, columns)),
167
+ context: { instance_url: app.getEffectiveInstance() },
168
+ }, { summary: `${records.length} domain(s)` });
169
+ }),
170
+ })
171
+ .command({
172
+ command: 'show <domain>',
173
+ aliases: ['get'],
174
+ describe: 'Show a domain by name or sys_id',
175
+ builder: (y) => y
176
+ .positional('domain', {
177
+ describe: 'Domain name (e.g. ACME) or sys_id',
178
+ type: 'string',
179
+ }),
180
+ handler: wrap(async (argv, app) => {
181
+ await requireDomainSeparation(app);
182
+ assertSafeExactMatch(argv.domain);
183
+ let records;
184
+ // Try by name first, then by sys_id
185
+ for (const queryField of ['name', 'sys_id']) {
186
+ const params = new URLSearchParams();
187
+ params.set('sysparm_query', `${queryField}=${argv.domain}`);
188
+ params.set('sysparm_limit', '1');
189
+ params.set('sysparm_display_value', 'all');
190
+ records = await app.sdk.list('domain', params);
191
+ if (records.length > 0) break;
192
+ }
193
+ if (!records || records.length === 0) {
194
+ throw new Error(`Domain not found: ${argv.domain}`);
195
+ }
196
+ const detail = await formatDomainDetail(app, records[0]);
197
+ app.ok(detail, { summary: `Domain ${argv.domain}` });
198
+ }),
199
+ })
200
+ .command({
201
+ command: 'current',
202
+ describe: 'Show the configured domain for this profile',
203
+ handler: wrap(async (argv, app) => {
204
+ await requireDomainSeparation(app);
205
+ const domain = app._profileDomain(app.config);
206
+ if (!domain) {
207
+ app.ok({ domain: '', scoping: 'none' }, {
208
+ summary: 'No domain configured — requests are not domain-scoped',
209
+ });
210
+ return;
211
+ }
212
+ // Resolve the configured value to a display name if it's a sys_id
213
+ let label = domain;
214
+ try {
215
+ const params = new URLSearchParams();
216
+ params.set('sysparm_query', `sys_id=${domain}`);
217
+ params.set('sysparm_limit', '1');
218
+ params.set('sysparm_fields', 'sys_id,name');
219
+ const records = await app.sdk.list('domain', params);
220
+ if (records.length > 0) label = `${getStringField(records[0], 'name')} (${domain})`;
221
+ } catch { /* non-fatal — show raw value */ }
222
+ app.ok({ domain, scoping: 'X-Now-Domain header' }, {
223
+ summary: `Configured domain: ${label}`,
224
+ });
225
+ }),
226
+ })
227
+ .command({
228
+ command: 'set [domain]',
229
+ describe: 'Set the configured domain for this profile (interactive picker when run bare; omit to clear)',
230
+ handler: wrap(async (argv, app) => {
231
+ await requireDomainSeparation(app);
232
+ let domainArg = argv.domain;
233
+ if (!domainArg) {
234
+ const picked = await pickDomain(app);
235
+ if (!picked) return; // cancelled or non-interactive
236
+ domainArg = picked;
237
+ } else if (domainArg.toLowerCase() === 'none' || domainArg.toLowerCase() === 'clear') {
238
+ app.setDomain('');
239
+ app.ok({ domain: '' }, { summary: 'Domain cleared — requests are no longer domain-scoped' });
240
+ return;
241
+ }
242
+
243
+ const sysID = await resolveDomainSysId(app, domainArg);
244
+ app.setDomain(sysID);
245
+ app.ok({ domain: sysID, name: domainArg }, {
246
+ summary: `Configured domain: ${domainArg} — all requests scoped to it`,
247
+ });
248
+ }),
249
+ });
250
+ },
251
+ handler: async (argv) => {
252
+ if (!argv._[1]) {
253
+ const app = argv.app;
254
+ let installed = false;
255
+ try {
256
+ if (app && app.sdk) installed = await isDomainSeparationInstalled(app);
257
+ } catch {
258
+ installed = false;
259
+ }
260
+ if (!installed) {
261
+ if (app && app.sdk) {
262
+ console.log('Domain separation is not installed on this instance.');
263
+ console.log('Install the Domain Separation plugin to manage domains.');
264
+ } else {
265
+ console.log('No instance configured — run "jsn auth login <instance>" first.');
266
+ }
267
+ return;
268
+ }
269
+ console.log('Manage ServiceNow domains (domain separation).\n');
270
+ console.log('Commands:');
271
+ console.log(' list List domains (hierarchy + paths)');
272
+ console.log(' show <domain> Show a domain');
273
+ console.log(' current Show the configured domain for this profile');
274
+ console.log(' set [domain] Configure the domain for this profile (picker when bare; "clear" to unset)');
275
+ console.log('\nDomains are applied per-request via the X-Now-Domain header —');
276
+ console.log('the platform filters every query to the domain and its children.');
277
+ console.log('Run "jsn domains <command> --help" for details.');
278
+ }
279
+ },
280
+ };
281
+ }
282
+
283
+ /** Flat display record for list mode (keeps the JSON payload small). */
284
+ function formatDomainListRecord(r, columns) {
285
+ const out = {};
286
+ for (const c of columns) {
287
+ const v = getStringField(r, c);
288
+ out[c] = v;
289
+ }
290
+ out.sys_id = getStringField(r, 'sys_id');
291
+ return out;
292
+ }