sitevision-cli 1.0.0-beta.6 → 1.0.0-beta.8
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/dist/commands/deploy.d.ts +1 -2
- package/dist/commands/deploy.js +73 -77
- package/dist/components/AuthLoginScreen.d.ts +21 -0
- package/dist/components/AuthLoginScreen.js +87 -0
- package/dist/utils/oauth2-auth.d.ts +17 -10
- package/dist/utils/oauth2-auth.js +90 -78
- package/dist/utils/session-cookie-auth.d.ts +31 -6
- package/dist/utils/session-cookie-auth.js +74 -47
- package/package.json +1 -1
|
@@ -8,10 +8,9 @@ interface DeployScreenProps {
|
|
|
8
8
|
force: boolean;
|
|
9
9
|
production: boolean;
|
|
10
10
|
activate: boolean;
|
|
11
|
-
signingPassword?: string;
|
|
12
11
|
onBack?: () => void;
|
|
13
12
|
onRetryCredentials?: () => void;
|
|
14
13
|
}
|
|
15
|
-
export declare function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate,
|
|
14
|
+
export declare function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate, onBack, onRetryCredentials, }: DeployScreenProps): React.JSX.Element;
|
|
16
15
|
export declare const deployCommand: Command;
|
|
17
16
|
export {};
|
package/dist/commands/deploy.js
CHANGED
|
@@ -8,12 +8,21 @@ import { zipExists } from '../utils/zip.js';
|
|
|
8
8
|
import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
|
|
9
9
|
import { setDeployPassword, deleteSessionCookie } from '../utils/keychain.js';
|
|
10
10
|
import { resolveOAuth2AccessToken } from '../utils/oauth2-auth.js';
|
|
11
|
-
import {
|
|
12
|
-
export function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate,
|
|
11
|
+
import { AuthLoginScreen } from '../components/AuthLoginScreen.js';
|
|
12
|
+
export function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate, onBack, onRetryCredentials, }) {
|
|
13
13
|
const [state, setState] = React.useState({
|
|
14
14
|
status: 'deploying',
|
|
15
15
|
message: production ? 'Deploying to production...' : 'Deploying to dev...',
|
|
16
16
|
});
|
|
17
|
+
// 'init' resolves cached credentials, 'login' shows the Ink login screen,
|
|
18
|
+
// 'deploy' runs the upload. Token/cookie login now happens here, so both the
|
|
19
|
+
// TUI and the standalone command reach it.
|
|
20
|
+
const [phase, setPhase] = React.useState('init');
|
|
21
|
+
const [credential, setCredential] = React.useState({
|
|
22
|
+
accessToken: devProperties.accessToken,
|
|
23
|
+
sessionCookie: devProperties.sessionCookie,
|
|
24
|
+
});
|
|
25
|
+
const deployStartedRef = React.useRef(false);
|
|
17
26
|
useInput((input, key) => {
|
|
18
27
|
if (state.status !== 'deploying') {
|
|
19
28
|
if (onBack && (key.escape || input === 'q')) {
|
|
@@ -24,43 +33,48 @@ export function DeployScreen({ projectRoot, manifest, devProperties, force, prod
|
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
});
|
|
36
|
+
// Decide once whether we can deploy straight away or must log in first.
|
|
27
37
|
React.useEffect(() => {
|
|
38
|
+
const authMethod = devProperties.authMethod ?? 'basic';
|
|
39
|
+
if (authMethod === 'basic' || devProperties.sessionCookie) {
|
|
40
|
+
setPhase('deploy');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (authMethod === 'cookie') {
|
|
44
|
+
// env/keychain cookie is already loaded in devProperties; none here.
|
|
45
|
+
setPhase('login');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (authMethod === 'oauth2') {
|
|
49
|
+
if (devProperties.accessToken) {
|
|
50
|
+
setPhase('deploy');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
void (async () => {
|
|
54
|
+
const token = await resolveOAuth2AccessToken(devProperties);
|
|
55
|
+
if (token) {
|
|
56
|
+
setCredential({ accessToken: token });
|
|
57
|
+
setPhase('deploy');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
setPhase('login');
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setPhase('deploy');
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, []);
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (phase !== 'deploy' || deployStartedRef.current)
|
|
70
|
+
return;
|
|
71
|
+
deployStartedRef.current = true;
|
|
28
72
|
async function runDeploy() {
|
|
29
73
|
try {
|
|
30
74
|
const appType = getAppType(manifest);
|
|
31
|
-
// Resolve an OAuth2 token silently (env/keychain refresh). A fresh
|
|
32
|
-
// browser login happens only in the direct `svc deploy` command, which
|
|
33
|
-
// sets accessToken before rendering — the Ink menu can't own the
|
|
34
|
-
// terminal for a login, so it resolves refresh-only here.
|
|
35
75
|
const authMethod = devProperties.authMethod ?? 'basic';
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
accessToken =
|
|
39
|
-
(await resolveOAuth2AccessToken(devProperties, {
|
|
40
|
-
interactive: false,
|
|
41
|
-
})) ?? undefined;
|
|
42
|
-
if (!accessToken) {
|
|
43
|
-
setState({
|
|
44
|
-
status: 'error',
|
|
45
|
-
error: 'No OAuth2 access token. Run `svc deploy` from a terminal to log in, or set SITEVISION_ACCESS_TOKEN.',
|
|
46
|
-
});
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
let sessionCookie = devProperties.sessionCookie;
|
|
51
|
-
if (authMethod === 'cookie' && !sessionCookie) {
|
|
52
|
-
sessionCookie =
|
|
53
|
-
(await resolveSessionCookie(devProperties, {
|
|
54
|
-
interactive: false,
|
|
55
|
-
})) ?? undefined;
|
|
56
|
-
if (!sessionCookie) {
|
|
57
|
-
setState({
|
|
58
|
-
status: 'error',
|
|
59
|
-
error: 'No session cookie. Run `svc deploy` from a terminal to log in, or set SITEVISION_SESSION_COOKIE.',
|
|
60
|
-
});
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
76
|
+
// Credential resolved in the init effect / login screen.
|
|
77
|
+
const { accessToken, sessionCookie } = credential;
|
|
64
78
|
// A stale session fails without a clean 401 — clear the stored cookie
|
|
65
79
|
// so the next run re-authenticates. Skip when SITEVISION_SESSION_COOKIE
|
|
66
80
|
// is set: detection re-reads it first, so clearing would just replay
|
|
@@ -154,15 +168,25 @@ export function DeployScreen({ projectRoot, manifest, devProperties, force, prod
|
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
170
|
runDeploy();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
devProperties
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
172
|
+
}, [phase]);
|
|
173
|
+
if (phase === 'login' && state.status !== 'error') {
|
|
174
|
+
return (_jsx(AuthLoginScreen, { method: (devProperties.authMethod ?? 'basic') === 'cookie'
|
|
175
|
+
? 'cookie'
|
|
176
|
+
: 'oauth2', devProperties: devProperties, onComplete: cred => {
|
|
177
|
+
setCredential(cred);
|
|
178
|
+
setPhase('deploy');
|
|
179
|
+
}, onError: message => {
|
|
180
|
+
setState({ status: 'error', error: message });
|
|
181
|
+
}, onCancel: () => {
|
|
182
|
+
if (onBack) {
|
|
183
|
+
onBack();
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
setState({ status: 'error', error: 'Login cancelled.' });
|
|
187
|
+
}
|
|
188
|
+
} }));
|
|
189
|
+
}
|
|
166
190
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: state.status === 'deploying' ? 'running' : state.status, label: state.status === 'deploying'
|
|
167
191
|
? 'Deploying'
|
|
168
192
|
: state.status === 'success'
|
|
@@ -200,32 +224,11 @@ export const deployCommand = {
|
|
|
200
224
|
console.log('Create a .dev_properties.json file with domain, siteName, addonName, and username, then run setup.\n');
|
|
201
225
|
return;
|
|
202
226
|
}
|
|
203
|
-
//
|
|
204
|
-
//
|
|
227
|
+
// Basic auth prompts for a password here; OAuth2 and cookie resolve or log
|
|
228
|
+
// in inside DeployScreen (Ink-native), so both the TUI and this command
|
|
229
|
+
// share one login path. env/--flag token/cookie are already loaded.
|
|
205
230
|
const authMethod = project.devProperties.authMethod ?? 'basic';
|
|
206
|
-
if (project.devProperties.
|
|
207
|
-
project.devProperties.sessionCookie) {
|
|
208
|
-
// Nothing to acquire — a bearer token or session cookie is in hand.
|
|
209
|
-
}
|
|
210
|
-
else if (authMethod === 'oauth2') {
|
|
211
|
-
const token = await resolveOAuth2AccessToken(project.devProperties);
|
|
212
|
-
if (!token) {
|
|
213
|
-
console.log('\n\x1b[31mError: No OAuth2 access token available.\x1b[0m');
|
|
214
|
-
console.log('Set SITEVISION_ACCESS_TOKEN, pass --token, or configure the oauth2 endpoints in .dev_properties.json to log in.\n');
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
project.devProperties.accessToken = token;
|
|
218
|
-
}
|
|
219
|
-
else if (authMethod === 'cookie') {
|
|
220
|
-
const cookie = await resolveSessionCookie(project.devProperties);
|
|
221
|
-
if (!cookie) {
|
|
222
|
-
console.log('\n\x1b[31mError: No session cookie available.\x1b[0m');
|
|
223
|
-
console.log('Log in when the browser opens, or set SITEVISION_SESSION_COOKIE / pass --cookie.\n');
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
project.devProperties.sessionCookie = cookie;
|
|
227
|
-
}
|
|
228
|
-
else if (!project.devProperties.password) {
|
|
231
|
+
if (authMethod === 'basic' && !project.devProperties.password) {
|
|
229
232
|
const { domain, username } = project.devProperties;
|
|
230
233
|
console.log('');
|
|
231
234
|
const password = await promptPassword(`Deploy password for ${username}@${domain}: `);
|
|
@@ -242,15 +245,8 @@ export const deployCommand = {
|
|
|
242
245
|
const production = Boolean(flags['production']);
|
|
243
246
|
const force = Boolean(flags['force']);
|
|
244
247
|
const activate = Boolean(flags['activate']);
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
if (production &&
|
|
248
|
-
project.hasSigningProperties &&
|
|
249
|
-
project.devProperties.signingUsername) {
|
|
250
|
-
// We already have a signed zip, no need to prompt for password here
|
|
251
|
-
// The sign command should have been run separately
|
|
252
|
-
}
|
|
253
|
-
const { waitUntilExit } = render(_jsx(DeployScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: project.devProperties, force: force, production: production, activate: activate, signingPassword: signingPassword }));
|
|
248
|
+
// Production deploys use the already-signed zip; `sign` is run separately.
|
|
249
|
+
const { waitUntilExit } = render(_jsx(DeployScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: project.devProperties, force: force, production: production, activate: activate }));
|
|
254
250
|
await waitUntilExit();
|
|
255
251
|
},
|
|
256
252
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { DevProperties } from '../types/index.js';
|
|
3
|
+
interface Credential {
|
|
4
|
+
accessToken?: string;
|
|
5
|
+
sessionCookie?: string;
|
|
6
|
+
}
|
|
7
|
+
interface Props {
|
|
8
|
+
method: 'oauth2' | 'cookie';
|
|
9
|
+
devProperties: DevProperties;
|
|
10
|
+
onComplete: (credential: Credential) => void;
|
|
11
|
+
onError: (message: string) => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ink-native interactive login for OAuth2 and session-cookie auth. Drives the
|
|
16
|
+
* browser and (for cookie) the "press Enter to capture" handoff through Ink's
|
|
17
|
+
* own input, so it works inside the TUI as well as the standalone command —
|
|
18
|
+
* neither needs to own raw stdin the way the old console prompt did.
|
|
19
|
+
*/
|
|
20
|
+
export declare function AuthLoginScreen({ method, devProperties, onComplete, onError, onCancel, }: Props): React.JSX.Element;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { beginOAuth2Login, openBrowser } from '../utils/oauth2-auth.js';
|
|
6
|
+
import { beginCookieLogin, } from '../utils/session-cookie-auth.js';
|
|
7
|
+
/**
|
|
8
|
+
* Ink-native interactive login for OAuth2 and session-cookie auth. Drives the
|
|
9
|
+
* browser and (for cookie) the "press Enter to capture" handoff through Ink's
|
|
10
|
+
* own input, so it works inside the TUI as well as the standalone command —
|
|
11
|
+
* neither needs to own raw stdin the way the old console prompt did.
|
|
12
|
+
*/
|
|
13
|
+
export function AuthLoginScreen({ method, devProperties, onComplete, onError, onCancel, }) {
|
|
14
|
+
const [phase, setPhase] = React.useState('starting');
|
|
15
|
+
const [authUrl, setAuthUrl] = React.useState('');
|
|
16
|
+
const [note, setNote] = React.useState('');
|
|
17
|
+
const cookieRef = React.useRef(null);
|
|
18
|
+
const cancelOAuthRef = React.useRef(null);
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
void (async () => {
|
|
21
|
+
if (method === 'oauth2') {
|
|
22
|
+
const session = beginOAuth2Login(devProperties);
|
|
23
|
+
if (!session) {
|
|
24
|
+
onError('OAuth2 is not fully configured (authorization/token endpoint or client ID missing).');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
cancelOAuthRef.current = session.cancel;
|
|
28
|
+
setAuthUrl(session.authUrl);
|
|
29
|
+
openBrowser(session.authUrl);
|
|
30
|
+
setPhase('awaiting');
|
|
31
|
+
const token = await session.complete();
|
|
32
|
+
cancelOAuthRef.current = null;
|
|
33
|
+
if (token) {
|
|
34
|
+
onComplete({ accessToken: token });
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
onError('OAuth2 login failed, timed out, or was rejected.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const session = await beginCookieLogin(devProperties);
|
|
42
|
+
if (!session) {
|
|
43
|
+
onError('Could not open a login browser (is Chrome installed?). Set SITEVISION_SESSION_COOKIE or pass --cookie with a cookie copied from your browser.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
cookieRef.current = session;
|
|
47
|
+
setPhase('awaiting');
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
// Release resources if the screen unmounts before completing: close the
|
|
51
|
+
// browser (cookie) and the loopback server (oauth2, frees the port).
|
|
52
|
+
return () => {
|
|
53
|
+
void cookieRef.current?.close();
|
|
54
|
+
cookieRef.current = null;
|
|
55
|
+
cancelOAuthRef.current?.();
|
|
56
|
+
cancelOAuthRef.current = null;
|
|
57
|
+
};
|
|
58
|
+
// Run once: the parent mounts this fresh when a login is needed.
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, []);
|
|
61
|
+
useInput((_input, key) => {
|
|
62
|
+
if (key.escape) {
|
|
63
|
+
onCancel();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (method === 'cookie' && phase === 'awaiting' && key.return) {
|
|
67
|
+
const session = cookieRef.current;
|
|
68
|
+
if (!session)
|
|
69
|
+
return;
|
|
70
|
+
setPhase('capturing');
|
|
71
|
+
void (async () => {
|
|
72
|
+
const result = await session.capture();
|
|
73
|
+
if (result.cookie) {
|
|
74
|
+
cookieRef.current = null;
|
|
75
|
+
await session.close();
|
|
76
|
+
onComplete({ sessionCookie: result.cookie });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Keep the browser open so the user can navigate and retry.
|
|
80
|
+
setNote(result.error ?? 'No session cookie found.');
|
|
81
|
+
setPhase('awaiting');
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: method === 'oauth2' ? 'OAuth2 login' : 'Session cookie login' }) }), method === 'oauth2' ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Waiting for you to finish login in the browser\u2026" })] }), authUrl && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "If the browser didn't open, visit:" }), _jsx(Text, { children: authUrl })] }))] })) : (_jsxs(Box, { flexDirection: "column", children: [phase === 'starting' && (_jsxs(Box, { children: [_jsx(Text, { color: "green", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Opening browser\u2026" })] })), phase === 'awaiting' && (_jsx(Text, { children: "Log in in the opened browser, then press Enter to capture the session." })), phase === 'capturing' && (_jsxs(Box, { children: [_jsx(Text, { color: "green", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Capturing session\u2026" })] })), note && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: note }) }))] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to cancel." }) })] }));
|
|
87
|
+
}
|
|
@@ -6,15 +6,22 @@ export declare function createPkcePair(): {
|
|
|
6
6
|
verifier: string;
|
|
7
7
|
challenge: string;
|
|
8
8
|
};
|
|
9
|
+
export declare function openBrowser(url: string): void;
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* refresh token is stored in the keychain for next time. Pass
|
|
15
|
-
* `interactive: false` from contexts that can't own the terminal (the Ink
|
|
16
|
-
* menu) to get refresh-only resolution with no browser.
|
|
11
|
+
* Start an interactive OAuth2 login. Returns the authorize URL to open and a
|
|
12
|
+
* `complete()` that awaits the loopback redirect, exchanges the code, stores the
|
|
13
|
+
* refresh token, and resolves the access token. UI-agnostic, so an Ink screen
|
|
14
|
+
* can drive it without owning the terminal.
|
|
17
15
|
*/
|
|
18
|
-
export declare function
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
export declare function beginOAuth2Login(dev: DevProperties): {
|
|
17
|
+
authUrl: string;
|
|
18
|
+
complete: () => Promise<string | null>;
|
|
19
|
+
cancel: () => void;
|
|
20
|
+
} | null;
|
|
21
|
+
/**
|
|
22
|
+
* Silently resolve an access token by refreshing the keychain refresh token.
|
|
23
|
+
* Returns null when there's no refresh token or it's expired/revoked (in which
|
|
24
|
+
* case the stale token is dropped). Interactive login lives in `beginOAuth2Login`,
|
|
25
|
+
* driven by the Ink login screen — the access token is never persisted.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveOAuth2AccessToken(dev: DevProperties): Promise<string | null>;
|
|
@@ -44,7 +44,7 @@ async function postToken(config, params, secret) {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
function openBrowser(url) {
|
|
47
|
+
export function openBrowser(url) {
|
|
48
48
|
const isWin = process.platform === 'win32';
|
|
49
49
|
const cmd = process.platform === 'darwin' ? 'open' : isWin ? 'cmd' : 'xdg-open';
|
|
50
50
|
const args = isWin ? ['/c', 'start', '', url] : [url];
|
|
@@ -55,106 +55,118 @@ function openBrowser(url) {
|
|
|
55
55
|
// Fall back to the printed URL.
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Serve the loopback redirect once. Returns the awaited code and a `close()`
|
|
60
|
+
* that shuts the server down (freeing the port) if the login is cancelled — so
|
|
61
|
+
* a retry doesn't hit an EADDRINUSE on the fixed redirect port.
|
|
62
|
+
*/
|
|
63
|
+
function startLoopback(port, state) {
|
|
64
|
+
let finish;
|
|
65
|
+
let settled = false;
|
|
66
|
+
const code = new Promise(resolve => {
|
|
67
|
+
finish = (value) => {
|
|
63
68
|
if (settled)
|
|
64
69
|
return;
|
|
65
70
|
settled = true;
|
|
66
71
|
clearTimeout(timer);
|
|
67
72
|
server.close();
|
|
68
|
-
resolve(
|
|
73
|
+
resolve(value);
|
|
69
74
|
};
|
|
70
|
-
const server = http.createServer((req, res) => {
|
|
71
|
-
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
72
|
-
if (url.pathname !== '/callback') {
|
|
73
|
-
res.writeHead(404).end();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const ok = url.searchParams.get('state') === state;
|
|
77
|
-
const code = url.searchParams.get('code');
|
|
78
|
-
const message = ok && code
|
|
79
|
-
? 'Login complete. You can close this window and return to the terminal.'
|
|
80
|
-
: 'Login failed. Check the terminal.';
|
|
81
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
82
|
-
res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
|
|
83
|
-
finish(ok ? code : null);
|
|
84
|
-
});
|
|
85
|
-
const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
|
|
86
|
-
server.on('error', () => finish(null));
|
|
87
|
-
server.listen(port, '127.0.0.1');
|
|
88
75
|
});
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
78
|
+
if (url.pathname !== '/callback') {
|
|
79
|
+
res.writeHead(404).end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const ok = url.searchParams.get('state') === state;
|
|
83
|
+
const authCode = url.searchParams.get('code');
|
|
84
|
+
const message = ok && authCode
|
|
85
|
+
? 'Login complete. You can close this window and return to the terminal.'
|
|
86
|
+
: 'Login failed. Check the terminal.';
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
88
|
+
res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
|
|
89
|
+
finish(ok ? authCode : null);
|
|
90
|
+
});
|
|
91
|
+
const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
|
|
92
|
+
server.on('error', () => finish(null));
|
|
93
|
+
server.listen(port, '127.0.0.1');
|
|
94
|
+
return { code, close: () => finish(null) };
|
|
89
95
|
}
|
|
90
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Start an interactive OAuth2 login. Returns the authorize URL to open and a
|
|
98
|
+
* `complete()` that awaits the loopback redirect, exchanges the code, stores the
|
|
99
|
+
* refresh token, and resolves the access token. UI-agnostic, so an Ink screen
|
|
100
|
+
* can drive it without owning the terminal.
|
|
101
|
+
*/
|
|
102
|
+
export function beginOAuth2Login(dev) {
|
|
103
|
+
const config = dev.oauth2;
|
|
104
|
+
if (!hasOAuth2Config(config))
|
|
105
|
+
return null;
|
|
106
|
+
const { domain } = dev;
|
|
107
|
+
const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
|
|
91
108
|
const port = config.redirectPort ?? DEFAULT_REDIRECT_PORT;
|
|
92
109
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
93
110
|
const { verifier, challenge } = createPkcePair();
|
|
94
111
|
const state = base64url(crypto.randomBytes(16));
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const url = new URL(config.authorizationEndpoint);
|
|
113
|
+
url.searchParams.set('response_type', 'code');
|
|
114
|
+
url.searchParams.set('client_id', config.clientId);
|
|
115
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
116
|
+
url.searchParams.set('state', state);
|
|
117
|
+
url.searchParams.set('code_challenge', challenge);
|
|
118
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
102
119
|
if (config.scopes?.length) {
|
|
103
|
-
|
|
120
|
+
url.searchParams.set('scope', config.scopes.join(' '));
|
|
104
121
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
const loopback = startLoopback(port, state);
|
|
123
|
+
const complete = async () => {
|
|
124
|
+
const code = await loopback.code;
|
|
125
|
+
if (!code)
|
|
126
|
+
return null;
|
|
127
|
+
const tokens = await postToken(config, {
|
|
128
|
+
grant_type: 'authorization_code',
|
|
129
|
+
code,
|
|
130
|
+
redirect_uri: redirectUri,
|
|
131
|
+
client_id: config.clientId,
|
|
132
|
+
code_verifier: verifier,
|
|
133
|
+
}, secret);
|
|
134
|
+
if (!tokens?.access_token)
|
|
135
|
+
return null;
|
|
136
|
+
if (tokens.refresh_token) {
|
|
137
|
+
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
138
|
+
}
|
|
139
|
+
return tokens.access_token;
|
|
140
|
+
};
|
|
141
|
+
return { authUrl: url.href, complete, cancel: loopback.close };
|
|
118
142
|
}
|
|
119
143
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* refresh token is stored in the keychain for next time. Pass
|
|
125
|
-
* `interactive: false` from contexts that can't own the terminal (the Ink
|
|
126
|
-
* menu) to get refresh-only resolution with no browser.
|
|
144
|
+
* Silently resolve an access token by refreshing the keychain refresh token.
|
|
145
|
+
* Returns null when there's no refresh token or it's expired/revoked (in which
|
|
146
|
+
* case the stale token is dropped). Interactive login lives in `beginOAuth2Login`,
|
|
147
|
+
* driven by the Ink login screen — the access token is never persisted.
|
|
127
148
|
*/
|
|
128
|
-
export async function resolveOAuth2AccessToken(dev
|
|
129
|
-
const { interactive = true } = options;
|
|
149
|
+
export async function resolveOAuth2AccessToken(dev) {
|
|
130
150
|
const config = dev.oauth2;
|
|
131
151
|
if (!hasOAuth2Config(config))
|
|
132
152
|
return null;
|
|
133
153
|
const { domain } = dev;
|
|
134
154
|
const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
|
|
135
155
|
const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
|
|
136
|
-
if (storedRefresh)
|
|
137
|
-
const tokens = await postToken(config, {
|
|
138
|
-
grant_type: 'refresh_token',
|
|
139
|
-
refresh_token: storedRefresh,
|
|
140
|
-
client_id: config.clientId,
|
|
141
|
-
}, secret);
|
|
142
|
-
if (tokens?.access_token) {
|
|
143
|
-
if (tokens.refresh_token) {
|
|
144
|
-
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
145
|
-
}
|
|
146
|
-
return tokens.access_token;
|
|
147
|
-
}
|
|
148
|
-
// Stale/expired refresh token — drop it and log in fresh.
|
|
149
|
-
deleteOAuth2RefreshToken(domain, config.clientId);
|
|
150
|
-
}
|
|
151
|
-
if (!interactive || !process.stdin.isTTY)
|
|
152
|
-
return null;
|
|
153
|
-
const tokens = await interactiveLogin(config, secret);
|
|
154
|
-
if (!tokens?.access_token)
|
|
156
|
+
if (!storedRefresh)
|
|
155
157
|
return null;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
const tokens = await postToken(config, {
|
|
159
|
+
grant_type: 'refresh_token',
|
|
160
|
+
refresh_token: storedRefresh,
|
|
161
|
+
client_id: config.clientId,
|
|
162
|
+
}, secret);
|
|
163
|
+
if (tokens?.access_token) {
|
|
164
|
+
if (tokens.refresh_token) {
|
|
165
|
+
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
166
|
+
}
|
|
167
|
+
return tokens.access_token;
|
|
158
168
|
}
|
|
159
|
-
|
|
169
|
+
// Stale/expired refresh token — drop it so the next run logs in fresh.
|
|
170
|
+
deleteOAuth2RefreshToken(domain, config.clientId);
|
|
171
|
+
return null;
|
|
160
172
|
}
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import type { DevProperties } from '../types/index.js';
|
|
2
|
+
interface RawCookie {
|
|
3
|
+
name: string;
|
|
4
|
+
value: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CaptureResult {
|
|
8
|
+
cookie?: string;
|
|
9
|
+
note?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CookieLoginSession {
|
|
13
|
+
capture: () => Promise<CaptureResult>;
|
|
14
|
+
close: () => Promise<void>;
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
|
-
*
|
|
4
|
-
* then
|
|
5
|
-
*
|
|
17
|
+
* Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
|
|
18
|
+
* host), then return every cookie on that host as a `Cookie:` header. On miss,
|
|
19
|
+
* return diagnostics naming the domains actually seen. Pure — no keychain, no
|
|
20
|
+
* browser — so it's unit-testable.
|
|
6
21
|
*/
|
|
7
|
-
export declare function
|
|
8
|
-
|
|
9
|
-
|
|
22
|
+
export declare function selectSessionCookie(all: RawCookie[], siteDomain: string): CaptureResult;
|
|
23
|
+
/**
|
|
24
|
+
* Launch a real browser at the login URL for an interactive SAML/SSO login and
|
|
25
|
+
* return handles to capture the session and close the browser. UI-agnostic: the
|
|
26
|
+
* Ink login screen decides when to `capture()` (on the user's keypress) and
|
|
27
|
+
* `close()`. Returns null if the browser can't be launched.
|
|
28
|
+
*
|
|
29
|
+
* `capture()` reads the whole cookie jar via CDP (httponly and secure included),
|
|
30
|
+
* stores the session in the keychain on success, and otherwise returns
|
|
31
|
+
* diagnostics naming the cookie domains it actually saw.
|
|
32
|
+
*/
|
|
33
|
+
export declare function beginCookieLogin(dev: DevProperties): Promise<CookieLoginSession | null>;
|
|
34
|
+
export {};
|
|
@@ -1,72 +1,99 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { setSessionCookie } from './keychain.js';
|
|
2
|
+
function bareDomain(domain) {
|
|
3
|
+
return domain.replace(/^\./, '');
|
|
4
|
+
}
|
|
5
|
+
/** Related if either host is the other or a subdomain of it (both directions). */
|
|
6
|
+
function domainRelated(a, b) {
|
|
7
|
+
const x = bareDomain(a);
|
|
8
|
+
const y = bareDomain(b);
|
|
9
|
+
return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
|
|
13
|
+
* host), then return every cookie on that host as a `Cookie:` header. On miss,
|
|
14
|
+
* return diagnostics naming the domains actually seen. Pure — no keychain, no
|
|
15
|
+
* browser — so it's unit-testable.
|
|
16
|
+
*/
|
|
17
|
+
export function selectSessionCookie(all, siteDomain) {
|
|
18
|
+
const sessions = all.filter(c => c.name === 'JSESSIONID');
|
|
19
|
+
if (sessions.length === 0) {
|
|
20
|
+
const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
|
|
21
|
+
return {
|
|
22
|
+
error: `No JSESSIONID among ${all.length} cookies. Domains seen: ${domains.join(', ') || 'none'}. If these are only your IdP, open a Sitevision page in the browser, then press Enter again.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
|
|
26
|
+
const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
|
|
27
|
+
return {
|
|
28
|
+
cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '),
|
|
29
|
+
note: `Captured session on ${bareDomain(chosen.domain)} (${cookies.length} cookies).`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Read every cookie in the browser jar (httponly and secure included). */
|
|
33
|
+
async function readAllCookies(browser, page) {
|
|
34
|
+
// puppeteer >= 22 exposes the whole jar directly.
|
|
35
|
+
if (typeof browser.cookies === 'function') {
|
|
36
|
+
try {
|
|
37
|
+
return (await browser.cookies());
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fall through to CDP.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const client = await page.createCDPSession();
|
|
44
|
+
const { cookies } = await client.send('Network.getAllCookies');
|
|
45
|
+
return cookies;
|
|
7
46
|
}
|
|
8
47
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
48
|
+
* Launch a real browser at the login URL for an interactive SAML/SSO login and
|
|
49
|
+
* return handles to capture the session and close the browser. UI-agnostic: the
|
|
50
|
+
* Ink login screen decides when to `capture()` (on the user's keypress) and
|
|
51
|
+
* `close()`. Returns null if the browser can't be launched.
|
|
52
|
+
*
|
|
53
|
+
* `capture()` reads the whole cookie jar via CDP (httponly and secure included),
|
|
54
|
+
* stores the session in the keychain on success, and otherwise returns
|
|
55
|
+
* diagnostics naming the cookie domains it actually saw.
|
|
13
56
|
*/
|
|
14
|
-
async function
|
|
57
|
+
export async function beginCookieLogin(dev) {
|
|
58
|
+
const { domain, username } = dev;
|
|
59
|
+
if (!domain || !username)
|
|
60
|
+
return null;
|
|
15
61
|
let puppeteer;
|
|
16
62
|
try {
|
|
17
63
|
({ default: puppeteer } = await import('puppeteer-core'));
|
|
18
64
|
}
|
|
19
65
|
catch {
|
|
20
|
-
console.log('\x1b[31mpuppeteer-core is not installed. Run `npm i puppeteer-core`, or pass --cookie / set SITEVISION_SESSION_COOKIE.\x1b[0m');
|
|
21
66
|
return null;
|
|
22
67
|
}
|
|
23
68
|
let browser;
|
|
24
69
|
try {
|
|
25
70
|
browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
|
|
26
71
|
const page = await browser.newPage();
|
|
72
|
+
const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
|
|
27
73
|
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
|
|
28
74
|
// A SAML redirect may abort the initial navigation — that's fine.
|
|
29
75
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
76
|
+
const capture = async () => {
|
|
77
|
+
const all = await readAllCookies(browser, page);
|
|
78
|
+
const result = selectSessionCookie(all, domain);
|
|
79
|
+
if (result.cookie) {
|
|
80
|
+
setSessionCookie(domain, username, result.cookie);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
const close = async () => {
|
|
85
|
+
await browser.close().catch(() => {
|
|
86
|
+
// Best-effort close.
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
return { capture, close };
|
|
43
90
|
}
|
|
44
|
-
|
|
91
|
+
catch {
|
|
45
92
|
if (browser) {
|
|
46
93
|
await browser.close().catch(() => {
|
|
47
94
|
// Best-effort close.
|
|
48
95
|
});
|
|
49
96
|
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Return a usable session cookie, or null. Order: keychain (a prior capture),
|
|
54
|
-
* then (when `interactive`) a browser login. Pass `interactive: false` from the
|
|
55
|
-
* Ink menu, which can't own the terminal for the "press Enter" handoff.
|
|
56
|
-
*/
|
|
57
|
-
export async function resolveSessionCookie(dev, options = {}) {
|
|
58
|
-
const { interactive = true } = options;
|
|
59
|
-
const { domain, username } = dev;
|
|
60
|
-
if (!domain || !username)
|
|
61
|
-
return null;
|
|
62
|
-
const stored = getSessionCookie(domain, username);
|
|
63
|
-
if (stored)
|
|
64
|
-
return stored;
|
|
65
|
-
if (!interactive || !process.stdin.isTTY)
|
|
66
97
|
return null;
|
|
67
|
-
|
|
68
|
-
const cookie = await captureViaBrowser(loginUrl, domain);
|
|
69
|
-
if (cookie)
|
|
70
|
-
setSessionCookie(domain, username, cookie);
|
|
71
|
-
return cookie;
|
|
98
|
+
}
|
|
72
99
|
}
|