@jacebenson/jsn 3.4.2 → 4.0.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/README.md CHANGED
@@ -64,17 +64,18 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
64
64
 
65
65
  Every command supports `--json`, `--query`, and `--help`.
66
66
 
67
- ## Profiles
67
+ ## Profiles (instances)
68
68
 
69
69
  Switch between instances without re-authenticating.
70
70
 
71
71
  ```bash
72
72
  jsn auth login https://dev12345.service-now.com
73
- jsn profiles list
74
- jsn profiles use dev12345
75
- jsn profiles show
73
+ jsn auth status
74
+ jsn auth switch dev12345
76
75
  ```
77
76
 
77
+ Add another instance with `jsn auth login <name-or-url>` — run bare for an interactive picker.
78
+
78
79
  ## Output Formats
79
80
 
80
81
  | Flag | When to use |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.4.2",
3
+ "version": "4.0.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": {
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@inquirer/core": "^10.1.0",
30
30
  "@inquirer/prompts": "^7.5.1",
31
+ "better-sqlite3": "^13.0.3",
31
32
  "chalk": "^5.4.1",
32
33
  "cli-table3": "^0.6.5",
33
34
  "gray-matter": "^4.0.3",
@@ -44,7 +44,7 @@ All `list` subcommands use a paginated interactive picker in TTY mode:
44
44
  2. **Use sys_id for updates** — All update/delete operations require sys_id
45
45
  3. **Check auth first** — Run `jsn auth status` before operations
46
46
  4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
47
- 5. **Use `--profile <name>`** to target a specific instance, or `jsn profiles use <name>` to change default
47
+ 5. **Use `--profile <name>`** to target a specific instance, or `jsn auth switch <name>` to change the default
48
48
  6. **Before using `eval` or `rest`** — Ask: *"Is there a more specific jsn command?"* Prefer specific over generic over escape hatch over eval.
49
49
  7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
50
50
 
package/src/app.js CHANGED
@@ -120,10 +120,15 @@ export class App {
120
120
 
121
121
  const scopeFormatted = `[${scope}]`;
122
122
 
123
+ // Read-only profiles get a lock badge on every command run, so the
124
+ // armed safety is always visible (not just in `auth status`).
125
+ const activeProfile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
126
+ const roBadge = activeProfile?.read_only === true ? ' 🔒' : '';
127
+
123
128
  process.stderr.write('# Use `jsn scopes` to change scope, `jsn updatesets set` to change updateset\n');
124
129
  process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
125
130
 
126
- const profileStr = `]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}]8;;\x07`;
131
+ const profileStr = `\u001b]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}${roBadge}\u001b]8;;\x07`;
127
132
  const userStr = `]8;;${userLink}\x07${String(displayUserName).padEnd(9)}]8;;\x07`;
128
133
  const scopeStr = `]8;;${scopeLink}\x07${String(scopeFormatted).padEnd(17)}]8;;\x07`;
129
134
  const updateSetStr = `]8;;${updateSetLink}\x07${updateSet}]8;;\x07`;
@@ -131,9 +136,7 @@ export class App {
131
136
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
132
137
 
133
138
  // ⚠️ Warning if in the Default update set (mutation commands only)
134
- const profile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
135
- const isYolo = profile?.yolo === true;
136
- if (!isYolo && isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
139
+ if (isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
137
140
  const isGlobal = scope === 'global';
138
141
  if (isGlobal) {
139
142
  // 🔴 HARD WARNING — Global + Default
@@ -150,8 +153,6 @@ export class App {
150
153
  '┃ ┃\n' +
151
154
  '┃ Or switch to a scoped scope first: ┃\n' +
152
155
  '┃ jsn scopes list ┃\n' +
153
- '┃ ┃\n' +
154
- '┃ (Run jsn updatesets yolo to silence this check) ┃\n' +
155
156
  '┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m\n'
156
157
  );
157
158
  } else {
@@ -162,7 +163,6 @@ export class App {
162
163
  ' ⚠ Default update set in scope [' + scope + ']\n' +
163
164
  ' Changes are contained to this scope, but a named\n' +
164
165
  ' update set is still recommended for tracking.\n' +
165
- ' (Run \x1b[1mjsn updatesets yolo\x1b[22m to silence this warning)\n' +
166
166
  '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
167
167
  '\x1b[0m' // reset
168
168
  );
package/src/auth.js CHANGED
@@ -58,24 +58,6 @@ function removePKCEState(instance) {
58
58
  const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
59
59
  const REDIRECT_URI = '/sdk-oauth.do';
60
60
 
61
- // ─── WSL detection ───
62
-
63
- /**
64
- * Detect if running under Windows Subsystem for Linux.
65
- * Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
66
- */
67
- let _cachedIsWSL = null;
68
- async function isWSLEnv() {
69
- if (_cachedIsWSL !== null) return _cachedIsWSL;
70
- try {
71
- const data = fs.readFileSync('/proc/version', 'utf-8');
72
- _cachedIsWSL = /microsoft|wsl/i.test(data);
73
- } catch {
74
- _cachedIsWSL = false;
75
- }
76
- return _cachedIsWSL;
77
- }
78
-
79
61
  // ─── Basic auth from environment variables ───
80
62
  // SN_USERNAME / SN_PASSWORD — global credentials
81
63
  // SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
@@ -390,43 +372,12 @@ export class AuthManager {
390
372
  const authURL = buildAuthURL(instanceURL, clientID, pkce);
391
373
 
392
374
  console.log();
393
- console.log('Opening browser for OAuth authentication...');
394
- console.log('If the browser does not open automatically, visit:');
375
+ console.log('Open this URL in your browser to authenticate:');
395
376
  console.log(authURL);
396
377
  console.log();
397
-
398
- // Try to open browser
399
- const open = (await import('node:child_process')).spawn;
400
- const platform = process.platform;
401
- let cmd, args;
402
- if (platform === 'darwin') {
403
- cmd = 'open';
404
- args = [authURL];
405
- } else if (platform === 'win32') {
406
- cmd = 'cmd';
407
- // Windows: cmd.exe interprets & as a command separator, which truncates
408
- // the URL after the first parameter. Passing an empty string as the first
409
- // arg to `start` serves as the window title, and spawn auto-quotes the
410
- // URL arg, protecting & and other special chars from cmd.exe's parser.
411
- args = ['/c', 'start', '', authURL];
412
- } else {
413
- // WSL detection: check if we're running under Windows Subsystem for Linux
414
- // On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
415
- // on &. We call cmd.exe directly with proper quoting instead.
416
- const isWSL = platform === 'linux' && (await isWSLEnv());
417
- if (isWSL) {
418
- cmd = 'cmd.exe';
419
- args = ['/c', 'start', '', authURL];
420
- } else {
421
- cmd = 'xdg-open';
422
- args = [authURL];
423
- }
424
- }
425
- const child = open(cmd, args, { detached: true, stdio: 'ignore' });
426
- child.on('error', () => {
427
- // Browser open command not available — user will open the URL manually
428
- });
429
- child.unref();
378
+ console.log('(The browser is not opened automatically — if a browser tab opens,');
379
+ console.log(' use the URL above if the opened page shows an error.)');
380
+ console.log();
430
381
 
431
382
  console.log('After authenticating in the browser, copy the authorization code shown on the page.');
432
383
  console.log('(input is hidden for security — just paste and press Enter)');
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import { hideBin } from 'yargs/helpers';
5
5
  import process from 'node:process';
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
- import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
8
+ import { loadConfig, getEffectiveInstance, getActiveProfile, globalConfigDir } from './config.js';
9
9
  import { App } from './app.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand } from './mutations.js';
@@ -13,7 +13,6 @@ import { isMutationCommand } from './mutations.js';
13
13
  // Command modules
14
14
  import { setupCmd } from './commands/setup.js';
15
15
  import { authCmd } from './commands/auth.js';
16
- import { profilesCmd } from './commands/profiles.js';
17
16
  import { recordsCmd } from './commands/records.js';
18
17
  import { catalogCmd } from './commands/catalog.js';
19
18
  import { incidentsCmd } from './commands/incidents.js';
@@ -176,10 +175,10 @@ export function buildCLI() {
176
175
  argv._overrideInstance = argv.instance;
177
176
  }
178
177
  },
179
- // Guard mutation commands when no instance configured
178
+ // Guard mutation commands: require an instance, block read-only profiles
180
179
  (argv) => {
181
180
  const cmd = (argv._[0] || '').toString();
182
- if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill', 'docs'].includes(cmd)) {
181
+ if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'skill', 'docs'].includes(cmd)) {
183
182
  return;
184
183
  }
185
184
  // Check for --instance override
@@ -187,11 +186,26 @@ export function buildCLI() {
187
186
  app._overrideInstance = argv.instance;
188
187
  argv._overrideInstance = argv.instance;
189
188
  }
190
- // Determine if this is a mutation
191
- const subcommand = (argv._[1] || '').toString();
192
- const isMutation = isMutationCommand(cmd, subcommand);
193
- if (isMutation) {
194
- app.requireInstance();
189
+ if (isMutationCommand(argv)) {
190
+ try {
191
+ app.requireInstance();
192
+ } catch (err) {
193
+ // requireInstance throws errUsage; format it cleanly (the yargs
194
+ // fail handler re-throws middleware errors, which would print a
195
+ // raw stack trace).
196
+ process.stderr.write(`Error: ${err.message}\n`);
197
+ process.exit(1);
198
+ }
199
+ const profile = getActiveProfile(cfg);
200
+ if (profile?.read_only === true) {
201
+ const name = cfg.activeProfile || cfg.defaultProfile;
202
+ process.stderr.write(
203
+ `Error: Profile "${name}" is read-only. Mutations are blocked.\n\n` +
204
+ 'Switch to a write-enabled profile first:\n' +
205
+ ` jsn auth switch <name>\n`
206
+ );
207
+ process.exit(1);
208
+ }
195
209
  }
196
210
  },
197
211
  ])
@@ -277,7 +291,6 @@ export function buildCLI() {
277
291
  // CONFIGURATION
278
292
  .command(setupCmd(wrap))
279
293
  .command(authCmd(wrap))
280
- .command(profilesCmd(wrap))
281
294
  .command(updateSetsCmd(wrap))
282
295
  .command(scopesCmd(wrap))
283
296
  // CORE
@@ -1,16 +1,176 @@
1
- import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
1
+ import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
2
2
  import { errUsage } from '../errors.js';
3
3
  import process from 'node:process';
4
4
 
5
+ /**
6
+ * Resolve a login/logout/refresh argument to a concrete instance URL.
7
+ *
8
+ * gh-style precision: a full URL is used as-is; a bare name is first checked
9
+ * against known profile names (gh: --hostname), then treated as a ServiceNow
10
+ * instance name with the canonical `.service-now.com` host appended. This kills
11
+ * the old trap where `jsn auth login dev99999` silently targeted
12
+ * `https://dev99999` (a dead host).
13
+ */
14
+ export function resolveInstanceArg(arg, cfg) {
15
+ if (!arg) return '';
16
+ const trimmed = arg.trim();
17
+ if (/^https?:\/\//i.test(trimmed)) return normalizeInstanceURL(trimmed);
18
+ const profile = cfg?.profiles?.[trimmed];
19
+ if (profile?.instance_url) return normalizeInstanceURL(profile.instance_url);
20
+ // Already looks like a host (has a dot) — add the scheme, nothing else
21
+ if (trimmed.includes('.')) return normalizeInstanceURL(trimmed);
22
+ // Bare ServiceNow instance name → canonical host
23
+ return normalizeInstanceURL(`${trimmed}.service-now.com`);
24
+ }
25
+
26
+ /**
27
+ * Interactive first-run wizard (formerly `jsn setup`).
28
+ *
29
+ * Walks: instance URL → profile name → auth method → optional OAuth login.
30
+ * Creates and activates the profile, then returns the result so the caller
31
+ * decides how to report it. Basic auth saves credentials directly (the OAuth
32
+ * path has no interactive username/password).
33
+ */
34
+ export async function loginWizard(app, argv = {}) {
35
+ const readline = (await import('node:readline')).default;
36
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
37
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
38
+
39
+ console.log('Welcome to JSN - ServiceNow CLI');
40
+ console.log();
41
+
42
+ let instance = getEffectiveInstance(app.config);
43
+ if (!instance) {
44
+ instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
45
+ instance = normalizeInstanceURL(instance);
46
+ }
47
+ console.log(`Instance: ${instance}`);
48
+
49
+ // Warn if another profile already uses this instance URL
50
+ const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
51
+ if (existing) {
52
+ console.log(`Note: instance is also used by profile "${existing[0]}".`);
53
+ }
54
+
55
+ const profileName = (await ask('Profile name (default): ')) || 'default';
56
+ const profile = { instance_url: instance };
57
+ if (argv['read-only']) {
58
+ profile.read_only = true;
59
+ }
60
+
61
+ const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
62
+ const useBasic = authMethod.toLowerCase().startsWith('b');
63
+
64
+ let loggedIn = false;
65
+ if (useBasic) {
66
+ const username = await ask('Username: ');
67
+ rl.close(); // Close outer readline before askHidden creates its own
68
+ const { loadCredentials, askHidden, saveCredentials } = await import('../auth.js');
69
+ const existingCreds = loadCredentials(instance) || loadCredentials(instance, username);
70
+ if (existingCreds && existingCreds.auth_method === 'basic') {
71
+ console.log(`Using existing credentials for ${instance}`);
72
+ } else {
73
+ const password = await askHidden('Password: ');
74
+ saveCredentials(instance, { auth_method: 'basic', username, password }, username);
75
+ console.log('Basic auth credentials saved');
76
+ }
77
+ profile.username = username;
78
+ profile.auth_method = 'basic';
79
+ } else {
80
+ profile.auth_method = 'oauth';
81
+ }
82
+
83
+ await setProfile(app.config, profileName, profile);
84
+ // Set as active profile so subsequent commands know which instance to use
85
+ app.config.activeProfile = profileName;
86
+ app.config.defaultProfile = profileName;
87
+ await saveConfig(app.config);
88
+
89
+ if (!useBasic) {
90
+ const loginNow = await ask('Login now? [Y/n]: ');
91
+ rl.close();
92
+ if (!loginNow || loginNow.toLowerCase() !== 'n') {
93
+ await app.auth.login(instance);
94
+ // Re-save credentials under <user>@<instance> after verifying username
95
+ try {
96
+ const { SDKClient } = await import('../sdk.js');
97
+ const sdk = new SDKClient(instance, app.auth);
98
+ const user = await sdk.getCurrentUser();
99
+ if (user && user.user_name) {
100
+ const { loadCredentials, saveCredentials } = await import('../auth.js');
101
+ const stored = loadCredentials(instance);
102
+ if (stored && stored.access_token) {
103
+ stored.username = user.user_name;
104
+ saveCredentials(instance, stored, user.user_name);
105
+ }
106
+ profile.username = user.user_name;
107
+ }
108
+ } catch {
109
+ // Non-fatal — token saved, username will be set on next auth login
110
+ }
111
+ console.log('Login successful!');
112
+ loggedIn = true;
113
+ }
114
+ } else {
115
+ rl.close();
116
+ }
117
+
118
+ return { instanceURL: instance, profileName, loggedIn };
119
+ }
120
+
121
+ /**
122
+ * gh-faithful login target picker: existing profiles plus an "add a new
123
+ * instance" row. Typing filters profiles; picking the + row uses what you
124
+ * typed (or prompts for it).
125
+ *
126
+ * Returns { addNew: true, instanceName } or { addNew: false, name }.
127
+ */
128
+ async function pickLoginTarget(app) {
129
+ const { search, input } = await import('@inquirer/prompts');
130
+ const ADD_NEW = '__add_new__';
131
+ const profileNames = Object.keys(app.config.profiles || {});
132
+ let lastInput = '';
133
+ const selected = await search({
134
+ message: 'Log in to which instance?',
135
+ source: (term) => {
136
+ const filter = (term || '').toLowerCase();
137
+ lastInput = term || '';
138
+ const profiles = profileNames
139
+ .filter((name) => {
140
+ const label = `${name} — ${app.config.profiles[name].instance_url}`.toLowerCase();
141
+ return !filter || label.includes(filter);
142
+ })
143
+ .map((name) => ({
144
+ name: `${name} — ${app.config.profiles[name].instance_url}`,
145
+ value: name,
146
+ }));
147
+ const addLabel = filter
148
+ ? `+ Add "${term.trim()}" as a new instance`
149
+ : '+ Add a new instance';
150
+ return [{ name: addLabel, value: ADD_NEW }, ...profiles];
151
+ },
152
+ });
153
+ if (selected === ADD_NEW) {
154
+ const typed = lastInput.trim();
155
+ if (typed) return { addNew: true, instanceName: typed };
156
+ const name = await input({
157
+ message: 'ServiceNow instance name or URL (e.g. dev12345):',
158
+ validate: (v) => (v.trim() ? true : 'Instance name or URL is required'),
159
+ });
160
+ return { addNew: true, instanceName: name.trim() };
161
+ }
162
+ return { addNew: false, name: selected };
163
+ }
164
+
5
165
  export function authCmd(wrap) {
6
166
  return {
7
- command: 'auth <subcommand>',
167
+ command: 'auth [subcommand]',
8
168
  describe: 'Manage authentication (OAuth or basic auth via env vars)',
9
169
  builder: (yargs) => {
10
170
  return yargs
11
171
  .command({
12
172
  command: 'login [instance]',
13
- describe: 'Authenticate with a ServiceNow instance via OAuth',
173
+ describe: 'Authenticate with a ServiceNow instance (interactive picker when run bare)',
14
174
  builder: (sub) => sub
15
175
  .option('code', {
16
176
  describe: 'Authorization code from browser (bypasses interactive prompt)',
@@ -27,38 +187,42 @@ export function authCmd(wrap) {
27
187
  .option('wait-file', {
28
188
  describe: 'File path to watch for authorization code (used with --print-url)',
29
189
  type: 'string',
190
+ })
191
+ .option('read-only', {
192
+ describe: 'Mark the created profile as read-only (blocks mutation commands)',
193
+ type: 'boolean',
194
+ default: false,
30
195
  }),
31
196
  handler: wrap(async (argv, app) => {
32
197
  let instanceURL;
33
198
  if (argv.instance) {
34
- // Check if it's a profile name first
35
- const profiles = app.config.profiles || {};
36
- if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
37
- instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
199
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
200
+ } else if (app.isInteractive()) {
201
+ // gh-faithful: interactive terminals always get a picker —
202
+ // existing profiles plus "+ Add a new instance". Zero profiles
203
+ // goes straight to the setup wizard.
204
+ const profileNames = Object.keys(app.config.profiles || {});
205
+ if (profileNames.length === 0) {
206
+ const wizard = await loginWizard(app, argv);
207
+ if (wizard.loggedIn) {
208
+ app.ok({ authenticated: true, instance: wizard.instanceURL, profile: wizard.profileName },
209
+ { summary: `✓ Authenticated to ${wizard.instanceURL} (profile: ${wizard.profileName})` });
210
+ } else {
211
+ app.ok({ setup: true, instance: wizard.instanceURL, profile: wizard.profileName },
212
+ { summary: `Profile "${wizard.profileName}" created for ${wizard.instanceURL}. Run "jsn auth login" to authenticate.` });
213
+ }
214
+ return;
215
+ }
216
+ const target = await pickLoginTarget(app);
217
+ if (target.addNew) {
218
+ instanceURL = resolveInstanceArg(target.instanceName, app.config);
38
219
  } else {
39
- instanceURL = normalizeInstanceURL(argv.instance);
220
+ instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
40
221
  }
41
222
  } else {
42
223
  instanceURL = getEffectiveInstance(app.config);
43
224
  if (!instanceURL) {
44
- // Try interactive profile picker
45
- const profileNames = Object.keys(app.config.profiles || {});
46
- if (profileNames.length > 0 && app.isInteractive()) {
47
- const { search } = await import('@inquirer/prompts');
48
- const choices = profileNames.map(name => ({
49
- name: `${name} — ${app.config.profiles[name].instance_url}`,
50
- value: name,
51
- }));
52
- const selected = await search({
53
- message: 'Select a profile:',
54
- source: (input) => {
55
- const filter = (input || '').toLowerCase();
56
- return choices.filter(c => c.name.toLowerCase().includes(filter));
57
- },
58
- });
59
- instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
60
- } else {
61
- throw errUsage(`Instance URL required.
225
+ throw errUsage(`Instance URL required.
62
226
 
63
227
  Examples:
64
228
  jsn auth login https://dev12345.service-now.com
@@ -66,7 +230,6 @@ Examples:
66
230
  jsn auth login --password https://dev328604.service-now.com
67
231
 
68
232
  Find your instance URL in your browser's address bar when logged into ServiceNow.`);
69
- }
70
233
  }
71
234
  }
72
235
 
@@ -129,6 +292,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
129
292
  instance_url: instanceURL,
130
293
  auth_method: argv.password ? 'basic' : 'oauth',
131
294
  username: username || undefined,
295
+ read_only: argv['read-only'] || undefined,
132
296
  };
133
297
 
134
298
  // Re-save OAuth credentials with username now that we have it,
@@ -169,11 +333,11 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
169
333
  })
170
334
  .command({
171
335
  command: 'logout [instance]',
172
- describe: 'Remove stored OAuth credentials',
336
+ describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
173
337
  handler: wrap(async (argv, app) => {
174
338
  let instanceURL;
175
339
  if (argv.instance) {
176
- instanceURL = normalizeInstanceURL(argv.instance);
340
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
177
341
  } else {
178
342
  instanceURL = getEffectiveInstance(app.config);
179
343
  if (!instanceURL) {
@@ -240,6 +404,7 @@ Examples:
240
404
  days_since_last_seen: daysSinceLastSeen,
241
405
  stale: daysSinceLastSeen > 7,
242
406
  default: instance === defaultInstance,
407
+ read_only: profile.read_only || false,
243
408
  });
244
409
  }
245
410
 
@@ -273,13 +438,7 @@ Examples:
273
438
  handler: wrap(async (argv, app) => {
274
439
  let instanceURL;
275
440
  if (argv.instance) {
276
- // Check if it's a profile name or URL
277
- const profiles = app.config.profiles || {};
278
- if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
279
- instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
280
- } else {
281
- instanceURL = normalizeInstanceURL(argv.instance);
282
- }
441
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
283
442
  } else {
284
443
  instanceURL = getEffectiveInstance(app.config);
285
444
  if (!instanceURL) {
@@ -300,17 +459,111 @@ Examples:
300
459
  expires_at: refreshed.expires_at,
301
460
  }, { summary: `✓ Token refreshed for ${instanceURL}` });
302
461
  }),
462
+ })
463
+ .command({
464
+ command: 'switch [name]',
465
+ describe: 'Switch active profile (gh: auth switch)',
466
+ builder: (sub) => sub
467
+ .positional('name', {
468
+ describe: 'Profile name to activate',
469
+ type: 'string',
470
+ }),
471
+ handler: wrap(async (argv, app) => {
472
+ let name = argv.name;
473
+ if (!name) {
474
+ const profileNames = Object.keys(app.config.profiles || {});
475
+ if (profileNames.length === 0) {
476
+ throw errUsage('No profiles configured. Run "jsn auth login" first.');
477
+ }
478
+ if (profileNames.length > 1 && app.isInteractive()) {
479
+ const { search } = await import('@inquirer/prompts');
480
+ const choices = profileNames.map(profileName => ({
481
+ name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
482
+ value: profileName,
483
+ }));
484
+ const selected = await search({
485
+ message: 'Select a profile:',
486
+ source: (input) => {
487
+ const filter = (input || '').toLowerCase();
488
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
489
+ },
490
+ });
491
+ name = selected;
492
+ } else {
493
+ name = profileNames[0];
494
+ }
495
+ }
496
+ await setActiveProfile(app.config, name);
497
+ app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
498
+ }),
499
+ })
500
+ .command({
501
+ command: 'remove [name]',
502
+ describe: 'Delete an instance entry and its stored credentials',
503
+ builder: (sub) => sub
504
+ .positional('name', {
505
+ describe: 'Profile name to delete',
506
+ type: 'string',
507
+ }),
508
+ handler: wrap(async (argv, app) => {
509
+ let name = argv.name;
510
+ if (!name) {
511
+ const profileNames = Object.keys(app.config.profiles || {});
512
+ if (profileNames.length === 0) {
513
+ throw errUsage('No profiles configured. Nothing to remove.');
514
+ }
515
+ if (profileNames.length > 1 && app.isInteractive()) {
516
+ const { search } = await import('@inquirer/prompts');
517
+ const choices = profileNames.map(profileName => ({
518
+ name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
519
+ value: profileName,
520
+ }));
521
+ const selected = await search({
522
+ message: 'Remove which profile?',
523
+ source: (input) => {
524
+ const filter = (input || '').toLowerCase();
525
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
526
+ },
527
+ });
528
+ name = selected;
529
+ } else {
530
+ name = profileNames[0];
531
+ }
532
+ }
533
+ if (!app.config.profiles[name]) {
534
+ throw new Error(`Profile not found: ${name}`);
535
+ }
536
+ const instance = app.config.profiles[name].instance_url;
537
+ delete app.config.profiles[name];
538
+ if (app.config.defaultProfile === name) app.config.defaultProfile = '';
539
+ if (app.config.activeProfile === name) app.config.activeProfile = '';
540
+ saveConfig(app.config);
541
+
542
+ // Only clear credentials if no other profile uses this instance URL
543
+ const stillInUse = instance && Object.values(app.config.profiles || {})
544
+ .some(p => p.instance_url === instance);
545
+ if (instance && !stillInUse) {
546
+ app.auth.logout(instance);
547
+ }
548
+
549
+ app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
550
+ }),
303
551
  });
304
552
  },
305
553
  handler: () => {
306
- console.log('Manage OAuth authentication for ServiceNow instances.');
554
+ console.log('Manage authentication for ServiceNow instances.');
307
555
  console.log('');
308
556
  console.log('Available subcommands:');
309
- console.log(' login <url> Login with OAuth (supports --print-url, --code for CI/CD)');
310
- console.log(' status Show auth status for the active profile');
311
- console.log(' refresh Refresh the access token');
557
+ console.log(' login <url|name> Log in / add an instance (picker when run bare)');
558
+ console.log(' status Show auth status for all profiles');
559
+ console.log(' switch <name> Switch the active profile');
560
+ console.log(' refresh Refresh the access token');
561
+ console.log(' logout Clear credentials (keeps the instance entry)');
562
+ console.log(' remove <name> Delete an instance entry and its credentials');
312
563
  console.log('');
313
564
  console.log('Run "jsn auth <command> --help" for details.');
565
+ console.log('');
566
+ console.log('Tip: "jsn auth login" is the only command you need to add an instance.');
314
567
  },
315
568
  };
316
569
  }
@@ -139,7 +139,7 @@ export function flowsCmd(wrap) {
139
139
  };
140
140
  }
141
141
 
142
- async function formatFlowInspection(inspection, ctx) {
142
+ export async function formatFlowInspection(inspection, ctx) {
143
143
  const lines = [];
144
144
  const instanceURL = ctx?.instanceURL || '';
145
145
  const flow = inspection.flow;
@@ -156,6 +156,27 @@ async function formatFlowInspection(inspection, ctx) {
156
156
  lines.push(` Link: ${instanceURL}/sys_hub_flow.do?sys_id=${flow.sysID}`);
157
157
  }
158
158
 
159
+ // Flow variables section
160
+ if (inspection.flowVariables && inspection.flowVariables.length > 0) {
161
+ const vars = [...inspection.flowVariables].sort((a, b) => {
162
+ const ao = parseInt(getStringField(a, 'order'), 10) || 0;
163
+ const bo = parseInt(getStringField(b, 'order'), 10) || 0;
164
+ return ao - bo;
165
+ });
166
+ lines.push('');
167
+ lines.push('▶ FLOW VARIABLES');
168
+ lines.push('─'.repeat(50));
169
+ for (const v of vars) {
170
+ const name = firstNonEmpty(getStringField(v, 'label'), getStringField(v, 'name'), 'Variable');
171
+ const typeName = firstNonEmpty(getStringField(v, 'type_label'), getStringField(v, 'type'), '');
172
+ if (typeName) {
173
+ lines.push(` • ${name}: ${typeName}`);
174
+ } else {
175
+ lines.push(` • ${name}`);
176
+ }
177
+ }
178
+ }
179
+
159
180
  // Subflow I/O section
160
181
  if (flow.type && flow.type.toLowerCase() === 'subflow') {
161
182
  lines.push('');
@@ -480,7 +501,7 @@ async function formatStepLine(stepNum, pad, step, ctx) {
480
501
  }
481
502
  }
482
503
 
483
- function formatActionStep(stepNum, pad, action) {
504
+ export function formatActionStep(stepNum, pad, action) {
484
505
  const lines = [];
485
506
  let actionName = firstNonEmpty(
486
507
  getNestedString(action, 'actionType', 'fName'),
@@ -527,16 +548,22 @@ function formatActionStep(stepNum, pad, action) {
527
548
 
528
549
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
529
550
  if (!inputValue) continue;
530
- if (inputValue.length > 50) {
531
- inputValue = inputValue.slice(0, 47) + '...';
532
- }
533
551
 
534
552
  let label = inputName;
535
553
  if (raw.parameter && typeof raw.parameter === 'object') {
536
554
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
537
555
  }
538
556
 
539
- lines.push(`${pad} ${label}: ${inputValue}`);
557
+ // ServiceNow writes multi-field payloads (encoded-query style) as
558
+ // "field=value^field2=value2". Split those onto one line per field;
559
+ // plain long strings stay on a single line, untruncated.
560
+ if (inputValue.includes('^')) {
561
+ for (const field of inputValue.split('^')) {
562
+ if (field) lines.push(`${pad} ${label}: ${field}`);
563
+ }
564
+ } else {
565
+ lines.push(`${pad} ${label}: ${inputValue}`);
566
+ }
540
567
  }
541
568
  }
542
569
 
@@ -164,21 +164,6 @@ export function updateSetsCmd(wrap) {
164
164
  });
165
165
  }),
166
166
  })
167
- .command({
168
- command: 'yolo',
169
- aliases: ['silence'],
170
- describe: 'Silence the "Default update set" warning for this profile',
171
- handler: wrap(async (argv, app) => {
172
- const name = app.context.profileName;
173
- if (!name) {
174
- throw new Error('No active profile to mark as yolo. Run jsn setup first.');
175
- }
176
- app.config.profiles[name].yolo = true;
177
- const { saveConfig } = await import('../../config.js');
178
- saveConfig(app.config);
179
- app.ok({ profile: name, yolo: true }, { summary: 'Default update set warning silenced for this profile' });
180
- }),
181
- })
182
167
  .command({
183
168
  command: 'export <name>',
184
169
  describe: 'Export an update set to XML',
@@ -225,7 +210,6 @@ export function updateSetsCmd(wrap) {
225
210
  console.log(' create Create a new update set (auto-sets as current)');
226
211
  console.log(' export <name> Export an update set to XML');
227
212
  console.log(' complete <name> Mark an update set as complete (coming soon)');
228
- console.log(' yolo Silence the "Default update set" warning');
229
213
  console.log('\nRun "jsn updatesets <command> --help" for details.');
230
214
  console.log('\nTip: Create an update set first:');
231
215
  console.log(' jsn updatesets create --name "My Feature"');
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
- import { DatabaseSync } from 'node:sqlite';
6
+ import Database from 'better-sqlite3';
7
7
 
8
8
  const DOCS_DIR_NAME = 'servicenow-cli/docs';
9
9
 
@@ -37,7 +37,7 @@ export function ensureDocsCacheDir() {
37
37
  export function openDocsDb(opts = {}) {
38
38
  const dbPath = opts.dbPath || getDocsDbPath();
39
39
  ensureDocsCacheDir();
40
- const db = new DatabaseSync(dbPath);
40
+ const db = new Database(dbPath);
41
41
  db.exec('PRAGMA journal_mode = WAL');
42
42
  return db;
43
43
  }
@@ -1,7 +1,7 @@
1
1
  // CLI search against the docs SQLite database.
2
2
 
3
3
  import fs from 'node:fs';
4
- import { DatabaseSync } from 'node:sqlite';
4
+ import Database from 'better-sqlite3';
5
5
  import { getDocsDbPath, hasEmbeddings } from './db.js';
6
6
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
7
7
 
@@ -24,7 +24,7 @@ export function searchDocs(opts = {}) {
24
24
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
25
25
  }
26
26
 
27
- const db = new DatabaseSync(dbPath, { readOnly: true });
27
+ const db = new Database(dbPath, { readonly: true });
28
28
  const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
29
29
  const offset = parseInt(opts.offset, 10) || 0;
30
30
  const bundle = opts.bundle ? String(opts.bundle) : null;
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import http from 'node:http';
6
6
  import { URL } from 'node:url';
7
- import { DatabaseSync } from 'node:sqlite';
7
+ import Database from 'better-sqlite3';
8
8
  import { getDocsDbPath, hasEmbeddings } from './db.js';
9
9
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
10
10
 
@@ -72,7 +72,7 @@ export function serveDocs(opts = {}) {
72
72
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
73
73
  }
74
74
 
75
- const db = new DatabaseSync(dbPath, { readOnly: true });
75
+ const db = new Database(dbPath, { readonly: true });
76
76
  const hasEmb = hasEmbeddings(db);
77
77
  const hrrDim = (() => {
78
78
  try {
@@ -1,10 +1,13 @@
1
- import readline from 'node:readline';
2
- import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
1
+ import { loginWizard } from './auth.js';
3
2
 
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.
4
6
  export function setupCmd(wrap) {
5
7
  return {
6
8
  command: 'setup',
7
- describe: 'Interactive first-time 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
8
11
  builder: (yargs) => {
9
12
  return yargs
10
13
  .option('read-only', {
@@ -13,87 +16,10 @@ export function setupCmd(wrap) {
13
16
  default: false,
14
17
  });
15
18
  },
16
- handler: wrap(async (_argv, app) => {
17
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
18
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
19
-
20
- console.log('Welcome to JSN - ServiceNow CLI');
21
- console.log();
22
-
23
- let instance = getEffectiveInstance(app.config);
24
- if (!instance) {
25
- instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
26
- instance = normalizeInstanceURL(instance);
27
- }
28
- console.log(`Instance: ${instance}`);
29
-
30
- // Warn if another profile already uses this instance URL
31
- const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
32
- if (existing) {
33
- console.log(`Note: instance is also used by profile "${existing[0]}".`);
34
- }
35
-
36
- const profileName = (await ask('Profile name (default): ')) || 'default';
37
- const profile = { instance_url: instance };
38
- if (_argv['read-only']) {
39
- profile.read_only = true;
40
- }
41
-
42
- const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
43
- const useBasic = authMethod.toLowerCase().startsWith('b');
44
-
45
- if (useBasic) {
46
- const username = await ask('Username: ');
47
- rl.close(); // Close outer readline before askHidden creates its own
48
- const { loadCredentials, askHidden } = await import('../auth.js');
49
- const existing = loadCredentials(instance) || loadCredentials(instance, username);
50
- if (existing && existing.auth_method === 'basic') {
51
- console.log(`Using existing credentials for ${instance}`);
52
- } else {
53
- const password = await askHidden('Password: ');
54
- const { saveCredentials } = await import('../auth.js');
55
- saveCredentials(instance, { auth_method: 'basic', username, password }, username);
56
- profile.username = username;
57
- console.log('Basic auth credentials saved');
58
- }
59
- profile.auth_method = 'basic';
60
- } else {
61
- profile.auth_method = 'oauth';
62
- }
63
-
64
- await setProfile(app.config, profileName, profile);
65
- // Set as active profile so subsequent commands know which instance to use
66
- app.config.activeProfile = profileName;
67
- app.config.defaultProfile = profileName;
68
- saveConfig(app.config);
69
-
70
- if (!useBasic) {
71
- const loginNow = await ask('Login now? [Y/n]: ');
72
- if (!loginNow || loginNow.toLowerCase() !== 'n') {
73
- await app.auth.login(instance);
74
- // Re-save credentials under <user>@<instance> after verifying username
75
- try {
76
- const { SDKClient } = await import('./sdk.js');
77
- const sdk = new SDKClient(instance, app.auth);
78
- const user = await sdk.getCurrentUser();
79
- if (user && user.user_name) {
80
- const { loadCredentials, saveCredentials } = await import('../auth.js');
81
- const stored = loadCredentials(instance);
82
- if (stored && stored.access_token) {
83
- stored.username = user.user_name;
84
- saveCredentials(instance, stored, user.user_name);
85
- }
86
- profile.username = user.user_name;
87
- }
88
- } catch {
89
- // Non-fatal — token saved, username will be set on next auth login
90
- }
91
- console.log('Login successful!');
92
- }
93
- }
94
-
95
- rl.close();
96
- app.ok({ setup: true, instance, profile: profileName }, { summary: 'Setup complete' });
19
+ 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' });
97
23
  }),
98
24
  };
99
25
  }
@@ -39,7 +39,10 @@ function realHomeDir() {
39
39
  * profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
40
40
  */
41
41
  function hermesSkillDir() {
42
- const base = path.join(realHomeDir(), '.hermes');
42
+ // JSN_HERMES_BASE_DIR lets tests point at a temp .hermes tree.
43
+ // realHomeDir() deliberately bypasses $HOME (Hermes overrides it to a
44
+ // sandbox dir), so tests can't control the base via HOME.
45
+ const base = process.env.JSN_HERMES_BASE_DIR || path.join(realHomeDir(), '.hermes');
43
46
  const profileEnv = process.env.HERMES_PROFILE;
44
47
  let profileName = profileEnv;
45
48
 
package/src/config.js CHANGED
@@ -168,3 +168,12 @@ export function setProfile(cfg, name, profile) {
168
168
  cfg.profiles[name] = profile;
169
169
  return saveConfig(cfg);
170
170
  }
171
+
172
+ export function setActiveProfile(cfg, name) {
173
+ if (!cfg.profiles || !cfg.profiles[name]) {
174
+ throw new Error(`Profile not found: ${name}`);
175
+ }
176
+ cfg.defaultProfile = name;
177
+ cfg.activeProfile = name;
178
+ return saveConfig(cfg);
179
+ }
package/src/help.js CHANGED
@@ -107,10 +107,8 @@ const DATA_COMMANDS = [
107
107
  ];
108
108
 
109
109
  const CONFIG_COMMANDS = [
110
- { name: 'setup', desc: 'Interactive first-time setup' },
111
- { name: 'auth', desc: 'OAuth authentication' },
112
- { name: 'profiles', alias: 'profile', desc: 'Instance profiles' },
113
- { name: 'updatesets', desc: 'Update sets (set, yolo, export, import)' },
110
+ { name: 'auth', desc: 'Authentication (login, status, switch, refresh, logout, remove)' },
111
+ { name: 'updatesets', desc: 'Update sets (set, create, export)' },
114
112
  { name: 'scopes', desc: 'Application scopes' },
115
113
  ];
116
114
 
package/src/mutations.js CHANGED
@@ -2,6 +2,29 @@
2
2
  // Used by the read-only profile middleware to block mutation commands.
3
3
  // Each entry is a command path array matching argv._ tokens.
4
4
 
5
+ // Dev commands that expose create/update/delete via buildDevCmd.
6
+ // Each generates the root form (`jsn <name> create`) and the legacy dev form
7
+ // (`jsn dev <name> create`), so read-only profiles block both spellings.
8
+ const DEV_CRUD_COMMANDS = [
9
+ 'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
10
+ 'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
11
+ 'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
12
+ 'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
13
+ 'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
14
+ 'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
15
+ ];
16
+
17
+ function devCrudPaths(name) {
18
+ return [
19
+ [name, 'create'],
20
+ [name, 'update'],
21
+ [name, 'delete'],
22
+ ['dev', name, 'create'],
23
+ ['dev', name, 'update'],
24
+ ['dev', name, 'delete'],
25
+ ];
26
+ }
27
+
5
28
  export const MUTATION_COMMANDS = [
6
29
  // Incidents
7
30
  ['incidents', 'create'],
@@ -37,18 +60,20 @@ export const MUTATION_COMMANDS = [
37
60
  ['groups', 'delete'],
38
61
  // Catalog
39
62
  ['catalog', 'create-item'],
40
- // Dev flows
41
- ['dev', 'flows', 'create'],
42
- ['dev', 'flows', 'update'],
43
- ['dev', 'flows', 'delete'],
44
- // Dev scopes
63
+ // Dev CRUD commands (root + `jsn dev` forms)
64
+ ...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
65
+ // Dev eval (no sub-action)
66
+ ['dev', 'eval'],
67
+ // Scopes: create + set (root + dev forms)
68
+ ['scopes', 'create'],
69
+ ['scopes', 'set'],
45
70
  ['dev', 'scopes', 'create'],
46
71
  ['dev', 'scopes', 'set'],
47
- // Dev eval
48
- ['dev', 'eval'],
49
- // Dev updatesets
50
- ['dev', 'updatesets', 'set'],
72
+ // Update sets: create + set (root + dev forms)
73
+ ['updatesets', 'create'],
74
+ ['updatesets', 'set'],
51
75
  ['dev', 'updatesets', 'create'],
76
+ ['dev', 'updatesets', 'set'],
52
77
  ];
53
78
 
54
79
  /**
package/src/sdk.js CHANGED
@@ -245,6 +245,7 @@ export class SDKClient {
245
245
  subFlowInstances: [],
246
246
  flowInputs: [],
247
247
  flowOutputs: [],
248
+ flowVariables: [],
248
249
  };
249
250
 
250
251
  // 2) Prefer the ProcessFlow API — the Flow Designer UI's own source.
@@ -372,6 +373,20 @@ export class SDKClient {
372
373
  if (records) inspection.flowOutputs = records;
373
374
  }
374
375
 
376
+ // Flow variables fallback (payload.flowVariables may be empty on legacy
377
+ // payload shapes, but sys_hub_flow_variable rows exist for every flow).
378
+ // Note: the table only carries name/label/order — type is empty there, so
379
+ // prefer payload.flowVariables when available.
380
+ if (!inspection.flowVariables || inspection.flowVariables.length === 0) {
381
+ const varsQuery = new URLSearchParams();
382
+ varsQuery.set('sysparm_display_value', 'all');
383
+ varsQuery.set('sysparm_query', `model=${flowSysID}^ORDERBYorder`);
384
+ varsQuery.set('sysparm_fields', 'sys_id,name,label,type,order');
385
+ varsQuery.set('sysparm_limit', '200');
386
+ const records = await this.list('sys_hub_flow_variable', varsQuery);
387
+ if (records) inspection.flowVariables = records;
388
+ }
389
+
375
390
  return inspection;
376
391
  }
377
392
 
@@ -635,6 +650,9 @@ function extractPayloadData(inspection, payload) {
635
650
  const outputs = toMapSlice(payload.outputs);
636
651
  if (outputs) inspection.flowOutputs = outputs;
637
652
 
653
+ const flowVariables = toMapSlice(payload.flowVariables);
654
+ if (flowVariables) inspection.flowVariables = flowVariables;
655
+
638
656
  const triggerInstances = payload.triggerInstances;
639
657
  if (!Array.isArray(triggerInstances) || triggerInstances.length === 0) return;
640
658
 
@@ -1,190 +0,0 @@
1
- import { normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
2
-
3
- export function profilesCmd(wrap) {
4
- return {
5
- command: 'profiles [subcommand]',
6
- aliases: ['profile'],
7
- describe: 'Manage configuration profiles',
8
- builder: (yargs) => {
9
- return yargs
10
- .command({
11
- command: 'create <name>',
12
- describe: 'Create a new profile',
13
- builder: (sub) => sub
14
- .positional('name', {
15
- describe: 'Profile name',
16
- type: 'string',
17
- })
18
- .option('instance', {
19
- alias: 'i',
20
- describe: 'ServiceNow instance URL (e.g. dev12345.service-now.com)',
21
- type: 'string',
22
- })
23
- .option('username', {
24
- alias: 'u',
25
- describe: 'Username for basic auth',
26
- type: 'string',
27
- })
28
- .option('password', {
29
- alias: 'p',
30
- describe: 'Password for basic auth',
31
- type: 'string',
32
- })
33
- .option('read-only', {
34
- describe: 'Mark profile as read-only (blocks mutation commands)',
35
- type: 'boolean',
36
- default: false,
37
- }),
38
- handler: wrap(async (argv, app) => {
39
- const name = argv.name;
40
- if (app.config.profiles && app.config.profiles[name]) {
41
- throw new Error(`Profile "${name}" already exists. Use "jsn profiles update" or remove it first.`);
42
- }
43
-
44
- let instance = argv.instance;
45
- if (!instance) {
46
- throw new Error(`Instance URL is required.\n\n jsn profiles create ${name} --instance https://dev12345.service-now.com`);
47
- }
48
- instance = normalizeInstanceURL(instance);
49
-
50
- const profile = { instance_url: instance };
51
-
52
- // If username/password provided, save credentials too
53
- if (argv.username && argv.password) {
54
- const { saveCredentials } = await import('../auth.js');
55
- saveCredentials(instance, {
56
- auth_method: 'basic',
57
- username: argv.username,
58
- password: argv.password,
59
- }, argv.username);
60
- profile.auth_method = 'basic';
61
- profile.username = argv.username;
62
- }
63
-
64
- // If read-only flag is set, mark the profile
65
- if (argv['read-only']) {
66
- profile.read_only = true;
67
- }
68
-
69
- await setProfile(app.config, name, profile);
70
-
71
- app.ok({
72
- profile: name,
73
- instance,
74
- auth_method: profile.auth_method || 'oauth (not yet logged in)',
75
- }, { summary: `Profile "${name}" created for ${instance}` });
76
- }),
77
- })
78
- .command({
79
- command: 'list',
80
- describe: 'List all profiles',
81
- handler: wrap(async (_argv, app) => {
82
- const profiles = [];
83
- for (const [name, profile] of Object.entries(app.config.profiles || {})) {
84
- const instance = profile.instance_url || '';
85
- const isAuth = app.auth.isAuthenticatedFor(instance);
86
- const isDefault = name === app.config.defaultProfile;
87
- profiles.push({
88
- name,
89
- instance,
90
- username: profile.username || '',
91
- authenticated: isAuth,
92
- default: isDefault,
93
- read_only: profile.read_only || false,
94
- });
95
- }
96
-
97
- // Build context-aware breadcrumb: suggest switching to a non-active profile
98
- const breadcrumbs = [];
99
- if (profiles.length > 0) {
100
- const activeName = app.config.activeProfile || app.config.defaultProfile;
101
- const nonActiveProfiles = profiles.filter(p => p.name !== activeName);
102
- if (nonActiveProfiles.length > 0) {
103
- const suggestName = nonActiveProfiles[0].name;
104
- breadcrumbs.push({
105
- action: 'switch',
106
- cmd: `jsn profiles use ${suggestName}`,
107
- description: 'Switch to a different profile',
108
- });
109
- }
110
- }
111
-
112
- app.ok({ profiles }, { summary: `${profiles.length} profile(s)`, breadcrumbs });
113
- }),
114
- })
115
- .command({
116
- command: 'use <name>',
117
- describe: 'Set active profile',
118
- handler: wrap(async (argv, app) => {
119
- const name = argv.name;
120
- if (!app.config.profiles[name]) {
121
- throw new Error(`Profile not found: ${name}`);
122
- }
123
- app.config.defaultProfile = name;
124
- app.config.activeProfile = name;
125
- saveConfig(app.config);
126
- app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
127
- }),
128
- })
129
- .command({
130
- command: 'show [name]',
131
- describe: 'Show profile details',
132
- handler: wrap(async (argv, app) => {
133
- const name = argv.name || app.config.defaultProfile || Object.keys(app.config.profiles || {})[0];
134
- if (!name || !app.config.profiles[name]) {
135
- throw new Error('No profiles configured');
136
- }
137
- const profile = app.config.profiles[name];
138
- const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
139
- app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
140
- }),
141
- })
142
- .command({
143
- command: 'remove <name>',
144
- describe: 'Remove a profile',
145
- handler: wrap(async (argv, app) => {
146
- const name = argv.name;
147
- if (!app.config.profiles[name]) {
148
- throw new Error(`Profile not found: ${name}`);
149
- }
150
- const instance = app.config.profiles[name].instance_url;
151
- delete app.config.profiles[name];
152
- if (app.config.defaultProfile === name) {
153
- app.config.defaultProfile = '';
154
- }
155
- if (app.config.activeProfile === name) {
156
- app.config.activeProfile = '';
157
- }
158
- saveConfig(app.config);
159
-
160
- // Only clean up credentials if no other profile uses this instance URL
161
- const stillInUse = instance && Object.values(app.config.profiles || {})
162
- .some(p => p.instance_url === instance);
163
- if (instance && !stillInUse) {
164
- app.auth.logout(instance);
165
- }
166
-
167
- app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
168
- }),
169
- })
170
-
171
- },
172
- handler: (argv) => {
173
- // When no subcommand is given, show help
174
- if (!argv._[1]) {
175
- console.log('Manage your ServiceNow instance profiles.\n');
176
- console.log('Usage: jsn profiles <create|list|show|use|remove> [options]\n');
177
- console.log('Commands:');
178
- console.log(' create <name> Create a new profile (--instance required)');
179
- console.log(' list List all profiles');
180
- console.log(' show [name] Show profile details');
181
- console.log(' use <name> Switch to a different profile');
182
- console.log(' remove <name> Remove a profile');
183
- console.log('\nRun "jsn profiles <command> --help" for more details.');
184
- console.log('\nNote: You can also create a profile by authenticating:');
185
- console.log(' jsn auth login --profile <name> <instance-url>');
186
- console.log(' jsn auth login --profile staging --password https://dev12345.service-now.com');
187
- }
188
- },
189
- };
190
- }