@jacebenson/jsn 4.3.9 → 4.3.10

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.
@@ -1,65 +1,97 @@
1
- import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
1
+ import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
2
  import { errUsage, errUsageHint } from '../errors.js';
3
- import { canUsePicker } from '../helpers.js';
3
+ import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
  import process from 'node:process';
6
+ import { refreshSession } from '../session.js';
7
+
8
+ function syncSession(app) {
9
+ if (typeof app._buildSdk === 'function' && typeof app.loadContext === 'function') refreshSession(app);
10
+ }
6
11
 
7
12
  // Auth manages local credentials/profiles — runs without a configured
8
13
  // instance. (Not in the daily-check skip-list: legacy behavior checks for
9
14
  // updates here.)
10
- declareCapabilities('auth', { noInstance: true });
15
+ declareCapabilities('auth', { noInstance: true, skipContextHeader: true });
11
16
 
12
- /**
13
- * Render the `jsn auth status` styled (TTY) view: one line per profile with
14
- * default marker, auth ✓/✗, 🔒 read_only, ⚡ skip_confirmations, ✅/⚠️
15
- * live-verification, and stale-last-seen hints.
16
- *
17
- * This view logic lives in the auth command — NOT in src/output.js — and is
18
- * shipped to the OutputWriter as `data._formatted` (the documented escape
19
- * hatch for command-curated visuals; see the interface comment in
20
- * src/output.js). The `summary` line is folded into the formatted string
21
- * because the writer suppresses opts.summary when _formatted is present.
22
- * Golden-pinned by test/auth.test.js ("auth status styled output").
23
- */
24
- export function renderAuthStatus(result, summary) {
25
- let out = summary ? `${summary}\n\n` : '';
17
+ /** Render a compact status table; identity fields require explicit details. */
18
+ export function renderAuthStatus(result, summary, { details = false } = {}) {
26
19
  const profiles = Array.isArray(result.profiles) ? result.profiles : [];
27
- if (profiles.length > 0) {
28
- out += '\n';
29
- for (const p of profiles) {
30
- const prefix = p.default ? '* ' : ' ';
31
- const authIcon = p.authenticated ? '✓' : '✗';
32
-
33
- // Show verified status if we got one
34
- let verifiedStr = '';
35
- if (p.verified === true) {
36
- verifiedStr = ' ✅';
37
- } else if (p.verified === false) {
38
- verifiedStr = ' ⚠️';
39
- }
40
-
41
- // Show stale hint if >7 days since last seen
42
- let staleStr = '';
43
- if (p.stale && p.days_since_last_seen) {
44
- staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
45
- }
46
-
47
- // Show lock icon for read-only profiles
48
- const lockIcon = p.read_only ? ' 🔒' : '';
20
+ if (!profiles.length) return 'No profiles configured.\nRun: jsn auth login <instance> --browser\n';
21
+ const methods = { oauth: 'OAuth', basic: 'Basic', gck: 'Browser session' };
22
+ const states = { available: 'Available', missing: 'Missing', expired: 'Expired', refreshable: 'Refresh required', malformed: 'Invalid' };
23
+ const headings = ['PROFILE', 'AUTH', 'CREDENTIALS', 'CONNECTION', 'ACCESS', 'DELETES', 'COUNTS'];
24
+ if (details) headings.push('USER', 'INSTANCE');
25
+ const notes = [];
26
+ const rows = profiles.map((profile, index) => {
27
+ const diagnostics = profile.diagnostics || {};
28
+ const probe = diagnostics.probe || {};
29
+ const label = profile.name || `Profile ${index + 1}`;
30
+ const connection = profile.verified === true ? 'Verified'
31
+ : probe.code === 'account_mismatch' ? 'User mismatch'
32
+ : profile.verified === false ? 'Failed' : 'Not checked';
33
+ const row = [
34
+ `${label}${profile.active ? ' *' : ''}${profile.default ? ' (default)' : ''}`,
35
+ methods[diagnostics.auth_method] || 'Not configured',
36
+ states[diagnostics.state] || (profile.authenticated ? 'Available' : 'Missing'),
37
+ connection,
38
+ profile.read_only ? 'Read-only' : 'Read/write',
39
+ profile.skip_confirmations ? 'No prompt' : 'Confirm',
40
+ profile.include_counts === false ? 'Off' : 'On',
41
+ ];
42
+ if (details) {
43
+ const user = profile.username || profile.verified_as || 'Not recorded';
44
+ row.push(`${user}${profile.verified_as && profile.verified_as !== user ? ` (session: ${profile.verified_as})` : ''}`, profile.instance || 'Not configured');
45
+ }
46
+ if (profile.verified !== true && probe.message) notes.push(`${label}: ${probe.message}`);
47
+ if (profile.name && profile.verified !== true) {
48
+ const command = `jsn auth ${diagnostics.state === 'refreshable' ? 'refresh' : 'login'} --profile ${quoteShellArg(profile.name)}`;
49
+ const hint = ['missing', 'expired', 'refreshable', 'malformed'].includes(diagnostics.state)
50
+ || ['unauthorized', 'invalid_browser_session', 'refresh_failed', 'account_mismatch'].includes(probe.code)
51
+ ? command : probe.hint;
52
+ if (hint) notes.push(`${label}: ${hint}`);
53
+ }
54
+ return row;
55
+ });
56
+ const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map(row => row[column].length)));
57
+ const formatRow = row => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd();
58
+ const lines = [summary || `${profiles.length} profiles`, '', formatRow(headings), ...rows.map(formatRow)];
59
+ if (profiles.some(profile => profile.active)) lines.push('', '* selected profile');
60
+ if (profiles.some(profile => profile.diagnostics?.probe?.code === 'not_requested')) lines.push('', 'Connection not checked (--local).');
61
+ if (notes.length) lines.push('', ...notes);
62
+ if (!details) lines.push('', 'Show users and instance URLs: jsn auth status --details');
63
+ return lines.join('\n') + '\n';
64
+ }
49
65
 
50
- // Show confirmations-off badge for skip_confirmations profiles
51
- const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
66
+ export function profileSettingValues(argv = {}) {
67
+ if (argv['confirm-deletes'] !== undefined && argv['skip-confirmations'] !== undefined
68
+ && argv['confirm-deletes'] === argv['skip-confirmations']) {
69
+ throw errUsage('--confirm-deletes and --skip-confirmations request conflicting values.');
70
+ }
71
+ return {
72
+ ...argv,
73
+ 'skip-confirmations': argv['confirm-deletes'] !== undefined ? !argv['confirm-deletes'] : argv['skip-confirmations'],
74
+ };
75
+ }
52
76
 
53
- if (p.name) {
54
- out += `${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`;
55
- } else if (p.username) {
56
- out += `${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`;
57
- } else {
58
- out += `${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`;
59
- }
77
+ /** The same profile preferences are offered by setup and new-profile login. */
78
+ export async function configureProfileOptions(profile, argv = {}, { interactive = false, confirm } = {}) {
79
+ argv = profileSettingValues(argv);
80
+ if (interactive && !confirm) ({ confirm } = await import('../prompts.js'));
81
+ const settings = [
82
+ ['read_only', 'read-only', 'Read-only profile (blocks mutation commands)?', false],
83
+ ['skip_confirmations', 'skip-confirmations', 'Confirm before deleting records?', false],
84
+ ['include_counts', 'include-counts', 'Include result totals on list commands?', true],
85
+ ];
86
+ for (const [key, flag, message, fallback] of settings) {
87
+ if (argv[flag] !== undefined) profile[key] = argv[flag];
88
+ else if (interactive) {
89
+ const current = profile[key] ?? fallback;
90
+ const value = await confirm({ message, default: key === 'skip_confirmations' ? !current : current });
91
+ profile[key] = key === 'skip_confirmations' ? !value : value;
60
92
  }
61
93
  }
62
- return out;
94
+ return profile;
63
95
  }
64
96
 
65
97
  /**
@@ -88,8 +120,15 @@ export function resolveTargetProfile(argv = {}, cfg = {}, instanceURL = '') {
88
120
  if (argv.profile && cfg.profiles?.[argv.profile]) {
89
121
  return { name: argv.profile, profile: cfg.profiles[argv.profile] };
90
122
  }
91
- const entry = Object.entries(cfg.profiles || {})
92
- .find(([, profile]) => normalizeInstanceURL(profile.instance_url) === instanceURL);
123
+ if (argv.instance && cfg.profiles?.[argv.instance]) return { name: argv.instance, profile: cfg.profiles[argv.instance] };
124
+ const matches = Object.entries(cfg.profiles || {})
125
+ .filter(([, profile]) => normalizeInstanceURL(profile.instance_url) === instanceURL);
126
+ if (matches.length > 1) {
127
+ const active = cfg.activeProfile || cfg.defaultProfile;
128
+ if (!argv.instance && matches.some(([name]) => name === active)) return { name: active, profile: cfg.profiles[active] };
129
+ throw errUsage('Multiple profiles use this instance. Choose the user with --profile <name>.');
130
+ }
131
+ const entry = matches[0];
93
132
  return entry ? { name: entry[0], profile: entry[1] } : { name: null, profile: null };
94
133
  }
95
134
 
@@ -100,6 +139,7 @@ export function resolveTargetProfile(argv = {}, cfg = {}, instanceURL = '') {
100
139
  */
101
140
  export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
102
141
  if (argv.basic || argv.password) return true;
142
+ if (argv.oauth || argv.code || argv.printUrl || argv.browser || argv.gck || argv.headers) return false;
103
143
  if (targetProfile) return targetProfile.auth_method === 'basic';
104
144
  return Object.values(cfg.profiles || {}).some(profile =>
105
145
  profile.instance_url === instanceURL && profile.auth_method === 'basic'
@@ -107,7 +147,8 @@ export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '', target
107
147
  }
108
148
 
109
149
  export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetProfile = null) {
110
- if (argv.gck || argv.headers) return true;
150
+ if (argv.gck || argv.headers || argv.browser) return true;
151
+ if (argv.basic || argv.password || argv.oauth || argv.code || argv.printUrl) return false;
111
152
  if (targetProfile) return targetProfile.auth_method === 'gck';
112
153
  return Object.values(cfg.profiles || {}).some(profile =>
113
154
  profile.instance_url === instanceURL && profile.auth_method === 'gck'
@@ -116,7 +157,20 @@ export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '', targetPr
116
157
 
117
158
  export function validateLoginSelectors(argv = {}, targetProfile = null) {
118
159
  const basic = argv.basic === true || argv.password === true;
119
- const gck = argv.gck === true || argv.headers !== undefined;
160
+ const gck = argv.gck === true || argv.headers !== undefined || argv.browser === true;
161
+ const oauth = argv.oauth === true || argv.code !== undefined || argv.printUrl === true || argv.waitFile !== undefined;
162
+ if (oauth && (basic || gck)) throw errUsage('Choose only one authentication method: --oauth, --basic, or --browser/--gck.');
163
+ if (argv.waitFile && !argv.printUrl) throw errUsage('--wait-file requires --print-url.');
164
+ if (argv.code !== undefined && argv.printUrl) throw errUsage('--code cannot be combined with --print-url.');
165
+ if (argv.browserUrl !== undefined && !argv.browser) {
166
+ throw errUsage('--browser-url requires --browser.');
167
+ }
168
+ if (argv.browserName !== undefined && (!argv.browser || argv.browserUrl !== undefined)) {
169
+ throw errUsage('--browser-name requires --browser and cannot be combined with --browser-url.');
170
+ }
171
+ if (argv.browser && (argv.headers !== undefined || argv.code !== undefined || argv.printUrl || argv.waitFile)) {
172
+ throw errUsage('--browser cannot be combined with --headers, --code, --print-url, or --wait-file.');
173
+ }
120
174
  if (basic && gck) {
121
175
  throw errUsage('Basic and browser-session selectors are mutually exclusive; choose --basic/--password or --gck/--headers.');
122
176
  }
@@ -126,6 +180,19 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
126
180
  if (gck && targetProfile?.auth_method === 'basic') {
127
181
  throw errUsage('This profile is configured for basic authentication; use --basic/--password or change the profile method.');
128
182
  }
183
+ if (oauth && targetProfile?.auth_method && targetProfile.auth_method !== 'oauth') {
184
+ throw errUsage(`This profile is configured for ${targetProfile.auth_method} authentication. Use --name to create a separate OAuth profile.`);
185
+ }
186
+ }
187
+
188
+ export async function loginUsingBrowser(app, instance, argv = {}, username) {
189
+ if (argv.browserUrl) return app.auth.loginWithBrowser(instance, { browserUrl: argv.browserUrl }, username);
190
+ const { chooseBrowser } = await import('../browser-launch.js');
191
+ const browser = await chooseBrowser({ browserName: argv.browserName, interactive: canUsePicker(app, argv) });
192
+ return app.auth.loginWithBrowser(instance, {
193
+ launch: true, browser,
194
+ onProgress: message => process.stderr.write(`${message}\n`),
195
+ }, username);
129
196
  }
130
197
 
131
198
  /**
@@ -134,13 +201,19 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
134
201
  * none configured. A non-interactive caller (agent, pipe, --json/--get)
135
202
  * never opens a picker; it falls back to the single/default profile.
136
203
  */
137
- export async function pickProfile(app, message) {
138
- const { search } = await import('@inquirer/prompts');
204
+ export async function pickProfile(app, message, argv = {}) {
205
+ const { search } = await import('../prompts.js');
139
206
  const profileNames = Object.keys(app.config.profiles || {});
140
207
  if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
141
- if (profileNames.length === 1 || !canUsePicker(app, {})) return profileNames[0];
208
+ if (argv.name || argv.profile) {
209
+ const selected = argv.name || argv.profile;
210
+ if (!app.config.profiles[selected]) throw errUsage(`Profile not found: ${selected}`);
211
+ return selected;
212
+ }
213
+ if (profileNames.length === 1) return profileNames[0];
214
+ if (!canUsePicker(app, argv)) throw errUsageHint(`Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`, 'Pass --profile <name>, or jsn auth switch <name> to choose a default.');
142
215
  const choices = profileNames.map(name => ({
143
- name: `${name} — ${app.config.profiles[name].instance_url}`,
216
+ name,
144
217
  value: name,
145
218
  }));
146
219
  return search({
@@ -205,166 +278,48 @@ export async function readBrowserSessionInput() {
205
278
  });
206
279
  }
207
280
 
208
- /**
209
- * Interactive first-run wizard (formerly `jsn setup`).
210
- *
211
- * Walks: instance URL → profile name → auth method → optional OAuth login.
212
- * Creates and activates the profile, then returns the result so the caller
213
- * decides how to report it. Basic auth saves credentials directly (the OAuth
214
- * path has no interactive username/password).
215
- */
281
+ /** Collect human answers, then call the same login operation as auth login. */
216
282
  export async function loginWizard(app, argv = {}) {
217
- const readline = (await import('node:readline')).default;
218
- let rl = readline.createInterface({ input: process.stdin, output: process.stdout });
219
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
220
-
221
- console.log('Welcome to JSN - ServiceNow CLI');
222
- console.log();
223
-
224
- // Ask for the instance URL unless one was explicitly provided (env var,
225
- // --instance flag/override). NEVER pre-fill from the config's default
226
- // profile — this wizard adds an instance, so it must always ask.
227
- // (Regression: setup → Add skipped the URL ask when a default profile
228
- // existed, silently re-targeting the default instance.)
229
- let instance = resolveWizardInstance(app, argv);
230
- if (instance) {
231
- instance = normalizeInstanceURL(instance);
232
- } else {
233
- instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
234
- instance = normalizeInstanceURL(instance);
235
- }
236
- console.log(`Instance: ${instance}`);
237
-
238
- // Warn if another profile already uses this instance URL
239
- const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
240
- if (existing) {
241
- console.log(`Note: instance is also used by profile "${existing[0]}".`);
242
- }
243
-
244
- const profileName = (await ask('Profile name (default): ')) || 'default';
245
- const profile = { instance_url: instance };
246
- if (argv['read-only']) {
247
- profile.read_only = true;
248
- }
249
- if (argv['skip-confirmations']) {
250
- profile.skip_confirmations = true;
251
- }
252
-
253
- const authMethod = await ask('Authentication method (OAuth/Basic/Browser session) [OAuth]: ');
254
- const useGck = authMethod.toLowerCase().startsWith('g') || authMethod.toLowerCase().startsWith('browser');
255
- const useBasic = !useGck && authMethod.toLowerCase().startsWith('b');
256
-
257
- let loggedIn = false;
258
- if (useBasic) {
259
- const username = await ask('Username: ');
260
- rl.close(); // Close outer readline before askHidden creates its own
261
- const { askHidden } = await import('../auth.js');
262
- const existingCreds = app.auth.loadCredentials(instance) || app.auth.loadCredentials(instance, username);
263
- if (existingCreds && existingCreds.auth_method === 'basic') {
264
- console.log(`Using existing credentials for ${instance}`);
265
- } else {
266
- const password = await askHidden('Password: ');
267
- app.auth.saveCredentials(instance, { auth_method: 'basic', username, password }, username);
268
- console.log('Basic auth credentials saved');
269
- }
270
- profile.username = username;
271
- profile.auth_method = 'basic';
272
- } else if (useGck) {
273
- rl.close();
274
- const input = await readBrowserSessionInput();
275
- await app.auth.loginWithGck(instance, input);
276
- profile.auth_method = 'gck';
277
- loggedIn = true;
278
- } else {
279
- profile.auth_method = 'oauth';
280
- }
281
-
282
- await setProfile(app.config, profileName, profile);
283
- // Set as active profile so subsequent commands know which instance to use
284
- app.config.activeProfile = profileName;
285
- app.config.defaultProfile = profileName;
286
-
287
- // Present all connection-level options during setup. Gated on
288
- // interactivity so a piped/scripted call never blocks on prompts.
289
- if (canUsePicker(app, argv)) {
290
- // @inquirer/prompts owns stdin while these confirmations run. Close the
291
- // older readline interface before handing stdin to Inquirer. This matters
292
- // on Windows Git Bash: leaving both readers attached can make the later
293
- // OAuth question see EOF and let Node exit instead of waiting for input.
294
- rl.close();
295
- const { confirm } = await import('@inquirer/prompts');
296
- profile.read_only = await confirm({ message: 'Read-only profile (blocks mutation commands)?', default: profile.read_only || false });
297
- profile.skip_confirmations = await confirm({ message: 'Skip confirmations (deletes run without prompting)?', default: profile.skip_confirmations || false });
298
- profile.include_counts = await confirm({ message: 'Include result totals on list commands?', default: profile.include_counts !== false });
299
- console.log('');
300
- }
301
-
302
- await saveConfig(app.config);
303
-
304
- if (!useBasic && !useGck) {
305
- // Recreate readline after Inquirer has released stdin. Do not reuse the
306
- // interface that was open before the confirmation prompts.
307
- rl = readline.createInterface({ input: process.stdin, output: process.stdout });
308
- const loginNow = await ask('Login now? [Y/n]: ');
309
- rl.close();
310
- if (!loginNow || loginNow.toLowerCase() !== 'n') {
311
- await app.auth.login(instance);
312
- // Re-save credentials under <user>@<instance> after verifying username
313
- try {
314
- const sdk = app.getSDKForProfile
315
- ? app.getSDKForProfile(instance, { authMethod: 'oauth' })
316
- : app.sdk;
317
- const user = await sdk.getCurrentUser();
318
- if (user && user.user_name) {
319
- app.auth.migrateLegacyCredential(instance, user.user_name);
320
- profile.username = user.user_name;
321
- }
322
- } catch {
323
- // Non-fatal — token saved, username will be set on next auth login
324
- }
325
- console.log('Login successful!');
326
- loggedIn = true;
327
-
328
- // Probe domain separation capability once and stamp the profile, so
329
- // `jsn domains` (and its help entry) only appear on instances with it.
330
- try {
331
- const domainsMod = await import('./domains.js');
332
- const sdk = app.getSDKForProfile
333
- ? app.getSDKForProfile(instance, {
334
- profile,
335
- authMethod: profile.auth_method || 'oauth',
336
- username: profile.username,
337
- })
338
- : app.sdk;
339
- profile.domain_separation = await domainsMod.isDomainSeparationInstalled({ sdk });
340
- await setProfile(app.config, profileName, profile);
341
-
342
- // If domain separation is installed, offer to pick a default domain
343
- // for the profile — every request will be scoped to it via X-Now-Domain.
344
- if (profile.domain_separation) {
345
- const { confirm } = await import('@inquirer/prompts');
346
- const setDomainNow = await confirm({
347
- message: 'Domain separation detected. Configure a domain for this instance?',
348
- default: false,
349
- });
350
- if (setDomainNow) {
351
- const domainSysID = await domainsMod.pickDomain({ ...app, sdk });
352
- if (domainSysID) {
353
- profile.domain = domainSysID;
354
- await setProfile(app.config, profileName, profile);
355
- console.log('Domain configured.');
356
- }
357
- }
358
- }
359
- } catch {
360
- // Non-fatal — capability unknown; domains command falls back to probing
361
- }
362
- }
363
- } else {
364
- rl.close();
283
+ const { input, select, confirm, password } = await import('../prompts.js');
284
+ const explicitInstance = resolveWizardInstance(app, argv);
285
+ const instance = resolveInstanceArg(explicitInstance || await input({
286
+ message: 'ServiceNow instance name or URL:',
287
+ validate: value => value.trim() ? true : 'An instance is required',
288
+ }), app.config);
289
+ const name = argv.name || await input({
290
+ message: 'Profile name:', default: suggestedProfileName(instance),
291
+ validate: value => !value.trim() ? 'A profile name is required'
292
+ : app.config.profiles?.[value.trim()] ? 'That profile already exists; choose another name or sign in again.' : true,
293
+ });
294
+ const method = argv.browser ? 'browser' : argv.basic || argv.password ? 'basic' : await select({
295
+ message: 'Authentication method:',
296
+ choices: [
297
+ { name: 'Browser — sign in on this computer', value: 'browser' },
298
+ { name: 'OAuth — authorization code', value: 'oauth' },
299
+ { name: 'Basic — username and password', value: 'basic' },
300
+ ],
301
+ });
302
+ const request = { ...argv, profile: undefined, instance, name: name.trim() };
303
+ if (method === 'browser') request.browser = true;
304
+ if (method === 'oauth') request.oauth = true;
305
+ if (method === 'basic') {
306
+ request.basic = true;
307
+ const username = await input({ message: 'Username:', validate: value => value.trim() ? true : 'A username is required' });
308
+ request._basicCredentials = { username: username.trim(), password: await password({ message: 'Password:' }) };
365
309
  }
310
+ const settings = await configureProfileOptions({}, argv, { interactive: true });
311
+ request['read-only'] = settings.read_only;
312
+ request['skip-confirmations'] = settings.skip_confirmations;
313
+ request['include-counts'] = settings.include_counts;
314
+ request.makeDefault = argv.makeDefault ?? await confirm({
315
+ message: 'Use this profile by default?', default: !app.config.defaultProfile,
316
+ });
317
+ const result = await loginProfile(request, app);
318
+ return { instanceURL: instance, profileName: result.profile, loggedIn: result.authenticated };
319
+ }
366
320
 
367
- return { instanceURL: instance, profileName, loggedIn };
321
+ function suggestedProfileName(instance) {
322
+ return instance.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
368
323
  }
369
324
 
370
325
  /**
@@ -375,7 +330,7 @@ export async function loginWizard(app, argv = {}) {
375
330
  * Returns { addNew: true, instanceName } or { addNew: false, name }.
376
331
  */
377
332
  async function pickLoginTarget(app) {
378
- const { search, input } = await import('@inquirer/prompts');
333
+ const { search, input } = await import('../prompts.js');
379
334
  const ADD_NEW = '__add_new__';
380
335
  const profileNames = Object.keys(app.config.profiles || {});
381
336
  let lastInput = '';
@@ -386,11 +341,11 @@ async function pickLoginTarget(app) {
386
341
  lastInput = term || '';
387
342
  const profiles = profileNames
388
343
  .filter((name) => {
389
- const label = `${name} — ${app.config.profiles[name].instance_url}`.toLowerCase();
344
+ const label = `${name} — ${app.config.profiles[name].instance_url} ${app.config.profiles[name].username || ''}`.toLowerCase();
390
345
  return !filter || label.includes(filter);
391
346
  })
392
347
  .map((name) => ({
393
- name: `${name} — ${app.config.profiles[name].instance_url}`,
348
+ name: `${name} — ${app.config.profiles[name].instance_url} (${app.config.profiles[name].username || 'account not recorded'})`,
394
349
  value: name,
395
350
  }));
396
351
  const addLabel = filter
@@ -414,7 +369,7 @@ async function pickLoginTarget(app) {
414
369
  /** Per-profile configurable flags (extensible — any boolean on the profile). */
415
370
  const PROFILE_FLAGS = [
416
371
  { name: 'Read-only (blocks mutation commands)', value: 'read_only', icon: '🔒' },
417
- { name: 'Skip confirmations (deletes run without prompting)', value: 'skip_confirmations', icon: '⚡' },
372
+ { name: 'Confirm before deleting records', value: 'skip_confirmations', icon: '⚡' },
418
373
  { name: 'Include result totals on list commands', value: 'include_counts', icon: '🔢' },
419
374
  ];
420
375
 
@@ -423,9 +378,20 @@ const PROFILE_FLAGS = [
423
378
  * Interactive picker when no flag passed; testable via argv.flag/argv.domain.
424
379
  */
425
380
  export async function modifyProfile(app, argv = {}) {
426
- const name = argv.name || await pickProfile(app, 'Modify which instance?');
381
+ argv = profileSettingValues(argv);
382
+ const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
427
383
  const profile = app.config.profiles?.[name];
428
384
  if (!profile) throw new Error(`Profile not found: ${name}`);
385
+ const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts'].some(flag => argv[flag] !== undefined);
386
+ if (explicitSettings) {
387
+ if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
388
+ const updated = await configureProfileOptions({ ...profile }, argv);
389
+ app.config.profiles[name] = updated;
390
+ await saveConfig(app.config);
391
+ app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false },
392
+ { summary: `Settings saved for ${name}` });
393
+ return;
394
+ }
429
395
 
430
396
  // Domain setting path: --domain <name|sys_id|clear>
431
397
  if (argv.domain !== undefined) {
@@ -454,10 +420,12 @@ export async function modifyProfile(app, argv = {}) {
454
420
  }
455
421
 
456
422
  let flag = argv.flag;
423
+ let picked = false;
457
424
  if (!flag) {
458
- const { select } = await import('@inquirer/prompts');
425
+ if (!canUsePicker(app, argv)) throw errUsageHint('Specify the settings to change when noninteractive.', 'Use --read-only, --no-include-counts, or --confirm-deletes.');
426
+ const { select } = await import('../prompts.js');
459
427
  const choices = PROFILE_FLAGS.map(f => ({
460
- name: `${f.icon} ${f.name}: ${profile[f.value] ? 'on' : 'off'}`,
428
+ name: `${f.icon} ${f.name}: ${(f.value === 'include_counts' ? profile[f.value] !== false : f.value === 'skip_confirmations' ? !profile[f.value] : profile[f.value]) ? 'on' : 'off'}`,
461
429
  value: f.value,
462
430
  }));
463
431
  if (profile.domain_separation) {
@@ -473,13 +441,14 @@ export async function modifyProfile(app, argv = {}) {
473
441
  message: `Modify ${name} (${profile.instance_url})`,
474
442
  choices,
475
443
  });
444
+ picked = true;
476
445
  if (flag === 'domain') {
477
446
  const domainsMod = await import('./domains.js');
478
447
  const installed = await domainsMod.isDomainSeparationInstalled({ sdk });
479
448
  if (!installed) {
480
449
  throw new Error(`Domain separation is not installed on ${profile.instance_url}`);
481
450
  }
482
- const { confirm } = await import('@inquirer/prompts');
451
+ const { confirm } = await import('../prompts.js');
483
452
  const clear = profile.domain
484
453
  ? await confirm({ message: `Domain is set. Clear it?`, default: false })
485
454
  : false;
@@ -499,7 +468,17 @@ export async function modifyProfile(app, argv = {}) {
499
468
  }
500
469
  }
501
470
 
502
- profile[flag] = !profile[flag];
471
+ if (picked) {
472
+ const setting = { read_only: 'read-only', skip_confirmations: 'confirm-deletes', include_counts: 'include-counts' }[flag];
473
+ const { confirm } = await import('../prompts.js');
474
+ const value = await confirm({
475
+ message: { read_only: 'Read-only profile (blocks mutation commands)?', skip_confirmations: 'Confirm before deleting records?', include_counts: 'Include result totals on list commands?' }[flag],
476
+ default: flag === 'skip_confirmations' ? !profile[flag] : flag === 'include_counts' ? profile[flag] !== false : !!profile[flag],
477
+ });
478
+ return modifyProfile(app, { name, [setting]: value });
479
+ }
480
+
481
+ profile[flag] = flag === 'include_counts' ? profile[flag] === false : !profile[flag];
503
482
  await saveConfig(app.config);
504
483
  app.ok({ profile: name, [flag]: profile[flag] },
505
484
  { summary: `${flag} is now ${profile[flag] ? 'ON' : 'off'} for ${name}` });
@@ -524,6 +503,7 @@ export async function removeProfile(app, name) {
524
503
  if (app.config.defaultProfile === name) app.config.defaultProfile = '';
525
504
  if (app.config.activeProfile === name) app.config.activeProfile = '';
526
505
  saveConfig(app.config);
506
+ syncSession(app);
527
507
 
528
508
  // Delete only this profile's credential identity. Other profiles may share
529
509
  // the instance URL but must retain their separate credentials.
@@ -545,16 +525,522 @@ function canLogoutBareProfile(app, instance, target, explicitlySelected) {
545
525
  return sameInstance.length <= 1;
546
526
  }
547
527
 
528
+ export async function loginProfile(argv, app) {
529
+ argv = profileSettingValues(argv);
530
+ validateLoginSelectors(argv);
531
+ if (argv.name !== undefined) {
532
+ if (!argv.name.trim()) throw errUsage('--name requires a nonempty profile name.');
533
+ if (argv.profile) throw errUsage('Use --profile to reconnect or --name to create a profile, not both.');
534
+ if (app.config.profiles?.[argv.name]) throw errUsage(`Profile "${argv.name}" already exists. Reconnect with --profile or choose another --name.`);
535
+ }
536
+ let instanceURL;
537
+ let selectedProfileName = argv.profile || (argv.instance && app.config.profiles?.[argv.instance] ? argv.instance : null);
538
+ if (argv.profile) {
539
+ const namedProfile = app.config.profiles?.[argv.profile];
540
+ if (!namedProfile?.instance_url) {
541
+ throw errUsage(`Profile "${argv.profile}" is not configured.`);
542
+ }
543
+ // An explicit profile is authoritative, including in an
544
+ // interactive terminal. Never let the picker retarget it.
545
+ instanceURL = normalizeInstanceURL(namedProfile.instance_url);
546
+ } else if (argv.instance) {
547
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
548
+ } else if (canUsePicker(app, argv)) {
549
+ // gh-faithful: interactive terminals always get a picker —
550
+ // existing profiles plus "+ Add a new instance". Zero profiles
551
+ // goes straight to the setup wizard.
552
+ const profileNames = Object.keys(app.config.profiles || {});
553
+ if (profileNames.length === 0) {
554
+ return loginWizard(app, argv);
555
+ }
556
+ const target = await pickLoginTarget(app);
557
+ if (target.addNew) {
558
+ return loginWizard(app, { ...argv, instance: resolveInstanceArg(target.instanceName, app.config) });
559
+ } else {
560
+ instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
561
+ selectedProfileName = target.name;
562
+ }
563
+ } else {
564
+ instanceURL = getEffectiveInstance(app.config);
565
+ if (!instanceURL) {
566
+ throw errUsage(`Instance URL required.
567
+
568
+ Examples:
569
+ jsn auth login https://dev12345.service-now.com
570
+ jsn auth login dev373698
571
+ jsn auth login --basic https://dev328604.service-now.com
572
+
573
+ Find your instance URL in your browser's address bar when logged into ServiceNow.`);
574
+ }
575
+ }
576
+
577
+ const matchingProfiles = Object.entries(app.config.profiles || {})
578
+ .filter(([, profile]) => normalizeInstanceURL(profile.instance_url) === instanceURL);
579
+ if (!selectedProfileName && !argv.name && matchingProfiles.length > 1) {
580
+ throw errUsage('Multiple profiles use this instance. Choose the account with: jsn auth login --profile <name>');
581
+ }
582
+ const target = argv.name ? { name: argv.name, profile: null }
583
+ : resolveTargetProfile({ ...argv, profile: selectedProfileName }, app.config, instanceURL);
584
+ const targetProfile = target.profile;
585
+ validateLoginSelectors(argv, targetProfile);
586
+ let targetUsername = targetProfile?.username || '';
587
+ const methodConfig = argv.name ? { profiles: {} } : app.config;
588
+ const useBasic = shouldUseBasicAuth(argv, methodConfig, instanceURL, targetProfile);
589
+ const useGck = shouldUseGckAuth(argv, methodConfig, instanceURL, targetProfile);
590
+ const interactive = canUsePicker(app, argv);
591
+ if (useGck && !argv.browser && !argv.headers && !interactive) {
592
+ throw errUsage('Browser sign-in needs a human. Pass --browser to explicitly open a login window, or supply a captured session.');
593
+ }
594
+ if (!useGck && !useBasic && !argv.printUrl && !argv.code && !interactive) {
595
+ throw errUsage('OAuth login requires --print-url followed by --code <code> when noninteractive.');
596
+ }
597
+ const preliminary = argv.printUrl && !argv.waitFile;
598
+ const preferences = await configureProfileOptions({ ...targetProfile }, argv, {
599
+ interactive: !targetProfile && interactive && !preliminary,
600
+ });
601
+ let setDefault = argv.makeDefault === true;
602
+ if (!targetProfile && argv.makeDefault === undefined && interactive && !preliminary) {
603
+ const { confirm } = await import('../prompts.js');
604
+ setDefault = await confirm({ message: 'Use this profile by default?', default: !app.config.defaultProfile });
605
+ }
606
+ // --basic (with --password as a compatibility alias): authenticate
607
+ // with basic auth from env vars and persist it for this profile.
608
+ if (useGck) {
609
+ const credentials = (argv.browser || (!argv.gck && !argv.headers))
610
+ ? await loginUsingBrowser(app, instanceURL, argv, targetUsername || null)
611
+ : await app.auth.loginWithGck(instanceURL, argv.headers || await readBrowserSessionInput(), targetUsername || null);
612
+ targetUsername = credentials?.username || targetUsername;
613
+ } else if (useBasic) {
614
+ let credentials;
615
+ try {
616
+ credentials = argv._basicCredentials
617
+ ? await app.auth.loginWithPassword(instanceURL, targetUsername || null, argv._basicCredentials)
618
+ : await app.auth.loginWithPassword(instanceURL, targetUsername || null);
619
+ } catch (error) {
620
+ if (!interactive || argv._basicCredentials || error.code !== 'auth_error') throw error;
621
+ const { input, password } = await import('../prompts.js');
622
+ const username = targetUsername || await input({ message: 'Username:', validate: value => value.trim() ? true : 'A username is required' });
623
+ credentials = await app.auth.loginWithPassword(instanceURL, targetUsername || null, {
624
+ username, password: await password({ message: 'Password:' }),
625
+ });
626
+ }
627
+ // A new profile has no configured username yet. The auth
628
+ // manager saves env/stored credentials under creds.username;
629
+ // carry that identity into verification and profile creation.
630
+ targetUsername = credentials?.username || targetUsername;
631
+ }
632
+ // --print-url with --wait-file: print URL and wait for code file
633
+ else if (argv.printUrl && argv.waitFile) {
634
+ await app.auth.buildAuthURL(instanceURL, argv.waitFile, targetUsername || null);
635
+ }
636
+ // --print-url: just print the URL and exit
637
+ else if (argv.printUrl) {
638
+ const authURL = app.auth.buildAuthURL(instanceURL, undefined, targetUsername || null);
639
+ const result = { authorization_url: authURL, instance: instanceURL, profile: target.name || undefined };
640
+ const targetFlag = argv.name ? `--name ${quoteShellArg(argv.name)}` : target.name ? `--profile ${quoteShellArg(target.name)}` : '';
641
+ const settingsFlags = ['read-only', 'skip-confirmations', 'include-counts']
642
+ .filter(flag => argv[flag] !== undefined).map(flag => `--${argv[flag] ? '' : 'no-'}${flag}`);
643
+ if (argv.makeDefault !== undefined) settingsFlags.push(`--${argv.makeDefault ? '' : 'no-'}make-default`);
644
+ app.ok(result, {
645
+ summary: 'Open the authorization URL, then complete login with the returned code.',
646
+ breadcrumbs: [{ action: 'Complete login', cmd: ['jsn auth login', quoteShellArg(instanceURL), targetFlag, '--code <code>', ...settingsFlags].filter(Boolean).join(' '), description: 'Keep the same profile name and settings when exchanging the code' }],
647
+ });
648
+ return result;
649
+ }
650
+ // --code: exchange the provided authorization code directly
651
+ else if (argv.code) {
652
+ await app.auth.loginWithCode(instanceURL, argv.code, targetUsername || null);
653
+ } else {
654
+ // Interactive: full OAuth flow with browser + prompt
655
+ await app.auth.login(instanceURL, targetUsername || null);
656
+ }
657
+
658
+ // Verify auth by fetching current user
659
+ let username = '';
660
+ try {
661
+ const activeName = app.config.activeProfile || app.config.defaultProfile;
662
+ const activeProfile = app.config.profiles?.[activeName];
663
+ const activeInstance = app.getEffectiveInstance?.() || activeProfile?.instance_url || '';
664
+ const activeUsername = activeProfile?.username || '';
665
+ const activeMethod = activeProfile?.auth_method || '';
666
+ const targetMethod = useGck ? 'gck' : useBasic ? 'basic' : 'oauth';
667
+ const targetDiffers = instanceURL !== activeInstance
668
+ || targetUsername !== activeUsername
669
+ || targetMethod !== activeMethod;
670
+ if (!app.sdk || targetDiffers) {
671
+ app.sdk = app.getSDKForProfile
672
+ ? app.getSDKForProfile(instanceURL, {
673
+ authMethod: targetMethod,
674
+ username: targetUsername,
675
+ profile: targetProfile,
676
+ })
677
+ : app.sdk;
678
+ }
679
+ const user = await app.sdk.getCurrentUser();
680
+ username = user?.user_name || user?.name || '';
681
+ } catch {
682
+ // Non-fatal — token is saved, just couldn't verify
683
+ }
684
+
685
+ // Basic/GCK auth establishes the target identity before the
686
+ // profile exists; preserve it when verification is unavailable.
687
+ username = username || targetUsername;
688
+
689
+ // Save to profiles — deduplicate by instance URL
690
+ if (!app.config.profiles) {
691
+ app.config.profiles = {};
692
+ }
693
+
694
+ // Preserve an explicitly selected profile even when another profile
695
+ // shares the same instance URL.
696
+ let profileName = target.name;
697
+ if (!profileName) {
698
+ for (const [existingName, existingProfile] of Object.entries(app.config.profiles)) {
699
+ if (existingProfile.instance_url === instanceURL) {
700
+ profileName = existingName;
701
+ break;
702
+ }
703
+ }
704
+ }
705
+
706
+ // If no existing profile found, generate a name from the URL
707
+ if (!profileName) {
708
+ profileName = suggestedProfileName(instanceURL);
709
+ }
710
+
711
+ app.config.profiles[profileName] = {
712
+ ...(app.config.profiles[profileName] || {}),
713
+ instance_url: instanceURL,
714
+ auth_method: useGck ? 'gck' : useBasic ? 'basic' : 'oauth',
715
+ username: username || undefined,
716
+ read_only: preferences.read_only,
717
+ skip_confirmations: preferences.skip_confirmations,
718
+ include_counts: preferences.include_counts,
719
+ };
720
+ if (!targetProfile && interactive) {
721
+ try {
722
+ const domains = await import('./domains.js');
723
+ const profile = app.config.profiles[profileName];
724
+ const sdk = app.getSDKForProfile
725
+ ? app.getSDKForProfile(instanceURL, { profile, authMethod: profile.auth_method, username: profile.username }) : app.sdk;
726
+ profile.domain_separation = await domains.isDomainSeparationInstalled({ sdk });
727
+ if (profile.domain_separation) {
728
+ const { confirm } = await import('../prompts.js');
729
+ if (await confirm({ message: 'Configure a default domain?', default: false })) profile.domain = await domains.pickDomain({ ...app, sdk });
730
+ }
731
+ } catch (error) {
732
+ if (error.name === 'PromptCancelled') throw error;
733
+ // Capability discovery remains best-effort, as in setup.
734
+ }
735
+ }
736
+
737
+ // Re-save OAuth credentials with username now that we have it,
738
+ // so they're keyed by <user>@<instance> for per-user isolation.
739
+ // The legacy bare-instance key is cleaned up behind the
740
+ // AuthManager seam (credential-store internals don't leak here).
741
+ if (!useBasic && username) {
742
+ app.auth.migrateLegacyCredential?.(instanceURL, username);
743
+ }
744
+
745
+ if (setDefault) {
746
+ app.config.instanceURL = instanceURL;
747
+ app.config.defaultProfile = profileName;
748
+ app.config.activeProfile = profileName;
749
+ }
750
+
751
+ const { saveConfig } = await import('../config.js');
752
+ saveConfig(app.config);
753
+ syncSession(app);
754
+
755
+ const result = {
756
+ authenticated: true,
757
+ instance: instanceURL,
758
+ username: username || undefined,
759
+ profile: profileName,
760
+ default: setDefault || undefined,
761
+ };
762
+ const summary = username
763
+ ? `✓ Authenticated to ${instanceURL} as ${username} (profile: ${profileName})`
764
+ : `✓ Authenticated to ${instanceURL} (profile: ${profileName})`;
765
+ const settings = app.config.profiles[profileName];
766
+ const breadcrumbs = !targetProfile ? [
767
+ {
768
+ action: 'Verify this profile', cmd: `jsn --profile ${quoteShellArg(profileName)} auth status`,
769
+ description: 'Check the account and connection for this profile',
770
+ },
771
+ {
772
+ action: 'Profile settings', cmd: `jsn auth modify ${quoteShellArg(profileName)}`,
773
+ description: `${settings.read_only ? 'read-only' : 'writes allowed'}; ${settings.skip_confirmations ? 'delete confirmations off' : 'confirm deletes'}; counts ${settings.include_counts === false ? 'off' : 'on'}`,
774
+ },
775
+ {
776
+ action: 'New profile flags', cmd: 'jsn auth login <instance> --read-only --no-include-counts',
777
+ description: 'Set these at login; --skip-confirmations disables delete prompts',
778
+ },
779
+ ] : [];
780
+ app.ok(result, { summary, breadcrumbs });
781
+ return result;
782
+ }
783
+
784
+ export async function logoutProfile(argv, app) {
785
+ if (!argv.instance && !argv.profile && canUsePicker(app, argv)) {
786
+ argv = { ...argv, profile: await pickProfile(app, 'Sign out of which profile?', argv) };
787
+ }
788
+ let instanceURL;
789
+ if (argv.profile) {
790
+ instanceURL = app.config.profiles?.[argv.profile]?.instance_url;
791
+ if (!instanceURL) throw errUsage(`Profile not found: ${argv.profile}`);
792
+ } else if (argv.instance) {
793
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
794
+ } else {
795
+ instanceURL = getEffectiveInstance(app.config);
796
+ if (!instanceURL) {
797
+ throw errUsage(`No instance specified.
798
+
799
+ Examples:
800
+ jsn auth logout
801
+ jsn auth logout https://dev12345.service-now.com`);
802
+ }
803
+ }
804
+ const target = resolveTargetProfile(argv, app.config, instanceURL);
805
+ const username = target.profile?.username ?? null;
806
+ if (username || canLogoutBareProfile(app, instanceURL, target.profile, !!argv.profile)) {
807
+ app.auth.logout(instanceURL, username);
808
+ }
809
+ app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
810
+ }
811
+
812
+ export async function showAuthStatus(argv, app) {
813
+ const configuredDefault = app.config.profiles?.[app.config.defaultProfile];
814
+ const defaultInstance = configuredDefault?.instance_url || app.config.instanceURL || '';
815
+
816
+ // Check environment auth
817
+ const envToken = process.env.SERVICENOW_OAUTH_TOKEN || '';
818
+
819
+ const profiles = [];
820
+ const configuredProfiles = Object.entries(app.config.profiles || {});
821
+ const selectedProfiles = argv.profile
822
+ ? configuredProfiles.filter(([name]) => name === argv.profile)
823
+ : configuredProfiles;
824
+ for (const [name, profile] of selectedProfiles) {
825
+ const instance = profile.instance_url;
826
+ const authOptions = { authMethod: profile.auth_method, username: profile.username };
827
+ const identityOptions = { username: profile.username, authMethod: profile.auth_method };
828
+ const isAuth = app.auth.isAuthenticatedFor(instance, identityOptions);
829
+ const lastSeen = app.auth.getLastSeen(instance, identityOptions);
830
+ const authState = app.auth.getAuthState(instance, authOptions);
831
+ const legacy = !isAuth && app.auth.hasLegacyCredentials(instance, identityOptions) ? true : undefined;
832
+ // Keep the established status field contract: unauthenticated
833
+ // profiles omit auth_source, except legacy credentials retain
834
+ // their historical `legacy` marker. getAuthState remains the
835
+ // safe metadata seam for authenticated profiles.
836
+ const authSource = isAuth ? authState.auth_source : (legacy ? 'legacy' : undefined);
837
+
838
+ // Try live verification through AuthManager's read-only probe seam.
839
+ let verified = null;
840
+ let verifiedAt = null;
841
+ let probe;
842
+ if (argv.local) {
843
+ probe = { status: 'not_attempted', code: 'not_requested', classification: 'not_checked' };
844
+ } else if (!instance) {
845
+ probe = app.auth.probeUnavailable?.('missing_instance') || { status: 'not_attempted', code: 'missing_instance', classification: 'unavailable' };
846
+ } else {
847
+ try {
848
+ const sdk = app.getSDKForProfile
849
+ ? app.getSDKForProfile(instance, { ...authOptions, profile })
850
+ : app.sdk || { getCurrentUser: async () => null };
851
+ let user;
852
+ probe = await app.auth.probeCurrentUser(instance, {
853
+ getCurrentUser: async (options) => {
854
+ user = await sdk.getCurrentUser(options);
855
+ return user;
856
+ },
857
+ }, authOptions);
858
+ if (probe.status === 'succeeded' && user?.user_name) {
859
+ verified = true;
860
+ verifiedAt = user.user_name;
861
+ if (profile.username && profile.username !== user.user_name) {
862
+ verified = false;
863
+ probe = {
864
+ status: 'failed', code: 'account_mismatch', classification: 'account_mismatch',
865
+ message: 'The session account does not match the configured profile account.',
866
+ };
867
+ }
868
+ } else if (probe.status !== 'not_attempted') {
869
+ verified = false;
870
+ }
871
+ } catch {
872
+ probe = app.auth.probeUnavailable?.('sdk_construction_failed') || { status: 'not_attempted', code: 'sdk_construction_failed', classification: 'configuration_error' };
873
+ verified = false;
874
+ }
875
+ }
876
+
877
+ // Calculate days since last seen
878
+ let daysSinceLastSeen = null;
879
+ if (lastSeen) {
880
+ daysSinceLastSeen = Math.floor((Date.now() / 1000 - lastSeen) / 86400);
881
+ }
882
+
883
+ profiles.push({
884
+ name,
885
+ instance,
886
+ username: profile.username || undefined,
887
+ active: name === (app.session?.profileName || app.config.activeProfile || app.config.defaultProfile),
888
+ authenticated: isAuth,
889
+ legacy,
890
+ auth_source: authSource,
891
+ verified,
892
+ verified_as: verifiedAt || undefined,
893
+ last_seen: lastSeen || undefined,
894
+ days_since_last_seen: daysSinceLastSeen,
895
+ stale: daysSinceLastSeen > 7,
896
+ default: name === app.config.defaultProfile,
897
+ read_only: profile.read_only || false,
898
+ skip_confirmations: profile.skip_confirmations || false,
899
+ include_counts: profile.include_counts !== false,
900
+ diagnostics: {
901
+ auth_method: authState.auth_method,
902
+ auth_source: authState.auth_source,
903
+ state: authState.state,
904
+ probe,
905
+ },
906
+ });
907
+ }
908
+
909
+ const result = {
910
+ default_instance: defaultInstance,
911
+ authenticated: app.auth.isAuthenticated(),
912
+ environment_auth: envToken ? true : undefined,
913
+ profiles,
914
+ };
915
+
916
+ const summary = `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}`;
917
+ // Ship the styled view as _formatted (see renderAuthStatus); the
918
+ // OutputWriter prints it verbatim and suppresses the summary.
919
+ result._formatted = renderAuthStatus(result, summary, { details: argv.details === true });
920
+ if (!argv.details) {
921
+ delete result.default_instance;
922
+ result.profiles = profiles.map(({ instance: _instance, username: _username, verified_as: _verifiedAs, ...visible }) => visible);
923
+ }
924
+ app.ok(result, {
925
+ summary,
926
+ breadcrumbs: [
927
+ { action: 'Switch profile', cmd: 'jsn auth switch <name>', description: 'Choose the default profile' },
928
+ { action: 'Renew session', cmd: 'jsn auth refresh --profile <name>', description: 'Refresh OAuth or sign in again in a browser' },
929
+ { action: 'Change settings', cmd: 'jsn auth modify --profile <name>', description: 'Read-only access, delete confirmations, and counts' },
930
+ ],
931
+ });
932
+
933
+ // Warn about legacy credentials that need re-authentication
934
+ const legacyProfiles = profiles.filter(p => p.legacy);
935
+ if (legacyProfiles.length > 0) {
936
+ process.stderr.write(
937
+ '\n⚠️ This release changed how credentials are stored.\n' +
938
+ ' Some profiles still use the old format.\n' +
939
+ ' Run the following to re-authenticate:\n'
940
+ );
941
+ for (const p of legacyProfiles) {
942
+ process.stderr.write(` jsn auth login ${p.name}\n`);
943
+ }
944
+ process.stderr.write('\n');
945
+ }
946
+ }
947
+
948
+ export async function refreshProfile(argv, app) {
949
+ let instanceURL;
950
+ if (argv.profile) {
951
+ instanceURL = app.config.profiles?.[argv.profile]?.instance_url;
952
+ if (!instanceURL) throw errUsage(`Profile not found: ${argv.profile}`);
953
+ } else if (argv.instance) {
954
+ instanceURL = resolveInstanceArg(argv.instance, app.config);
955
+ } else {
956
+ instanceURL = getEffectiveInstance(app.config);
957
+ if (!instanceURL) {
958
+ throw errUsage(`No instance specified and no default configured.
959
+
960
+ Examples:
961
+ jsn auth refresh
962
+ jsn auth refresh https://dev12345.service-now.com
963
+ jsn auth refresh dev12345`);
964
+ }
965
+ }
966
+
967
+ const target = resolveTargetProfile(argv, app.config, instanceURL);
968
+ const targetProfile = target.profile;
969
+ if (targetProfile?.auth_method === 'gck') {
970
+ return loginProfile({
971
+ ...argv, instance: instanceURL, profile: target.name,
972
+ browser: argv.browser || (!argv.headers && canUsePicker(app, argv)),
973
+ }, app);
974
+ }
975
+ const targetAuthOptions = {
976
+ authMethod: targetProfile ? targetProfile.auth_method : 'oauth',
977
+ username: targetProfile?.username,
978
+ };
979
+ const creds = await app.auth.getCredentialsFor(
980
+ instanceURL,
981
+ targetProfile?.username,
982
+ { ...targetAuthOptions, refresh: false }
983
+ );
984
+ const refreshed = await app.auth.refreshToken(instanceURL, creds, targetProfile?.username ?? null);
985
+
986
+ // Re-probe domain separation on refresh so the capability flag
987
+ // stays in sync if the plugin was installed/removed since setup.
988
+ try {
989
+ const { isDomainSeparationInstalled } = await import('./domains.js');
990
+ const sdk = app.getSDKForProfile
991
+ ? app.getSDKForProfile(instanceURL, {
992
+ ...targetAuthOptions,
993
+ profile: targetProfile,
994
+ })
995
+ : app.sdk;
996
+ const hasDS = await isDomainSeparationInstalled({ sdk });
997
+ const name = target.name || app.config.activeProfile || app.config.defaultProfile;
998
+ if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
999
+ app.config.profiles[name].domain_separation = hasDS;
1000
+ await saveConfig(app.config);
1001
+ }
1002
+ } catch {
1003
+ // Non-fatal — capability probe is best-effort
1004
+ }
1005
+
1006
+ app.ok({
1007
+ refreshed: true,
1008
+ instance: instanceURL,
1009
+ expires_at: refreshed.expires_at,
1010
+ }, { summary: `✓ Token refreshed for ${instanceURL}` });
1011
+ }
1012
+
1013
+ export async function switchProfile(argv, app) {
1014
+ const name = argv.name || argv.profile || await pickProfile(app, 'Use which profile by default?', argv);
1015
+ if (!app.config.profiles?.[name]) throw errUsage(`Profile not found: ${name}`);
1016
+ app.config.instanceURL = app.config.profiles[name].instance_url;
1017
+ await setActiveProfile(app.config, name);
1018
+ syncSession(app);
1019
+ app.ok({ active_profile: name }, { summary: `Default profile: ${name}` });
1020
+ }
1021
+
1022
+ export async function removeConnection(argv, app) {
1023
+ let name = argv.name;
1024
+ if (!name) {
1025
+ name = await pickProfile(app, 'Remove which profile?', argv);
1026
+ }
1027
+ await removeProfile(app, name);
1028
+ }
1029
+
548
1030
  export function authCmd(wrap) {
549
1031
  return {
550
1032
  command: 'auth [subcommand]',
551
- describe: 'Manage authentication (OAuth or Basic Auth)',
1033
+ describe: 'Manage authentication (OAuth, Basic, or browser session)',
552
1034
  builder: (yargs) => {
553
1035
  return yargs
554
1036
  .command({
555
1037
  command: 'login [instance]',
556
1038
  describe: 'Authenticate with a ServiceNow instance (interactive picker when run bare)',
557
1039
  builder: (sub) => sub
1040
+ .option('name', { type: 'string', describe: 'Create a new named profile, including another user on the same instance' })
1041
+ .option('make-default', { type: 'boolean', describe: 'Explicitly use this profile by default' })
1042
+ .option('oauth', { type: 'boolean', describe: 'Authenticate using OAuth authorization code with PKCE' })
1043
+ .option('confirm-deletes', { type: 'boolean', describe: 'Require confirmation before deleting records' })
558
1044
  .option('code', {
559
1045
  describe: 'Authorization code from browser (bypasses interactive prompt)',
560
1046
  type: 'string',
@@ -572,6 +1058,18 @@ export function authCmd(wrap) {
572
1058
  describe: 'Browser request headers or cURL command for browser-session auth',
573
1059
  type: 'string',
574
1060
  })
1061
+ .option('browser', {
1062
+ describe: 'Open a browser and wait for you to log in to ServiceNow',
1063
+ type: 'boolean',
1064
+ })
1065
+ .option('browser-name', {
1066
+ describe: 'Browser to launch: chromium, chrome, or edge (otherwise choose an installed browser)',
1067
+ type: 'string',
1068
+ })
1069
+ .option('browser-url', {
1070
+ describe: 'Advanced: connect to an existing local debugging URL instead of launching (requires --browser)',
1071
+ type: 'string',
1072
+ })
575
1073
  .option('print-url', {
576
1074
  describe: 'Print the OAuth URL and exit (saves PKCE state for --code)',
577
1075
  type: 'boolean',
@@ -583,446 +1081,60 @@ export function authCmd(wrap) {
583
1081
  .option('read-only', {
584
1082
  describe: 'Mark the created profile as read-only (blocks mutation commands)',
585
1083
  type: 'boolean',
586
- default: false,
587
1084
  })
588
1085
  .option('skip-confirmations', {
589
1086
  describe: 'Skip delete confirmations on this profile (deletes run without prompting)',
590
1087
  type: 'boolean',
591
- default: false,
592
1088
  })
593
1089
  .option('include-counts', {
594
1090
  describe: 'Include result totals on list commands (default on; pass --no-include-counts to opt out)',
595
1091
  type: 'boolean',
596
1092
  }),
597
- handler: wrap(async (argv, app) => {
598
- let instanceURL;
599
- if (argv.profile) {
600
- const namedProfile = app.config.profiles?.[argv.profile];
601
- if (!namedProfile?.instance_url) {
602
- throw errUsage(`Profile "${argv.profile}" is not configured.`);
603
- }
604
- // An explicit profile is authoritative, including in an
605
- // interactive terminal. Never let the picker retarget it.
606
- instanceURL = normalizeInstanceURL(namedProfile.instance_url);
607
- } else if (argv.instance) {
608
- instanceURL = resolveInstanceArg(argv.instance, app.config);
609
- } else if (canUsePicker(app, argv)) {
610
- // gh-faithful: interactive terminals always get a picker —
611
- // existing profiles plus "+ Add a new instance". Zero profiles
612
- // goes straight to the setup wizard.
613
- const profileNames = Object.keys(app.config.profiles || {});
614
- if (profileNames.length === 0) {
615
- const wizard = await loginWizard(app, argv);
616
- if (wizard.loggedIn) {
617
- app.ok({ authenticated: true, instance: wizard.instanceURL, profile: wizard.profileName },
618
- { summary: `✓ Authenticated to ${wizard.instanceURL} (profile: ${wizard.profileName})` });
619
- } else {
620
- app.ok({ setup: true, instance: wizard.instanceURL, profile: wizard.profileName },
621
- { summary: `Profile "${wizard.profileName}" created for ${wizard.instanceURL}. Run "jsn auth login" to authenticate.` });
622
- }
623
- return;
624
- }
625
- const target = await pickLoginTarget(app);
626
- if (target.addNew) {
627
- instanceURL = resolveInstanceArg(target.instanceName, app.config);
628
- } else {
629
- instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
630
- }
631
- } else {
632
- instanceURL = getEffectiveInstance(app.config);
633
- if (!instanceURL) {
634
- throw errUsage(`Instance URL required.
635
-
636
- Examples:
637
- jsn auth login https://dev12345.service-now.com
638
- jsn auth login dev373698
639
- jsn auth login --basic https://dev328604.service-now.com
640
-
641
- Find your instance URL in your browser's address bar when logged into ServiceNow.`);
642
- }
643
- }
644
-
645
- const target = resolveTargetProfile(argv, app.config, instanceURL);
646
- const targetProfile = target.profile;
647
- validateLoginSelectors(argv, targetProfile);
648
- let targetUsername = targetProfile?.username || '';
649
- const useBasic = shouldUseBasicAuth(argv, app.config, instanceURL, targetProfile);
650
- const useGck = shouldUseGckAuth(argv, app.config, instanceURL, targetProfile);
651
- // --basic (with --password as a compatibility alias): authenticate
652
- // with basic auth from env vars and persist it for this profile.
653
- if (useGck) {
654
- const input = argv.headers || await readBrowserSessionInput();
655
- const credentials = await app.auth.loginWithGck(instanceURL, input, targetUsername || undefined);
656
- targetUsername = credentials?.username || targetUsername;
657
- } else if (useBasic) {
658
- const credentials = await app.auth.loginWithPassword(instanceURL, targetUsername || undefined);
659
- // A new profile has no configured username yet. The auth
660
- // manager saves env/stored credentials under creds.username;
661
- // carry that identity into verification and profile creation.
662
- targetUsername = credentials?.username || targetUsername;
663
- }
664
- // --print-url with --wait-file: print URL and wait for code file
665
- else if (argv.printUrl && argv.waitFile) {
666
- await app.auth.buildAuthURL(instanceURL, argv.waitFile, targetUsername || undefined);
667
- }
668
- // --print-url: just print the URL and exit
669
- else if (argv.printUrl) {
670
- const authURL = app.auth.buildAuthURL(instanceURL);
671
- console.log(authURL);
672
- return;
673
- }
674
- // --code: exchange the provided authorization code directly
675
- else if (argv.code) {
676
- await app.auth.loginWithCode(instanceURL, argv.code, targetUsername || undefined);
677
- } else {
678
- // Interactive: full OAuth flow with browser + prompt
679
- await app.auth.login(instanceURL, targetUsername || undefined);
680
- }
681
-
682
- // Verify auth by fetching current user
683
- let username = '';
684
- try {
685
- const activeName = app.config.activeProfile || app.config.defaultProfile;
686
- const activeProfile = app.config.profiles?.[activeName];
687
- const activeInstance = app.getEffectiveInstance?.() || activeProfile?.instance_url || '';
688
- const activeUsername = activeProfile?.username || '';
689
- const activeMethod = activeProfile?.auth_method || '';
690
- const targetMethod = useGck ? 'gck' : useBasic ? 'basic' : 'oauth';
691
- const targetDiffers = instanceURL !== activeInstance
692
- || targetUsername !== activeUsername
693
- || targetMethod !== activeMethod;
694
- if (!app.sdk || targetDiffers) {
695
- app.sdk = app.getSDKForProfile
696
- ? app.getSDKForProfile(instanceURL, {
697
- authMethod: targetMethod,
698
- username: targetUsername,
699
- profile: targetProfile,
700
- })
701
- : app.sdk;
702
- }
703
- const user = await app.sdk.getCurrentUser();
704
- username = user?.user_name || user?.name || '';
705
- } catch {
706
- // Non-fatal — token is saved, just couldn't verify
707
- }
708
-
709
- // Basic/GCK auth establishes the target identity before the
710
- // profile exists; preserve it when verification is unavailable.
711
- username = username || targetUsername;
712
-
713
- // Save to profiles — deduplicate by instance URL
714
- if (!app.config.profiles) {
715
- app.config.profiles = {};
716
- }
717
-
718
- // Preserve an explicitly selected profile even when another profile
719
- // shares the same instance URL.
720
- let profileName = target.name;
721
- if (!profileName) {
722
- for (const [existingName, existingProfile] of Object.entries(app.config.profiles)) {
723
- if (existingProfile.instance_url === instanceURL) {
724
- profileName = existingName;
725
- break;
726
- }
727
- }
728
- }
729
-
730
- // If no existing profile found, generate a name from the URL
731
- if (!profileName) {
732
- profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
733
- }
734
-
735
- app.config.profiles[profileName] = {
736
- ...(app.config.profiles[profileName] || {}),
737
- instance_url: instanceURL,
738
- auth_method: useGck ? 'gck' : useBasic ? 'basic' : 'oauth',
739
- username: username || undefined,
740
- read_only: argv['read-only'] || undefined,
741
- skip_confirmations: argv['skip-confirmations'] || undefined,
742
- include_counts: argv['include-counts'] === true ? true : argv['include-counts'] === false ? false : undefined,
743
- };
744
-
745
- // Re-save OAuth credentials with username now that we have it,
746
- // so they're keyed by <user>@<instance> for per-user isolation.
747
- // The legacy bare-instance key is cleaned up behind the
748
- // AuthManager seam (credential-store internals don't leak here).
749
- if (!useBasic && username) {
750
- app.auth.migrateLegacyCredential?.(instanceURL, username);
751
- }
752
-
753
- // Set as default if this is the first one
754
- const setDefault = !app.config.instanceURL && !app.config.defaultProfile;
755
- if (setDefault) {
756
- app.config.instanceURL = instanceURL;
757
- app.config.defaultProfile = profileName;
758
- }
759
-
760
- const { saveConfig } = await import('../config.js');
761
- saveConfig(app.config);
762
-
763
- const result = {
764
- authenticated: true,
765
- instance: instanceURL,
766
- username: username || undefined,
767
- profile: profileName,
768
- default: setDefault || undefined,
769
- };
770
- const summary = username
771
- ? `✓ Authenticated to ${instanceURL} as ${username} (profile: ${profileName})`
772
- : `✓ Authenticated to ${instanceURL} (profile: ${profileName})`;
773
- app.ok(result, { summary });
774
- }),
1093
+ handler: wrap(loginProfile),
775
1094
  })
776
1095
  .command({
777
1096
  command: 'logout [instance]',
778
1097
  describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
779
- handler: wrap(async (argv, app) => {
780
- let instanceURL;
781
- if (argv.instance) {
782
- instanceURL = resolveInstanceArg(argv.instance, app.config);
783
- } else {
784
- instanceURL = getEffectiveInstance(app.config);
785
- if (!instanceURL) {
786
- throw errUsage(`No instance specified.
787
-
788
- Examples:
789
- jsn auth logout
790
- jsn auth logout https://dev12345.service-now.com`);
791
- }
792
- }
793
- const target = resolveTargetProfile(argv, app.config, instanceURL);
794
- const username = target.profile?.username ?? null;
795
- if (username || canLogoutBareProfile(app, instanceURL, target.profile, !!argv.profile)) {
796
- app.auth.logout(instanceURL, username);
797
- }
798
- app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
799
- }),
1098
+ handler: wrap(logoutProfile),
800
1099
  })
801
1100
  .command({
802
1101
  command: 'status',
803
- describe: 'Show detailed authentication status',
804
- handler: wrap(async (argv, app) => {
805
- const configuredDefault = app.config.profiles?.[app.config.defaultProfile];
806
- const defaultInstance = configuredDefault?.instance_url || app.config.instanceURL || '';
807
-
808
- // Check environment auth
809
- const envToken = process.env.SERVICENOW_OAUTH_TOKEN || '';
810
-
811
- const profiles = [];
812
- const configuredProfiles = Object.entries(app.config.profiles || {});
813
- const selectedProfiles = argv.profile
814
- ? configuredProfiles.filter(([name]) => name === argv.profile)
815
- : configuredProfiles;
816
- for (const [name, profile] of selectedProfiles) {
817
- const instance = profile.instance_url;
818
- const authOptions = { authMethod: profile.auth_method, username: profile.username };
819
- const identityOptions = { username: profile.username, authMethod: profile.auth_method };
820
- const isAuth = app.auth.isAuthenticatedFor(instance, identityOptions);
821
- const lastSeen = app.auth.getLastSeen(instance, identityOptions);
822
- const authState = app.auth.getAuthState(instance, authOptions);
823
- const legacy = !isAuth && app.auth.hasLegacyCredentials(instance, identityOptions) ? true : undefined;
824
- // Keep the established status field contract: unauthenticated
825
- // profiles omit auth_source, except legacy credentials retain
826
- // their historical `legacy` marker. getAuthState remains the
827
- // safe metadata seam for authenticated profiles.
828
- const authSource = isAuth ? authState.auth_source : (legacy ? 'legacy' : undefined);
829
-
830
- // Try live verification through AuthManager's read-only probe seam.
831
- let verified = null;
832
- let verifiedAt = null;
833
- let probe;
834
- if (!instance) {
835
- probe = app.auth.probeUnavailable?.('missing_instance') || { status: 'not_attempted', code: 'missing_instance', classification: 'unavailable' };
836
- } else {
837
- try {
838
- const sdk = app.getSDKForProfile
839
- ? app.getSDKForProfile(instance, { ...authOptions, profile })
840
- : app.sdk || { getCurrentUser: async () => null };
841
- let user;
842
- probe = await app.auth.probeCurrentUser(instance, {
843
- getCurrentUser: async (options) => {
844
- user = await sdk.getCurrentUser(options);
845
- return user;
846
- },
847
- }, authOptions);
848
- if (probe.status === 'succeeded' && user?.user_name) {
849
- verified = true;
850
- verifiedAt = user.user_name;
851
- } else if (probe.status !== 'not_attempted') {
852
- verified = false;
853
- }
854
- } catch {
855
- probe = app.auth.probeUnavailable?.('sdk_construction_failed') || { status: 'not_attempted', code: 'sdk_construction_failed', classification: 'configuration_error' };
856
- verified = false;
857
- }
858
- }
859
-
860
- // Calculate days since last seen
861
- let daysSinceLastSeen = null;
862
- if (lastSeen) {
863
- daysSinceLastSeen = Math.floor((Date.now() / 1000 - lastSeen) / 86400);
864
- }
865
-
866
- profiles.push({
867
- name,
868
- instance,
869
- authenticated: isAuth,
870
- legacy,
871
- auth_source: authSource,
872
- verified,
873
- verified_as: verifiedAt || undefined,
874
- last_seen: lastSeen || undefined,
875
- days_since_last_seen: daysSinceLastSeen,
876
- stale: daysSinceLastSeen > 7,
877
- default: name === app.config.defaultProfile,
878
- read_only: profile.read_only || false,
879
- skip_confirmations: profile.skip_confirmations || false,
880
- include_counts: profile.include_counts !== false,
881
- diagnostics: {
882
- auth_method: authState.auth_method,
883
- auth_source: authState.auth_source,
884
- state: authState.state,
885
- probe,
886
- },
887
- });
888
- }
889
-
890
- const result = {
891
- default_instance: defaultInstance,
892
- authenticated: app.auth.isAuthenticated(),
893
- environment_auth: envToken ? true : undefined,
894
- profiles,
895
- };
896
-
897
- const summary = `${profiles.length} profile(s)`;
898
- // Ship the styled view as _formatted (see renderAuthStatus); the
899
- // OutputWriter prints it verbatim and suppresses the summary.
900
- result._formatted = renderAuthStatus(result, summary);
901
- app.ok(result, { summary });
902
-
903
- // Warn about legacy credentials that need re-authentication
904
- const legacyProfiles = profiles.filter(p => p.legacy);
905
- if (legacyProfiles.length > 0) {
906
- process.stderr.write(
907
- '\n⚠️ This release changed how credentials are stored.\n' +
908
- ' Some profiles still use the old format.\n' +
909
- ' Run the following to re-authenticate:\n'
910
- );
911
- for (const p of legacyProfiles) {
912
- process.stderr.write(` jsn auth login ${p.name}\n`);
913
- }
914
- process.stderr.write('\n');
915
- }
1102
+ describe: 'Show authentication status (use --details for users and instance URLs)',
1103
+ builder: sub => sub.option('local', {
1104
+ type: 'boolean', describe: 'Show saved credential state without contacting any instance',
1105
+ }).option('details', {
1106
+ type: 'boolean', describe: 'Include users and instance URLs in status output',
916
1107
  }),
1108
+ handler: wrap(showAuthStatus),
917
1109
  })
918
1110
  .command({
919
1111
  command: 'refresh [instance]',
920
- describe: 'Refresh OAuth token for an instance',
921
- handler: wrap(async (argv, app) => {
922
- let instanceURL;
923
- if (argv.instance) {
924
- instanceURL = resolveInstanceArg(argv.instance, app.config);
925
- } else {
926
- instanceURL = getEffectiveInstance(app.config);
927
- if (!instanceURL) {
928
- throw errUsage(`No instance specified and no default configured.
929
-
930
- Examples:
931
- jsn auth refresh
932
- jsn auth refresh https://dev12345.service-now.com
933
- jsn auth refresh dev12345`);
934
- }
935
- }
936
-
937
- const target = resolveTargetProfile(argv, app.config, instanceURL);
938
- const targetProfile = target.profile;
939
- const targetAuthOptions = {
940
- authMethod: targetProfile ? targetProfile.auth_method : 'oauth',
941
- username: targetProfile?.username,
942
- };
943
- const creds = await app.auth.getCredentialsFor(
944
- instanceURL,
945
- targetProfile?.username,
946
- targetAuthOptions
947
- );
948
- const refreshed = await app.auth.refreshToken(instanceURL, creds, targetProfile?.username ?? null);
949
-
950
- // Re-probe domain separation on refresh so the capability flag
951
- // stays in sync if the plugin was installed/removed since setup.
952
- try {
953
- const { isDomainSeparationInstalled } = await import('./domains.js');
954
- const sdk = app.getSDKForProfile
955
- ? app.getSDKForProfile(instanceURL, {
956
- ...targetAuthOptions,
957
- profile: targetProfile,
958
- })
959
- : app.sdk;
960
- const hasDS = await isDomainSeparationInstalled({ sdk });
961
- const name = target.name || app.config.activeProfile || app.config.defaultProfile;
962
- if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
963
- app.config.profiles[name].domain_separation = hasDS;
964
- await saveConfig(app.config);
965
- }
966
- } catch {
967
- // Non-fatal — capability probe is best-effort
968
- }
969
-
970
- app.ok({
971
- refreshed: true,
972
- instance: instanceURL,
973
- expires_at: refreshed.expires_at,
974
- }, { summary: `✓ Token refreshed for ${instanceURL}` });
975
- }),
1112
+ describe: 'Refresh OAuth credentials or renew a browser session',
1113
+ builder: sub => sub
1114
+ .option('browser', { type: 'boolean', describe: 'Explicitly open a browser to renew the session' })
1115
+ .option('browser-name', { type: 'string', describe: 'Browser to launch: chromium, chrome, or edge' })
1116
+ .option('browser-url', { type: 'string', describe: 'Advanced: local browser debugging URL (requires --browser)' })
1117
+ .option('headers', { type: 'string', describe: 'Captured browser request headers or cURL command' }),
1118
+ handler: wrap(refreshProfile),
976
1119
  })
977
1120
  .command({
978
1121
  command: 'switch [name]',
979
- describe: 'Switch active profile (gh: auth switch)',
1122
+ describe: 'Choose the profile used by default',
980
1123
  builder: (sub) => sub
981
1124
  .positional('name', {
982
1125
  describe: 'Profile name to activate',
983
1126
  type: 'string',
984
1127
  }),
985
- handler: wrap(async (argv, app) => {
986
- let name = argv.name;
987
- if (!name) {
988
- const profileNames = Object.keys(app.config.profiles || {});
989
- if (profileNames.length === 0) {
990
- throw errUsage('No profiles configured. Run "jsn auth login" first.');
991
- }
992
- if (profileNames.length === 1) {
993
- name = profileNames[0];
994
- } else if (canUsePicker(app, argv)) {
995
- const { search } = await import('@inquirer/prompts');
996
- const choices = profileNames.map(profileName => ({
997
- name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
998
- value: profileName,
999
- }));
1000
- const selected = await search({
1001
- message: 'Select a profile:',
1002
- source: (input) => {
1003
- const filter = (input || '').toLowerCase();
1004
- return choices.filter(c => c.name.toLowerCase().includes(filter));
1005
- },
1006
- });
1007
- name = selected;
1008
- } else {
1009
- // Never silently switch the active profile for a
1010
- // non-interactive caller: picking one for them is a
1011
- // config write they did not ask for.
1012
- throw errUsageHint(
1013
- `Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`,
1014
- 'Pass the profile name: jsn auth switch <name>'
1015
- );
1016
- }
1017
- }
1018
- await setActiveProfile(app.config, name);
1019
- app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
1020
- }),
1128
+ handler: wrap(switchProfile),
1021
1129
  })
1022
1130
  .command({
1023
1131
  command: 'modify [name]',
1024
- describe: 'Toggle instance configuration (read-only, skip confirmations, domain)',
1132
+ describe: 'Change profile settings (read-only, counts, delete confirmations, domain)',
1025
1133
  builder: (sub) => sub
1134
+ .option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
1135
+ .option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
1136
+ .option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
1137
+ .option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
1026
1138
  .positional('name', {
1027
1139
  describe: 'Profile name to modify',
1028
1140
  type: 'string',
@@ -1043,25 +1155,26 @@ Examples:
1043
1155
  describe: 'Profile name to delete',
1044
1156
  type: 'string',
1045
1157
  }),
1046
- handler: wrap(async (argv, app) => {
1047
- let name = argv.name;
1048
- if (!name) {
1049
- name = await pickProfile(app, 'Remove which instance?');
1050
- }
1051
- await removeProfile(app, name);
1052
- }),
1158
+ handler: wrap(removeConnection),
1053
1159
  });
1054
1160
  },
1055
- handler: () => {
1056
- console.log('Manage authentication for ServiceNow instances.');
1057
- console.log('');
1058
- console.log('Available subcommands:');
1059
- console.log(' login <url|name> Log in / add an instance (picker when run bare)');
1060
- console.log(' refresh Refresh the access token');
1061
- console.log(' status Show auth status for all profiles');
1062
- console.log('');
1063
- console.log('Tip: "jsn setup" is the interactive manager — add, switch, remove, or modify instances.');
1064
- console.log('Run "jsn auth <command> --help" for details.');
1065
- },
1161
+ handler: wrap(async (_argv, app) => {
1162
+ const commands = [
1163
+ { name: 'login', description: 'Sign in or add a connection' },
1164
+ { name: 'status', description: 'Show authentication and connection status' },
1165
+ { name: 'refresh', description: 'Refresh OAuth credentials or renew a browser session' },
1166
+ { name: 'switch', description: 'Choose the default profile' },
1167
+ { name: 'modify', description: 'Change connection settings' },
1168
+ { name: 'logout', description: 'Sign out and keep the profile' },
1169
+ { name: 'remove', description: 'Remove a connection and its credentials' },
1170
+ ];
1171
+ app.ok({
1172
+ commands: commands.map(command => command.name),
1173
+ _formatted: formatCommandList({ title: 'Manage ServiceNow authentication and connection settings', commands }),
1174
+ }, {
1175
+ summary: 'Manage ServiceNow authentication and connection settings',
1176
+ breadcrumbs: [{ action: 'Guided setup', cmd: 'jsn setup', description: 'Interactive login and connection management' }],
1177
+ });
1178
+ }),
1066
1179
  };
1067
1180
  }