@jacebenson/jsn 4.0.1 → 4.0.3

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.1",
3
+ "version": "4.0.3",
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/",
@@ -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
  }
@@ -165,6 +203,63 @@ async function pickLoginTarget(app) {
165
203
  return { addNew: false, name: selected };
166
204
  }
167
205
 
206
+ /** Per-profile configurable flags (extensible — any boolean on the profile). */
207
+ const PROFILE_FLAGS = [
208
+ { name: 'Read-only (blocks mutation commands)', value: 'read_only', icon: '🔒' },
209
+ { name: 'Skip confirmations (deletes run without prompting)', value: 'skip_confirmations', icon: '⚡' },
210
+ ];
211
+
212
+ /**
213
+ * Toggle a per-profile configuration flag (read_only / skip_confirmations).
214
+ * Interactive flag picker when no flag passed; testable via argv.flag.
215
+ */
216
+ export async function modifyProfile(app, argv = {}) {
217
+ const name = argv.name || await pickProfile(app, 'Modify which instance?');
218
+ const profile = app.config.profiles?.[name];
219
+ if (!profile) throw new Error(`Profile not found: ${name}`);
220
+
221
+ let flag = argv.flag;
222
+ if (!flag) {
223
+ const { select } = await import('@inquirer/prompts');
224
+ flag = await select({
225
+ 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
+ })),
230
+ });
231
+ }
232
+
233
+ profile[flag] = !profile[flag];
234
+ await saveConfig(app.config);
235
+ app.ok({ profile: name, [flag]: profile[flag] },
236
+ { summary: `${flag} is now ${profile[flag] ? 'ON' : 'off'} for ${name}` });
237
+ }
238
+
239
+ /**
240
+ * Delete an instance entry and its stored credentials (shared by the
241
+ * `auth remove` subcommand and the `jsn setup` hub).
242
+ */
243
+ export async function removeProfile(app, name) {
244
+ if (!app.config.profiles[name]) {
245
+ throw new Error(`Profile not found: ${name}`);
246
+ }
247
+ const instance = app.config.profiles[name].instance_url;
248
+ delete app.config.profiles[name];
249
+ if (app.config.defaultProfile === name) app.config.defaultProfile = '';
250
+ if (app.config.activeProfile === name) app.config.activeProfile = '';
251
+ saveConfig(app.config);
252
+
253
+ // Only clear credentials if no other profile uses this instance URL
254
+ const stillInUse = instance && Object.values(app.config.profiles || {})
255
+ .some(p => p.instance_url === instance);
256
+ if (instance && !stillInUse) {
257
+ app.auth.logout(instance);
258
+ }
259
+
260
+ app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
261
+ }
262
+
168
263
  export function authCmd(wrap) {
169
264
  return {
170
265
  command: 'auth [subcommand]',
@@ -507,6 +602,18 @@ Examples:
507
602
  app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
508
603
  }),
509
604
  })
605
+ .command({
606
+ command: 'modify [name]',
607
+ describe: 'Toggle instance configuration (read-only, skip confirmations)',
608
+ builder: (sub) => sub
609
+ .positional('name', {
610
+ describe: 'Profile name to modify',
611
+ type: 'string',
612
+ }),
613
+ handler: wrap(async (argv, app) => {
614
+ await modifyProfile(app, argv);
615
+ }),
616
+ })
510
617
  .command({
511
618
  command: 'remove [name]',
512
619
  describe: 'Delete an instance entry and its stored credentials',
@@ -518,45 +625,9 @@ Examples:
518
625
  handler: wrap(async (argv, app) => {
519
626
  let name = argv.name;
520
627
  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
- }
628
+ name = await pickProfile(app, 'Remove which instance?');
542
629
  }
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}` });
630
+ await removeProfile(app, name);
560
631
  }),
561
632
  });
562
633
  },
@@ -565,15 +636,11 @@ Examples:
565
636
  console.log('');
566
637
  console.log('Available subcommands:');
567
638
  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
639
  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');
640
+ console.log(' status Show auth status for all profiles');
573
641
  console.log('');
642
+ console.log('Tip: "jsn setup" is the interactive manager — add, switch, remove, or modify instances.');
574
643
  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
644
  },
578
645
  };
579
646
  }
@@ -307,6 +307,11 @@ async function formatFlowStructure(inspection, ctx) {
307
307
  if (!ctx.catalogVarNames) ctx.catalogVarNames = new Map();
308
308
  for (const [k, v] of names) ctx.catalogVarNames.set(k, v);
309
309
  }
310
+ const labelCache = buildLabelCache(inspection.payload);
311
+ if (labelCache.size > 0) {
312
+ if (!ctx.labelCache) ctx.labelCache = new Map();
313
+ for (const [k, v] of labelCache) ctx.labelCache.set(k, v);
314
+ }
310
315
  const payload = inspection.payload;
311
316
  if (Object.keys(payload).length > 0) {
312
317
  return formatFlowStructureFromPayload(payload, ctx);
@@ -314,6 +319,48 @@ async function formatFlowStructure(inspection, ctx) {
314
319
  return formatFlowStructureFallback(inspection);
315
320
  }
316
321
 
322
+ /**
323
+ * The ProcessFlow payload carries a label cache (also serialized as
324
+ * labelCacheAsJsonString) mapping data-pill names like "<step-uid>.<var>"
325
+ * to the human labels the Flow Designer UI shows, e.g.
326
+ * "1 - Get Catalog Variables➛session_title". Build a Map so guid-prefixed
327
+ * pills can be rendered readably.
328
+ */
329
+ function buildLabelCache(payload) {
330
+ const cache = new Map();
331
+ if (!payload || typeof payload !== 'object') return cache;
332
+ let entries = payload.label_cache;
333
+ if (!Array.isArray(entries)) {
334
+ try {
335
+ entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
336
+ } catch {
337
+ entries = [];
338
+ }
339
+ }
340
+ for (const e of entries) {
341
+ if (!e || typeof e !== 'object') continue;
342
+ const name = getStringField(e, 'name');
343
+ const label = getStringField(e, 'label');
344
+ if (name && label) cache.set(name, label);
345
+ }
346
+ return cache;
347
+ }
348
+
349
+ const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
350
+
351
+ /**
352
+ * Replace opaque guid-prefixed pills ({{<32-hex-guid>.<var>}}) with their
353
+ * human label from the payload's label cache. Readable step refs like
354
+ * {{Created_1.table_name}} are NOT matched and stay raw.
355
+ */
356
+ function resolveGuidPills(value, labelCache) {
357
+ if (!labelCache || labelCache.size === 0 || !value) return value;
358
+ return String(value).replace(GUID_PILL_RE, (m, guid, name) => {
359
+ const label = labelCache.get(`${guid}.${name}`);
360
+ return label ? `{{${label}}}` : m;
361
+ });
362
+ }
363
+
317
364
  /**
318
365
  * ServiceNow serializes catalog variable selections in action inputs as
319
366
  * comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
@@ -657,6 +704,10 @@ export function formatActionStep(stepNum, pad, action, ctx) {
657
704
  const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
658
705
  if (resolvedCatalog) inputValue = resolvedCatalog;
659
706
 
707
+ // Opaque guid-prefixed pills ({{<32-hex>.<var>}}) get their human
708
+ // label from the payload's label cache. Readable step refs stay raw.
709
+ inputValue = resolveGuidPills(inputValue, ctx?.labelCache);
710
+
660
711
  let label = inputName;
661
712
  if (raw.parameter && typeof raw.parameter === 'object') {
662
713
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -1,25 +1,61 @@
1
- import { loginWizard } from './auth.js';
1
+ import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
2
+ import { setActiveProfile } from '../config.js';
3
+ import { errUsage } from '../errors.js';
2
4
 
3
- // `jsn setup` is kept as a hidden compatibility alias for `jsn auth login`.
4
- // The wizard lives in auth.js (loginWizard) so there is exactly ONE code path
5
- // for first-run onboarding — this command can never drift out of sync.
5
+ /** Interactive hub: what do you want to do? Returns 'add' | 'switch' | 'remove' | 'modify'. */
6
+ export async function authHubMenu() {
7
+ const { select } = await import('@inquirer/prompts');
8
+ return select({
9
+ message: 'What would you like to do?',
10
+ choices: [
11
+ { name: 'Add a new instance', value: 'add' },
12
+ { name: 'Switch to a different instance', value: 'switch' },
13
+ { name: 'Remove an instance', value: 'remove' },
14
+ { name: 'Modify an instance', value: 'modify' },
15
+ ],
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Dispatch a hub action to the shared operation. Kept separate from the
21
+ * handler so routing is testable without invoking inquirer prompts.
22
+ */
23
+ export async function dispatchSetupAction(app, argv, action) {
24
+ if (action === 'add') {
25
+ const result = await loginWizard(app, argv);
26
+ app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
27
+ } else if (action === 'switch') {
28
+ const name = await pickProfile(app, 'Switch to which instance?');
29
+ await setActiveProfile(app.config, name);
30
+ app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
31
+ } else if (action === 'remove') {
32
+ const name = await pickProfile(app, 'Remove which instance?');
33
+ await removeProfile(app, name);
34
+ } else { // modify
35
+ await modifyProfile(app, argv);
36
+ }
37
+ }
38
+
39
+ // `jsn setup` is the interactive front door for managing instances.
40
+ // The granular, scriptable surface lives under `jsn auth` — this command
41
+ // dispatches to the SAME functions (loginWizard, setActiveProfile,
42
+ // removeProfile, modifyProfile) so the two surfaces can never drift.
6
43
  export function setupCmd(wrap) {
7
44
  return {
8
45
  command: 'setup',
9
- describe: 'Interactive first-time setup (use "jsn auth login")',
10
- hidden: true, // legacy alias for `jsn auth login` — works but hidden from help
11
- builder: (yargs) => {
12
- return yargs
13
- .option('read-only', {
14
- describe: 'Mark profile as read-only (blocks mutation commands)',
15
- type: 'boolean',
16
- default: false,
17
- });
18
- },
46
+ describe: 'Set up and manage your ServiceNow instances (interactive)',
19
47
  handler: wrap(async (argv, app) => {
20
- console.log('Note: "jsn setup" is now "jsn auth login". Running the same wizard...\n');
21
- const result = await loginWizard(app, argv);
22
- app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
48
+ if (!app.isInteractive()) {
49
+ throw errUsage('"jsn setup" is interactive. Use the specific commands:\n jsn auth login <instance>\n jsn auth refresh\n jsn auth status');
50
+ }
51
+ const profileNames = Object.keys(app.config.profiles || {});
52
+ if (profileNames.length === 0) {
53
+ const result = await loginWizard(app, argv);
54
+ app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
55
+ return;
56
+ }
57
+ const action = await authHubMenu();
58
+ await dispatchSetupAction(app, argv, action);
23
59
  }),
24
60
  };
25
61
  }
package/src/help.js CHANGED
@@ -107,7 +107,8 @@ const DATA_COMMANDS = [
107
107
  ];
108
108
 
109
109
  const CONFIG_COMMANDS = [
110
- { name: 'auth', desc: 'Authentication (login, status, switch, refresh, logout, remove)' },
110
+ { name: 'setup', desc: 'Set up and manage instances (interactive: add, switch, remove, modify)' },
111
+ { name: 'auth', desc: 'Authentication (login, refresh, status)' },
111
112
  { name: 'updatesets', desc: 'Update sets (set, create, export)' },
112
113
  { name: 'scopes', desc: 'Application scopes' },
113
114
  ];
@@ -1,13 +0,0 @@
1
- // Suppresses the node:sqlite ExperimentalWarning on Node 22.x.
2
- // Loaded via NODE_OPTIONS=--require before the main ESM entry point.
3
-
4
- const original = process.emitWarning;
5
- process.emitWarning = function emitWarning(warning, ...args) {
6
- const message = typeof warning === 'string'
7
- ? warning
8
- : (warning?.message || '');
9
- if (message.includes('SQLite')) {
10
- return;
11
- }
12
- return original.apply(this, [warning, ...args]);
13
- };