@jacebenson/jsn 4.3.9-dev.62 → 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 +1 -1
- package/src/auth.js +81 -38
- package/src/browser-auth.js +162 -0
- package/src/browser-launch.js +148 -0
- package/src/capabilities.js +10 -0
- package/src/cli.js +2 -2
- package/src/commands/auth.js +763 -650
- package/src/commands/flows.js +13 -6
- package/src/commands/setup.js +25 -20
- package/src/errors.js +8 -0
- package/src/flow-create.js +204 -15
- package/src/flow-designer-interactive.js +7 -2
- package/src/prompts.js +37 -0
package/package.json
CHANGED
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 '
|
|
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.
|
|
716
|
-
console.
|
|
717
|
-
console.
|
|
718
|
-
console.
|
|
719
|
-
console.
|
|
720
|
-
console.
|
|
721
|
-
console.
|
|
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.
|
|
724
|
-
console.
|
|
725
|
-
console.
|
|
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.
|
|
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.
|
|
755
|
-
console.
|
|
756
|
-
console.
|
|
757
|
-
console.
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
828
|
-
throw errAuth(`Could not test the browser session against ${instanceURL}. Nothing was saved. Retry
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/capabilities.js
CHANGED
|
@@ -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 (!
|
|
323
|
+
if (!contextHeaderSkipCommands().has(cmd)) {
|
|
324
324
|
await argv.app.printContextHeader(argv);
|
|
325
325
|
}
|
|
326
326
|
})
|