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.
@@ -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, signingPassword, onBack, onRetryCredentials, }: DeployScreenProps): React.JSX.Element;
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 {};
@@ -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 { resolveSessionCookie } from '../utils/session-cookie-auth.js';
12
- export function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate, signingPassword, onBack, onRetryCredentials, }) {
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
- let accessToken = devProperties.accessToken;
37
- if (authMethod === 'oauth2' && !accessToken) {
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
- projectRoot,
159
- manifest,
160
- devProperties,
161
- force,
162
- production,
163
- activate,
164
- signingPassword,
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
- // Resolve deploy credentials. A token/cookie from env/--flag (already
204
- // loaded in detectProject / cli) short-circuits everything.
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.accessToken ||
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
- // For production, we need the signed zip, which requires signing credentials
246
- let signingPassword;
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
- * Return a usable OAuth2 access token, or null if one can't be obtained.
11
- *
12
- * Order: silent refresh from the keychain refresh token, then (when
13
- * `interactive`) a browser login. The access token is never persisted; the
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 resolveOAuth2AccessToken(dev: DevProperties, options?: {
19
- interactive?: boolean;
20
- }): Promise<string | null>;
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
- /** Serve the loopback redirect once, resolving the authorization code. */
59
- function waitForCode(port, state) {
60
- return new Promise(resolve => {
61
- let settled = false;
62
- const finish = (code) => {
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(code);
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
- async function interactiveLogin(config, secret) {
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 authUrl = new URL(config.authorizationEndpoint);
96
- authUrl.searchParams.set('response_type', 'code');
97
- authUrl.searchParams.set('client_id', config.clientId);
98
- authUrl.searchParams.set('redirect_uri', redirectUri);
99
- authUrl.searchParams.set('state', state);
100
- authUrl.searchParams.set('code_challenge', challenge);
101
- authUrl.searchParams.set('code_challenge_method', 'S256');
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
- authUrl.searchParams.set('scope', config.scopes.join(' '));
120
+ url.searchParams.set('scope', config.scopes.join(' '));
104
121
  }
105
- const codePromise = waitForCode(port, state);
106
- openBrowser(authUrl.href);
107
- console.log(`\nOpening browser to log in. If it doesn't open, visit:\n${authUrl.href}\n`);
108
- const code = await codePromise;
109
- if (!code)
110
- return null;
111
- return postToken(config, {
112
- grant_type: 'authorization_code',
113
- code,
114
- redirect_uri: redirectUri,
115
- client_id: config.clientId,
116
- code_verifier: verifier,
117
- }, secret);
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
- * Return a usable OAuth2 access token, or null if one can't be obtained.
121
- *
122
- * Order: silent refresh from the keychain refresh token, then (when
123
- * `interactive`) a browser login. The access token is never persisted; the
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, options = {}) {
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
- if (tokens.refresh_token) {
157
- setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
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
- return tokens.access_token;
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
- * Return a usable session cookie, or null. Order: keychain (a prior capture),
4
- * then (when `interactive`) a browser login. Pass `interactive: false` from the
5
- * Ink menu, which can't own the terminal for the "press Enter" handoff.
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 resolveSessionCookie(dev: DevProperties, options?: {
8
- interactive?: boolean;
9
- }): Promise<string | null>;
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 { getSessionCookie, setSessionCookie } from './keychain.js';
2
- import { promptEnter } from './password-prompt.js';
3
- /** Match cookies set on the site host or any parent domain. */
4
- function domainMatches(cookieDomain, siteDomain) {
5
- const bare = cookieDomain.replace(/^\./, '');
6
- return siteDomain === bare || siteDomain.endsWith(`.${bare}`);
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
- * Open a real browser at the login URL, let the user complete SSO, then read
10
- * the session cookies (JSESSIONID and any siblings) via CDP which returns
11
- * httponly cookies that page JavaScript can't see. Returns a `Cookie:` header
12
- * value, or null if capture failed.
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 captureViaBrowser(loginUrl, siteDomain) {
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
- await promptEnter('\nLog in in the opened browser, then press Enter here to capture the session: ');
31
- const client = await page.createCDPSession();
32
- const { cookies } = await client.send('Network.getAllCookies');
33
- const wanted = cookies.filter(c => domainMatches(c.domain, siteDomain));
34
- if (wanted.every(c => c.name !== 'JSESSIONID')) {
35
- console.log('\x1b[31mNo JSESSIONID found for this site. Was the login completed?\x1b[0m');
36
- return null;
37
- }
38
- return wanted.map(c => `${c.name}=${c.value}`).join('; ');
39
- }
40
- catch (error) {
41
- console.log(`\x1b[31mBrowser login failed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
42
- return null;
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
- finally {
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
- const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
68
- const cookie = await captureViaBrowser(loginUrl, domain);
69
- if (cookie)
70
- setSessionCookie(domain, username, cookie);
71
- return cookie;
98
+ }
72
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"