@jacebenson/jsn 4.0.3 → 4.1.0

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.1.0",
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": {
@@ -29,8 +29,6 @@
29
29
  "@inquirer/core": "^10.1.0",
30
30
  "@inquirer/prompts": "^7.5.1",
31
31
  "better-sqlite3": "^13.0.3",
32
- "chalk": "^5.4.1",
33
- "cli-table3": "^0.6.5",
34
32
  "gray-matter": "^4.0.3",
35
33
  "turndown": "^7.2.4",
36
34
  "yargs": "^17.7.2"
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
@@ -10,6 +10,7 @@ import { App } from './app.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand } from './mutations.js';
12
12
  import { guardExit } from './errors.js';
13
+ import { FormatJSON } from './output.js';
13
14
 
14
15
  // Command modules
15
16
  import { setupCmd } from './commands/setup.js';
@@ -53,6 +54,7 @@ import { formsCmd } from './commands/dev/forms.js';
53
54
  import { listsCmd } from './commands/dev/lists.js';
54
55
  import { updateSetsCmd } from './commands/dev/updatesets.js';
55
56
  import { scopesCmd } from './commands/dev/scopes.js';
57
+ import { domainsCmd } from './commands/dev/domains.js';
56
58
  import { evalCmd } from './commands/dev/eval.js';
57
59
  import { restCmd } from './commands/dev/rest.js';
58
60
  import { logsCmd } from './commands/dev/logs.js';
@@ -85,6 +87,19 @@ function wrap(handler) {
85
87
  process.stderr.write(`Error (system): ${err.message}\n`);
86
88
  process.exit(3);
87
89
  }
90
+ if (err.code === 'confirmation_required') {
91
+ // Structured error for AI agents / piped consumers: emit the
92
+ // JSON envelope on stdout (where tooling reads) so the agent can
93
+ // surface the decision to its user or re-run with --force.
94
+ const app = argv.app;
95
+ if (app && app.output.effectiveFormat() === FormatJSON) {
96
+ app.err(err);
97
+ } else {
98
+ process.stderr.write(`Error: ${err.message}\n`);
99
+ if (err.hint) process.stderr.write(`\n${err.hint}\n`);
100
+ }
101
+ process.exit(1);
102
+ }
88
103
  process.stderr.write(`Error: ${err.message}\n`);
89
104
  process.exit(1);
90
105
  }
@@ -287,6 +302,7 @@ export function buildCLI() {
287
302
  .command(authCmd(wrap))
288
303
  .command(updateSetsCmd(wrap))
289
304
  .command(scopesCmd(wrap))
305
+ .command(domainsCmd(wrap))
290
306
  // CORE
291
307
  .command(incidentsCmd(wrap))
292
308
  .command(changesCmd(wrap))
@@ -388,7 +404,7 @@ export function buildCLI() {
388
404
  }
389
405
  process.exit(0);
390
406
  }
391
- process.stdout.write(renderHelp() + '\n');
407
+ process.stdout.write(renderHelp(loadConfig()) + '\n');
392
408
  process.exit(0);
393
409
  }
394
410
  process.stderr.write(msg + '\n');
@@ -1,9 +1,9 @@
1
1
  // Generic command builder for CRUD operations on a ServiceNow table
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
- import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch, canPrompt } from '../helpers.js';
5
5
  import { search } from '@inquirer/prompts';
6
- import { isTTY, FormatAuto } from '../output.js';
6
+ import { FormatAuto } from '../output.js';
7
7
 
8
8
  export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
9
9
  return {
@@ -28,7 +28,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
28
28
  const offset = argv.offset;
29
29
 
30
30
  // Interactive picker in TTY with auto format and no explicit query/offset
31
- const isInteractive = isTTY(process.stdout) && isTTY(process.stdin);
31
+ const isInteractive = canPrompt();
32
32
  if (isInteractive && app.output.getFormat() === FormatAuto && !query && offset === 0) {
33
33
  const pickerColumns = ['sys_id', 'number', 'short_description', 'state', 'assigned_to'];
34
34
  const pickerParams = new URLSearchParams();
@@ -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);
@@ -175,7 +175,15 @@ export function catalogCmd(wrap) {
175
175
  }
176
176
 
177
177
  async function showItem(app, sysID) {
178
- const item = await app.sdk.get('sc_cat_item', sysID);
178
+ // Use list + display_value=all so reference fields (category, flow) resolve
179
+ // to readable display values instead of raw sys_ids.
180
+ const p = new URLSearchParams();
181
+ p.set('sysparm_query', `sys_id=${sysID}`);
182
+ p.set('sysparm_limit', '1');
183
+ p.set('sysparm_display_value', 'all');
184
+ const items = await app.sdk.list('sc_cat_item', p);
185
+ const item = items[0] || null;
186
+ if (!item) throw new Error(`Not found: ${sysID}`);
179
187
 
180
188
  const vp = new URLSearchParams();
181
189
  vp.set('sysparm_query', `cat_item=${sysID}^active=true`);
@@ -216,7 +224,8 @@ async function showItem(app, sysID) {
216
224
  const wfName = getStringField(item, 'workflow') || '';
217
225
  const wfID = item.workflow?.value || '';
218
226
  const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
219
- app.ok({
227
+
228
+ const data = {
220
229
  name: getStringField(item, 'name'),
221
230
  short_description: getStringField(item, 'short_description'),
222
231
  category: getStringField(item, 'category'),
@@ -232,7 +241,60 @@ async function showItem(app, sysID) {
232
241
  standalone: standalone.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })),
233
242
  sets: Array.from(setVars.values()).map(s => ({ name: s.name, variables: s.variables.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })) })),
234
243
  } : undefined,
235
- }, {
236
- summary: `${getStringField(item, 'name')} — ${totalVars} variable(s)`,
244
+ };
245
+
246
+ // Styled-mode rendering (non-enumerable so JSON output stays clean)
247
+ Object.defineProperty(data, '_formatted', {
248
+ value: buildCatalogFormatted(data, standalone, Array.from(setVars.values()), totalVars),
249
+ enumerable: false,
250
+ configurable: true,
237
251
  });
252
+
253
+ app.ok(data, {
254
+ summary: `${data.name} — ${totalVars} variable(s)`,
255
+ breadcrumbs: [
256
+ { action: 'list', cmd: 'jsn catalogitems list', description: 'Back to all catalog items' },
257
+ ],
258
+ });
259
+ }
260
+
261
+ function buildCatalogFormatted(data, standalone, setVars, totalVars) {
262
+ const lines = [];
263
+ lines.push(`${data.name} (sc_cat_item)`);
264
+ lines.push('');
265
+ lines.push('─ Details ─');
266
+ if (data.short_description) lines.push(` short_description: ${data.short_description}`);
267
+ if (data.category) lines.push(` category: ${data.category}`);
268
+ lines.push(` active: ${data.active}`);
269
+ if (data.url) lines.push(` url: ${data.url}`);
270
+ if (data.execution_plan) lines.push(` execution_plan: ${data.execution_plan}`);
271
+ lines.push('');
272
+ if (data.flow) {
273
+ lines.push('─ Flow ─');
274
+ lines.push(` ${data.flow}`);
275
+ if (data.flow_cmd) lines.push(` → ${data.flow_cmd}`);
276
+ lines.push('');
277
+ }
278
+ if (data.workflow) {
279
+ lines.push('─ Workflow ─');
280
+ lines.push(` ${data.workflow}`);
281
+ if (data.workflow_cmd) lines.push(` → ${data.workflow_cmd}`);
282
+ lines.push('');
283
+ }
284
+ if (totalVars > 0) {
285
+ lines.push('─ Catalog Variables ─');
286
+ for (const v of standalone) {
287
+ const label = v.question_text || v.name;
288
+ lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
289
+ }
290
+ for (const s of setVars) {
291
+ lines.push(` [${s.name}]`);
292
+ for (const v of s.variables) {
293
+ const label = v.question_text || v.name;
294
+ lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
295
+ }
296
+ }
297
+ lines.push('');
298
+ }
299
+ return lines.join('\n');
238
300
  }
@@ -1,9 +1,9 @@
1
1
  // Generic dev subcommand builder for table-based CRUD
2
2
 
3
- import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch, canPrompt } from '../../helpers.js';
4
4
  import { getCurrentUser, getCurrentApplication } from '../../context.js';
5
5
  import { paginatedSearch } from '../../paginated-search.js';
6
- import { isTTY, FormatAuto } from '../../output.js';
6
+ import { FormatAuto } from '../../output.js';
7
7
 
8
8
  function vowelArticle(word) {
9
9
  const first = word.charAt(0).toLowerCase();
@@ -111,7 +111,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
111
111
  const limit = argv.limit;
112
112
 
113
113
  // Interactive picker with pagination
114
- const interactive = isTTY(process.stdout) && isTTY(process.stdin);
114
+ const interactive = canPrompt();
115
115
  if (interactive && app.output.getFormat() === FormatAuto && !query) {
116
116
  // Get total count
117
117
  let totalCount = 0;