@jacebenson/jsn 4.3.9-dev.63 → 4.3.9-dev.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.9-dev.63",
3
+ "version": "4.3.9-dev.64",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -8,9 +8,10 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import crypto from 'node:crypto';
10
10
  import { execFileSync } from 'node:child_process';
11
- import { password as passwordPrompt } from '@inquirer/prompts';
11
+ import { password as passwordPrompt } from './prompts.js';
12
12
  import { globalConfigDir, normalizeInstanceURL } from './config.js';
13
13
  import { errAuth } from './errors.js';
14
+ import { pendingBrowserLogin } from './browser-auth.js';
14
15
 
15
16
  // ─── Credential key helpers ───
16
17
 
@@ -699,7 +700,7 @@ export class AuthManager {
699
700
  if (method === 'basic' && (!resolved.username || !resolved.password)) throw errAuth('Invalid basic auth credentials');
700
701
  if (method === 'oauth' && !resolved.access_token) throw errAuth('Invalid OAuth credentials');
701
702
  // Check expiry — refresh if less than 5 minutes remaining
702
- if (resolved.expires_at && Date.now() >= (resolved.expires_at - 300) * 1000) {
703
+ if (options.refresh !== false && resolved.expires_at && Date.now() >= (resolved.expires_at - 300) * 1000) {
703
704
  if (resolved.refresh_token) return this.refreshToken(instance, resolved, credentialUsername);
704
705
  throw errAuth('Token expired, please login again');
705
706
  }
@@ -712,17 +713,17 @@ export class AuthManager {
712
713
  const pkce = generatePKCE();
713
714
  const authURL = buildAuthURL(instanceURL, clientID, pkce);
714
715
 
715
- console.log();
716
- console.log('Open this URL in your browser to authenticate:');
717
- console.log(authURL);
718
- console.log();
719
- console.log('(The browser is not opened automatically — if a browser tab opens,');
720
- console.log(' use the URL above if the opened page shows an error.)');
721
- console.log();
716
+ console.error();
717
+ console.error('Open this URL in your browser to authenticate:');
718
+ console.error(authURL);
719
+ console.error();
720
+ console.error('(The browser is not opened automatically — if a browser tab opens,');
721
+ console.error(' use the URL above if the opened page shows an error.)');
722
+ console.error();
722
723
 
723
- console.log('After authenticating in the browser, copy the authorization code shown on the page.');
724
- console.log('(input is hidden for security — just paste and press Enter)');
725
- console.log();
724
+ console.error('After authenticating in the browser, copy the authorization code shown on the page.');
725
+ console.error('(input is hidden for security — just paste and press Enter)');
726
+ console.error();
726
727
 
727
728
  const authCode = await askHidden('Authorization code (hidden on paste for security): ');
728
729
  const code = authCode.trim();
@@ -730,7 +731,7 @@ export class AuthManager {
730
731
  throw errAuth('Authorization code is required');
731
732
  }
732
733
 
733
- console.log('\nExchanging authorization code for tokens...');
734
+ console.error('\nExchanging authorization code for tokens...');
734
735
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
735
736
  // Save with the selected profile username when one is already known.
736
737
  this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
@@ -751,10 +752,10 @@ export class AuthManager {
751
752
  savePKCEState(instanceURL, pkce, username);
752
753
  const url = buildAuthURL(instanceURL, clientID, pkce);
753
754
  if (waitFile) {
754
- console.log(url);
755
- console.log();
756
- console.log(`Waiting for authorization code in file: ${waitFile}`);
757
- console.log('(polling every 2 seconds — write the code to the file to complete login)');
755
+ console.error(url);
756
+ console.error();
757
+ console.error(`Waiting for authorization code in file: ${waitFile}`);
758
+ console.error('(polling every 2 seconds — write the code to the file to complete login)');
758
759
  return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile, 300000, username);
759
760
  }
760
761
  return url;
@@ -764,18 +765,15 @@ export class AuthManager {
764
765
  const start = Date.now();
765
766
  const pollInterval = 2000;
766
767
  while (Date.now() - start < timeout) {
767
- try {
768
- const code = fs.readFileSync(filePath, 'utf-8').trim();
769
- if (code) {
770
- console.log(`\nAuthorization code found in ${filePath}`);
771
- removePKCEState(instanceURL, username);
772
- const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
773
- this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
774
- console.log('Token exchange successful!\n');
775
- return newCreds;
776
- }
777
- } catch {
778
- // File not ready yet
768
+ let code = '';
769
+ try { code = fs.readFileSync(filePath, 'utf-8').trim(); } catch { /* File not ready yet. */ }
770
+ if (code) {
771
+ console.error(`\nAuthorization code found in ${filePath}`);
772
+ const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
773
+ removePKCEState(instanceURL, username);
774
+ this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
775
+ console.error('Token exchange successful!\n');
776
+ return newCreds;
779
777
  }
780
778
  await new Promise(r => setTimeout(r, pollInterval));
781
779
  }
@@ -787,11 +785,13 @@ export class AuthManager {
787
785
  * Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
788
786
  * Stores the basic auth credentials so they persist across sessions.
789
787
  */
790
- async loginWithPassword(instanceURL, username = this._activeUsername()) {
788
+ async loginWithPassword(instanceURL, username = this._activeUsername(), suppliedCredentials) {
791
789
  instanceURL = normalizeInstanceURL(instanceURL);
792
790
  const envCreds = getBasicAuthFromEnv(instanceURL);
793
791
  const storedCreds = this.loadCredentials(instanceURL, username);
794
- const creds = envCreds || storedCreds;
792
+ const creds = suppliedCredentials
793
+ ? { auth_method: 'basic', ...suppliedCredentials }
794
+ : envCreds || storedCreds;
795
795
  if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
796
796
  throw errAuth(
797
797
  `No basic auth credentials found in environment or stored profile.\n\n` +
@@ -802,8 +802,10 @@ export class AuthManager {
802
802
  ` jsn auth login --basic ${instanceURL}`
803
803
  );
804
804
  }
805
+ if (username && creds.username !== username) {
806
+ throw errAuth('The Basic credentials do not match the selected profile user. Use matching credentials or create a new named profile.');
807
+ }
805
808
  this.saveCredentials(instanceURL, creds, creds.username);
806
- console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
807
809
  return creds;
808
810
  }
809
811
 
@@ -812,28 +814,69 @@ export class AuthManager {
812
814
  * The PKCE state must have been saved by an earlier buildAuthURL() call.
813
815
  */
814
816
  async loginWithGck(instanceURL, input, username = this._activeUsername()) {
817
+ return this._loginWithBrowserSession(instanceURL, parseBrowserSessionInput(input), username);
818
+ }
819
+
820
+ async loginWithBrowser(instanceURL, options = {}, username = this._activeUsername()) {
821
+ if (options.launch) {
822
+ const { launchBrowserSession } = await import('./browser-launch.js');
823
+ const credentials = await launchBrowserSession(instanceURL, {
824
+ ...options,
825
+ verify: (creds, verification) => this._verifyBrowserSession(instanceURL, creds, username, { ...verification, requireIdentity: true }),
826
+ });
827
+ this.saveCredentials(normalizeInstanceURL(instanceURL), credentials, credentials.username);
828
+ return credentials;
829
+ }
830
+ const { readBrowserSession } = await import('./browser-auth.js');
831
+ const creds = await readBrowserSession(instanceURL, options);
832
+ return this._loginWithBrowserSession(instanceURL, creds, username, { requireIdentity: true });
833
+ }
834
+
835
+ async _loginWithBrowserSession(instanceURL, creds, username, { requireIdentity = false } = {}) {
836
+ const credentials = await this._verifyBrowserSession(instanceURL, creds, username, { requireIdentity });
837
+ this.saveCredentials(normalizeInstanceURL(instanceURL), credentials, credentials.username);
838
+ return credentials;
839
+ }
840
+
841
+ async _verifyBrowserSession(instanceURL, creds, username, { requireIdentity = false, signal, timeout = this.httpClient.timeout } = {}) {
815
842
  instanceURL = normalizeInstanceURL(instanceURL);
816
- const creds = parseBrowserSessionInput(input);
843
+ const query = new URLSearchParams({
844
+ sysparm_limit: '1',
845
+ sysparm_fields: 'user_name',
846
+ sysparm_query: 'user_name=javascript:gs.getUserName()',
847
+ });
817
848
  let response;
818
849
  try {
819
- response = await fetch(`${instanceURL}/api/now/table/sys_user?sysparm_limit=1`, {
850
+ response = await fetch(`${instanceURL}/api/now/table/sys_user?${query}`, {
820
851
  method: 'GET',
852
+ redirect: 'error',
853
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout),
821
854
  headers: {
822
855
  Accept: 'application/json',
823
856
  'X-UserToken': creds.access_token,
824
857
  Cookie: creds.cookies,
825
858
  },
826
859
  });
827
- } catch (error) {
828
- throw errAuth(`Could not test the browser session against ${instanceURL}. Nothing was saved. Retry with: jsn auth login ${instanceURL} --gck (${error.message})`);
860
+ } catch {
861
+ throw errAuth(`Could not test the browser session against ${instanceURL}. Nothing was saved. Retry browser-session login.`);
829
862
  }
830
863
  if (!response.ok) {
864
+ if (requireIdentity && [401, 403].includes(response.status)) {
865
+ throw pendingBrowserLogin('The session is not authorized for current-user verification. Finish signing in, or check current-user API access.');
866
+ }
831
867
  throw errAuth(`Browser session rejected by ${instanceURL} (HTTP ${response.status}). Nothing was saved. Retry with: jsn auth login ${instanceURL} --gck`);
832
868
  }
833
- const body = await response.json();
869
+ let body;
870
+ try { body = await response.json(); } catch { throw errAuth('Browser session verification did not return JSON. Nothing was saved.'); }
834
871
  const user = body?.result?.[0];
835
872
  const verifiedUsername = typeof user?.user_name === 'string' ? user.user_name : username;
836
- this.saveCredentials(instanceURL, { ...creds, username: verifiedUsername || undefined }, verifiedUsername || undefined);
873
+ if (requireIdentity && verifiedUsername === 'guest') throw pendingBrowserLogin('Waiting for browser login.');
874
+ if (requireIdentity && (!user?.user_name || typeof user.user_name !== 'string')) {
875
+ throw pendingBrowserLogin('Could not verify the browser account. Nothing was saved. Check current-user API access.');
876
+ }
877
+ if (requireIdentity && username && username !== verifiedUsername) {
878
+ throw errAuth('The browser account does not match the selected profile. Nothing was saved. Use the matching account or create a new profile.');
879
+ }
837
880
  return { ...creds, username: verifiedUsername || undefined };
838
881
  }
839
882
 
@@ -0,0 +1,162 @@
1
+ import { errAuth, errUsage } from './errors.js';
2
+ import { normalizeInstanceURL } from './config.js';
3
+
4
+ export const DEFAULT_BROWSER_URL = 'http://127.0.0.1:9222';
5
+
6
+ export function pendingBrowserLogin(message) {
7
+ return Object.assign(errAuth(message), { browserLoginPending: true });
8
+ }
9
+
10
+ // Only called for a fresh browser owned by the login launcher. Never use
11
+ // Browser.close for the advanced connection to a user's existing browser.
12
+ export async function closeLoginBrowser(browserUrl) {
13
+ const endpoint = localEndpoint(browserUrl, 'http:');
14
+ const response = await fetch(new URL('/json/version', endpoint), {
15
+ signal: AbortSignal.timeout(1000), redirect: 'error',
16
+ });
17
+ if (!response.ok) throw errAuth('Could not close the login browser.');
18
+ const version = await response.json();
19
+ const ws = localEndpoint(version.webSocketDebuggerUrl, 'ws:');
20
+ if (ws.hostname !== endpoint.hostname || ws.port !== endpoint.port) throw errAuth('Unexpected login browser endpoint.');
21
+ const client = await connect(ws, 1000);
22
+ try { await client.send('Browser.close'); } finally { client.close(); }
23
+ }
24
+
25
+ // Browser access is opt-in and confined to a local debugging connection.
26
+ // Never inspect profile databases or enumerate cookies for unrelated sites.
27
+ function localEndpoint(value, protocol) {
28
+ let url;
29
+ try { url = new URL(value); } catch { throw errUsage('Invalid browser debugging URL.'); }
30
+ if (url.protocol !== protocol || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
31
+ || url.username || url.password || url.search || url.hash) {
32
+ throw errUsage('Browser debugging must use a loopback URL without credentials, query, or fragment.');
33
+ }
34
+ return url;
35
+ }
36
+
37
+ function connect(url, timeout) {
38
+ return new Promise((resolve, reject) => {
39
+ const socket = new WebSocket(url);
40
+ const pending = new Map();
41
+ let nextId = 0;
42
+ const fail = () => {
43
+ for (const { reject, timer } of pending.values()) {
44
+ clearTimeout(timer);
45
+ reject(pendingBrowserLogin('Browser debugging connection closed. Retry the import.'));
46
+ }
47
+ pending.clear();
48
+ };
49
+ const timer = setTimeout(() => {
50
+ reject(pendingBrowserLogin('Timed out connecting to the browser debugging port.'));
51
+ socket.close();
52
+ }, timeout);
53
+ socket.addEventListener('error', () => {
54
+ clearTimeout(timer);
55
+ reject(pendingBrowserLogin('Could not connect to the browser debugging port.'));
56
+ fail();
57
+ });
58
+ socket.addEventListener('close', () => {
59
+ clearTimeout(timer);
60
+ reject(pendingBrowserLogin('Browser debugging connection closed. Retry the import.'));
61
+ fail();
62
+ });
63
+ socket.addEventListener('message', ({ data }) => {
64
+ let message;
65
+ try { message = JSON.parse(data); } catch { return; }
66
+ const request = pending.get(message.id);
67
+ if (!request) return;
68
+ pending.delete(message.id);
69
+ clearTimeout(request.timer);
70
+ // Provider errors can contain evaluated values; never echo them.
71
+ if (message.error) request.reject(pendingBrowserLogin('The browser could not read the selected session.'));
72
+ else request.resolve(message.result);
73
+ });
74
+ socket.addEventListener('open', () => {
75
+ clearTimeout(timer);
76
+ resolve({
77
+ send(method, params = {}) {
78
+ return new Promise((resolve, reject) => {
79
+ const id = ++nextId;
80
+ const timer = setTimeout(() => {
81
+ pending.delete(id);
82
+ reject(pendingBrowserLogin('Timed out reading the browser session.'));
83
+ }, timeout);
84
+ pending.set(id, { resolve, reject, timer });
85
+ try { socket.send(JSON.stringify({ id, method, params })); } catch {
86
+ clearTimeout(timer);
87
+ pending.delete(id);
88
+ reject(pendingBrowserLogin('Browser debugging connection closed. Retry the import.'));
89
+ }
90
+ });
91
+ },
92
+ close: () => socket.close(),
93
+ });
94
+ });
95
+ });
96
+ }
97
+
98
+ // Only read the token from the matching origin, including classic UI frames.
99
+ // This does not navigate, reload, or initiate requests in the user's browser.
100
+ const TOKEN_EXPRESSION = `(() => {
101
+ const origin = location.origin;
102
+ function read(win) {
103
+ try {
104
+ if (win.location.origin !== origin) return '';
105
+ if (typeof win.g_ck === 'string' && win.g_ck) return win.g_ck;
106
+ for (let i = 0; i < win.frames.length; i++) {
107
+ const token = read(win.frames[i]);
108
+ if (token) return token;
109
+ }
110
+ } catch {}
111
+ return '';
112
+ }
113
+ return { origin, token: read(window) };
114
+ })()`;
115
+
116
+ export async function readBrowserSession(instance, { browserUrl = DEFAULT_BROWSER_URL, timeout = 10000 } = {}) {
117
+ const origin = normalizeInstanceURL(instance);
118
+ const endpoint = localEndpoint(browserUrl, 'http:');
119
+ if (endpoint.pathname !== '/') throw errUsage('Browser debugging URL must be an origin, for example http://127.0.0.1:9222.');
120
+ let tabs;
121
+ try {
122
+ const response = await fetch(new URL('/json/list', endpoint), {
123
+ signal: AbortSignal.timeout(timeout), redirect: 'error',
124
+ });
125
+ if (!response.ok) throw new Error();
126
+ tabs = await response.json();
127
+ } catch {
128
+ throw pendingBrowserLogin('Could not list browser tabs. Enable local Chromium remote debugging, then retry with --browser --browser-url http://127.0.0.1:9222.');
129
+ }
130
+ const matching = Array.isArray(tabs) ? tabs.filter(tab => {
131
+ try { return tab.type === 'page' && new URL(tab.url).origin === origin; } catch { return false; }
132
+ }) : [];
133
+ if (!matching.length) throw pendingBrowserLogin('No matching ServiceNow tab. Open the selected instance in the connected browser and sign in first.');
134
+ if (matching.length > 1) throw errAuth('Multiple tabs match this instance. Keep one tab open for the account to import, then retry.');
135
+ const ws = localEndpoint(matching[0].webSocketDebuggerUrl, 'ws:');
136
+ if (ws.hostname !== endpoint.hostname || ws.port !== endpoint.port) {
137
+ throw errAuth('Browser returned a debugging connection outside the selected endpoint.');
138
+ }
139
+ const client = await connect(ws, timeout);
140
+ try {
141
+ const evaluated = await client.send('Runtime.evaluate', { expression: TOKEN_EXPRESSION, returnByValue: true });
142
+ const session = evaluated?.result?.value;
143
+ if (evaluated?.exceptionDetails || session?.origin !== origin || typeof session?.token !== 'string' || !session.token) {
144
+ throw pendingBrowserLogin('No user token in the matching tab. Sign in and open a ServiceNow application page, then retry; pasted headers with --gck remain available.');
145
+ }
146
+ const result = await client.send('Network.getCookies', { urls: [`${origin}/api/now/table/sys_user`] });
147
+ const cookies = result?.cookies;
148
+ if (!Array.isArray(cookies) || !cookies.length) throw pendingBrowserLogin('No cookies available for the selected instance. Sign in and retry.');
149
+ // Confirm the page did not navigate to another origin during the read.
150
+ const location = await client.send('Runtime.evaluate', { expression: 'location.origin', returnByValue: true });
151
+ if (location?.result?.value !== origin) throw pendingBrowserLogin('The browser tab changed during import. Retry on the selected instance.');
152
+ const cookieHeader = cookies.map(({ name, value }) => {
153
+ if (typeof name !== 'string' || !/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name)
154
+ || typeof value !== 'string' || /[\r\n;]/.test(value)) throw errAuth('The browser returned invalid cookies.');
155
+ return `${name}=${value}`;
156
+ }).join('; ');
157
+ if (/[\r\n]/.test(session.token)) throw errAuth('The browser returned an invalid user token.');
158
+ return { auth_method: 'gck', access_token: session.token, cookies: cookieHeader, auth_source: 'gck' };
159
+ } finally {
160
+ client.close();
161
+ }
162
+ }
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { readBrowserSession, closeLoginBrowser } from './browser-auth.js';
7
+ import { normalizeInstanceURL } from './config.js';
8
+ import { errAuth, errUsage } from './errors.js';
9
+
10
+ const BROWSERS = [
11
+ { name: 'Chromium', value: 'chromium', binaries: ['chromium', 'chromium-browser'] },
12
+ { name: 'Google Chrome', value: 'chrome', binaries: ['google-chrome', 'google-chrome-stable', 'chrome'] },
13
+ { name: 'Microsoft Edge', value: 'edge', binaries: ['microsoft-edge', 'microsoft-edge-stable', 'msedge'] },
14
+ ];
15
+
16
+ export function browserCandidates({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
17
+ const paths = platform === 'win32' ? path.win32 : path.posix;
18
+ const environment = name => platform === 'win32'
19
+ ? Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]
20
+ : env[name];
21
+ return BROWSERS.map(browser => {
22
+ const candidates = [];
23
+ for (const entry of (environment('PATH') || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)) {
24
+ const dir = platform === 'win32' ? entry.replace(/^"(.*)"$/, '$1') : entry;
25
+ for (const binary of browser.binaries) candidates.push(paths.join(dir, binary + (platform === 'win32' ? '.exe' : '')));
26
+ }
27
+ if (platform === 'darwin') {
28
+ const app = { chromium: 'Chromium.app/Contents/MacOS/Chromium', chrome: 'Google Chrome.app/Contents/MacOS/Google Chrome', edge: 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge' }[browser.value];
29
+ candidates.push(paths.join('/Applications', app), paths.join(home, 'Applications', app));
30
+ }
31
+ if (platform === 'win32') {
32
+ const app = { chromium: 'Chromium/Application/chrome.exe', chrome: 'Google/Chrome/Application/chrome.exe', edge: 'Microsoft/Edge/Application/msedge.exe' }[browser.value];
33
+ for (const dir of ['ProgramW6432', 'ProgramFiles', 'ProgramFiles(x86)', 'LocalAppData'].map(environment).filter(Boolean)) candidates.push(paths.join(dir, app));
34
+ }
35
+ return { ...browser, candidates: [...new Set(candidates)] };
36
+ });
37
+ }
38
+
39
+ export async function findBrowsers(options = {}) {
40
+ const found = [];
41
+ for (const browser of browserCandidates(options)) {
42
+ for (const executable of browser.candidates) {
43
+ try {
44
+ await fs.access(executable, fs.constants.X_OK);
45
+ if (!(await fs.stat(executable)).isFile()) continue;
46
+ found.push({ name: browser.name, value: browser.value, executable });
47
+ break;
48
+ } catch { /* Try another installation location. */ }
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ export async function chooseBrowser({ browserName, interactive = false } = {}) {
55
+ if (browserName && !BROWSERS.some(browser => browser.value === browserName)) {
56
+ throw errUsage('Choose chromium, chrome, or edge with --browser-name. Firefox login is not supported yet.');
57
+ }
58
+ const browsers = await findBrowsers();
59
+ if (browserName) {
60
+ const browser = browsers.find(browser => browser.value === browserName);
61
+ if (!browser) throw errUsage(`The selected browser (${browserName}) was not found. Install it or choose another browser.`);
62
+ return browser;
63
+ }
64
+ if (!browsers.length) throw errUsage('No supported browser found. Install Chrome/Chromium or Edge, or use --gck to paste a browser request. Firefox login is not supported yet.');
65
+ if (!interactive || browsers.length === 1) return browsers[0];
66
+ const { select } = await import('./prompts.js');
67
+ const selected = await select({ message: 'Log in using which browser?', choices: browsers });
68
+ return browsers.find(browser => browser.value === selected);
69
+ }
70
+
71
+ // Each login gets a fresh browser profile and an OS-assigned debugging port.
72
+ // Neither the user's regular browser nor a previous login account is reused.
73
+ export async function launchBrowserSession(instance, {
74
+ browser, verify = async credentials => credentials, timeout = 300000,
75
+ onProgress = () => {}, spawnBrowser = spawn, signal,
76
+ } = {}) {
77
+ if (!browser?.executable) throw errUsage('Select an installed browser before login.');
78
+ const origin = normalizeInstanceURL(instance);
79
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'jsn-browser-login-'));
80
+ await fs.chmod(directory, 0o700);
81
+ const controller = new AbortController();
82
+ const cancel = () => controller.abort();
83
+ process.once('SIGINT', cancel);
84
+ signal?.addEventListener('abort', cancel, { once: true });
85
+ if (signal?.aborted) cancel();
86
+ let child;
87
+ let exited = false;
88
+ let launchFailed = false;
89
+ let closed = Promise.resolve();
90
+ let lastCheck = '';
91
+ let browserUrl;
92
+ const deadline = Date.now() + timeout;
93
+ try {
94
+ if (controller.signal.aborted) throw errAuth('Browser login cancelled.');
95
+ child = spawnBrowser(browser.executable, [
96
+ '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0',
97
+ `--user-data-dir=${directory}`, '--no-first-run', '--no-default-browser-check',
98
+ '--new-window', origin,
99
+ ], { stdio: 'ignore', shell: false, windowsHide: true });
100
+ closed = new Promise(resolve => {
101
+ child.once('error', () => { launchFailed = true; exited = true; resolve(); });
102
+ child.once('close', () => { exited = true; resolve(); });
103
+ });
104
+ onProgress(`Opening ${browser.name}. Log in to ServiceNow in the new window; JSN will continue automatically.`);
105
+ while (Date.now() < deadline) {
106
+ if (controller.signal.aborted) throw errAuth('Browser login cancelled.');
107
+ if (exited) throw errAuth(launchFailed ? 'Could not launch the selected browser.' : 'The login window was closed before authentication completed.');
108
+ if (!browserUrl) {
109
+ try {
110
+ const port = (await fs.readFile(path.join(directory, 'DevToolsActivePort'), 'utf8')).split('\n')[0].trim();
111
+ if (/^\d+$/.test(port) && Number(port) > 0 && Number(port) < 65536) browserUrl = `http://127.0.0.1:${port}`;
112
+ } catch { /* Browser is still starting. */ }
113
+ }
114
+ if (browserUrl) {
115
+ try {
116
+ const credentials = await readBrowserSession(origin, { browserUrl, timeout: Math.min(2000, Math.max(1, deadline - Date.now())) });
117
+ if (controller.signal.aborted) throw errAuth('Browser login cancelled.');
118
+ const verified = await verify(credentials, {
119
+ signal: controller.signal, timeout: Math.min(5000, Math.max(1, deadline - Date.now())),
120
+ });
121
+ if (controller.signal.aborted) throw errAuth('Browser login cancelled.');
122
+ return verified;
123
+ } catch (error) {
124
+ if (controller.signal.aborted) throw errAuth('Browser login cancelled.');
125
+ if (!error.browserLoginPending) throw error;
126
+ lastCheck = error.message;
127
+ }
128
+ }
129
+ try { await delay(Math.min(500, Math.max(1, deadline - Date.now())), undefined, { signal: controller.signal }); } catch { throw errAuth('Browser login cancelled.'); }
130
+ }
131
+ throw errAuth(`Timed out waiting for browser login. ${lastCheck || 'Run browser login again when ready.'}`);
132
+ } finally {
133
+ process.removeListener('SIGINT', cancel);
134
+ signal?.removeEventListener('abort', cancel);
135
+ if (child && !exited) {
136
+ // Ask Chromium to exit normally so renderer processes release profile
137
+ // files. Windows child.kill() is forceful, not POSIX graceful SIGTERM.
138
+ if (browserUrl) {
139
+ try { await closeLoginBrowser(browserUrl); } catch { /* Fall back to the owned process handle. */ }
140
+ await Promise.race([closed, delay(2000, undefined, { ref: false })]);
141
+ }
142
+ if (!exited) child.kill();
143
+ await Promise.race([closed, delay(2000, undefined, { ref: false })]);
144
+ if (!exited) { child.kill('SIGKILL'); await Promise.race([closed, delay(1000, undefined, { ref: false })]); }
145
+ }
146
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
147
+ }
148
+ }
@@ -14,6 +14,8 @@
14
14
  // // Skip the once-per-day npm-version / skill freshness checks and the
15
15
  // // interactive context header.
16
16
  // skipDailyChecks: true,
17
+ // // Suppress the current instance header for profile management commands.
18
+ // skipContextHeader: true,
17
19
  // }
18
20
  //
19
21
  // cli.js derives every skip-list and the mutation guard's path list from
@@ -91,6 +93,14 @@ export function dailyCheckSkipCommands(caps = collectCapabilities()) {
91
93
  return out;
92
94
  }
93
95
 
96
+ export function contextHeaderSkipCommands(caps = collectCapabilities()) {
97
+ const out = dailyCheckSkipCommands(caps);
98
+ for (const [name, capability] of caps) {
99
+ if (capability.skipContextHeader) out.add(name);
100
+ }
101
+ return out;
102
+ }
103
+
94
104
  /**
95
105
  * Derive the mutation command-path list (argv._ token arrays) from the
96
106
  * capability registry. Each entry is a prefix pattern: an argv._ path that
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
12
  import { skippedUpdatesCmd } from './commands/skipped-updates.js';
13
- import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
13
+ import { noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
14
14
  import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
15
15
  import { formatCommandList } from './helpers.js';
16
16
 
@@ -320,7 +320,7 @@ export function buildCLI() {
320
320
  }
321
321
 
322
322
  // Print context header for interactive terminals
323
- if (!dailySkips.has(cmd)) {
323
+ if (!contextHeaderSkipCommands().has(cmd)) {
324
324
  await argv.app.printContextHeader(argv);
325
325
  }
326
326
  })