sitevision-cli 1.0.0-beta.11 → 1.0.0-beta.13

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.
@@ -28,13 +28,13 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
28
28
  setAuthUrl(session.authUrl);
29
29
  openBrowser(session.authUrl);
30
30
  setPhase('awaiting');
31
- const token = await session.complete();
31
+ const { token, error } = await session.complete();
32
32
  cancelOAuthRef.current = null;
33
33
  if (token) {
34
34
  onComplete({ accessToken: token });
35
35
  }
36
36
  else {
37
- onError('OAuth2 login failed, timed out, or was rejected.');
37
+ onError(error ?? 'OAuth2 login failed.');
38
38
  }
39
39
  }
40
40
  else {
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { TextInput } from './TextInput.js';
5
5
  import { writeDevProperties } from '../utils/project-detection.js';
6
6
  import { setDeployPassword, deleteDeployPassword, setOAuth2ClientSecret, } from '../utils/keychain.js';
7
- import { DEFAULT_REDIRECT_PORT } from '../utils/oauth2-auth.js';
7
+ import { DEFAULT_REDIRECT_PORT, discoverOAuth2Config, } from '../utils/oauth2-auth.js';
8
8
  const LABELS = {
9
9
  domain: 'Domain',
10
10
  siteName: 'Site Name',
@@ -50,8 +50,39 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
50
50
  scopes: initialProperties?.oauth2?.scopes?.join(' ') ?? '',
51
51
  clientSecret: '',
52
52
  }));
53
+ const [discoveryNote, setDiscoveryNote] = useState('');
53
54
  const method = properties.authMethod ?? 'basic';
54
55
  const isOAuth = method === 'oauth2';
56
+ // When OAuth2 is chosen, auto-fill the endpoints from the site's OpenID
57
+ // configuration (unauthenticated) so the user doesn't type them in.
58
+ useEffect(() => {
59
+ if (method !== 'oauth2' || !properties.domain)
60
+ return;
61
+ if (oauth.authorizationEndpoint && oauth.tokenEndpoint)
62
+ return;
63
+ let cancelled = false;
64
+ setDiscoveryNote('Looking up OAuth2 endpoints…');
65
+ void discoverOAuth2Config(properties.domain, properties.useHTTPForDevDeploy).then(discovered => {
66
+ if (cancelled)
67
+ return;
68
+ if (discovered) {
69
+ setOauth(previous => ({
70
+ ...previous,
71
+ authorizationEndpoint: previous.authorizationEndpoint || discovered.authorizationEndpoint,
72
+ tokenEndpoint: previous.tokenEndpoint || discovered.tokenEndpoint,
73
+ scopes: previous.scopes || (discovered.scopesSupported?.join(' ') ?? ''),
74
+ }));
75
+ setDiscoveryNote('Endpoints auto-filled from the site OpenID config.');
76
+ }
77
+ else {
78
+ setDiscoveryNote('Could not auto-discover endpoints — enter them below.');
79
+ }
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [method, properties.domain]);
55
86
  const methodSteps = method === 'oauth2'
56
87
  ? [
57
88
  'oauthClientId',
@@ -177,7 +208,7 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
177
208
  return null;
178
209
  }
179
210
  };
180
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Setup Development Properties" }), _jsxs(Text, { children: [' ', "Step ", stepIndex + 1, " of ", steps.length, ":", ' ', currentStep ? LABELS[currentStep] : ''] })] }), isOAuth && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Whitelist this redirect URI on the OAuth2 client: http://127.0.0.1:", redirectPort, "/callback"] }) })), _jsx(Box, { marginBottom: 1, children: steps.map((s, i) => (_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: i <= stepIndex ? 'green' : 'gray', children: i < stepIndex ? '✓' : i === stepIndex ? '●' : '○' }) }, s))) }), _jsx(Box, { borderStyle: "single", borderColor: "gray", padding: 1, children: renderInput() })] }));
211
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Setup Development Properties" }), _jsxs(Text, { children: [' ', "Step ", stepIndex + 1, " of ", steps.length, ":", ' ', currentStep ? LABELS[currentStep] : ''] })] }), isOAuth && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Whitelist this redirect URI on the OAuth2 client: http://127.0.0.1:", redirectPort, "/callback"] }), discoveryNote && _jsx(Text, { color: "yellow", children: discoveryNote })] })), _jsx(Box, { marginBottom: 1, children: steps.map((s, i) => (_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: i <= stepIndex ? 'green' : 'gray', children: i < stepIndex ? '✓' : i === stepIndex ? '●' : '○' }) }, s))) }), _jsx(Box, { borderStyle: "single", borderColor: "gray", padding: 1, children: renderInput() })] }));
181
212
  }
182
213
  function MethodSelect({ defaultValue, onSubmit, }) {
183
214
  const [index, setIndex] = useState(() => Math.max(0, AUTH_METHODS.findIndex(m => m.value === defaultValue)));
@@ -6,7 +6,39 @@ export declare function createPkcePair(): {
6
6
  verifier: string;
7
7
  challenge: string;
8
8
  };
9
+ export interface DiscoveredOAuth2 {
10
+ authorizationEndpoint: string;
11
+ tokenEndpoint: string;
12
+ scopesSupported?: string[];
13
+ }
14
+ /**
15
+ * Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
16
+ * authorization/token endpoints. Returns null if it isn't published (provider
17
+ * not enabled) or the response isn't a valid config, so callers fall back to
18
+ * manual entry.
19
+ */
20
+ export declare function discoverOAuth2Config(domain: string, useHTTP?: boolean): Promise<DiscoveredOAuth2 | null>;
9
21
  export declare function openBrowser(url: string): void;
22
+ /**
23
+ * Serve the loopback redirect once. Returns the awaited code and a `close()`
24
+ * that shuts the server down (freeing the port) if the login is cancelled — so
25
+ * a retry doesn't hit an EADDRINUSE on the fixed redirect port.
26
+ */
27
+ interface LoopbackResult {
28
+ code?: string;
29
+ error?: string;
30
+ }
31
+ /**
32
+ * Turn a redirect's query params into a result, prioritizing the provider's own
33
+ * error (the most useful reason) over a generic "no code". Exported for testing.
34
+ */
35
+ export declare function classifyRedirect(params: {
36
+ expectedState: string;
37
+ state: string | null;
38
+ error: string | null;
39
+ errorDescription: string | null;
40
+ code: string | null;
41
+ }): LoopbackResult;
10
42
  /**
11
43
  * Start an interactive OAuth2 login. Returns the authorize URL to open and a
12
44
  * `complete()` that awaits the loopback redirect, exchanges the code, stores the
@@ -15,7 +47,10 @@ export declare function openBrowser(url: string): void;
15
47
  */
16
48
  export declare function beginOAuth2Login(dev: DevProperties): {
17
49
  authUrl: string;
18
- complete: () => Promise<string | null>;
50
+ complete: () => Promise<{
51
+ token?: string;
52
+ error?: string;
53
+ }>;
19
54
  cancel: () => void;
20
55
  } | null;
21
56
  /**
@@ -25,3 +60,4 @@ export declare function beginOAuth2Login(dev: DevProperties): {
25
60
  * driven by the Ink login screen — the access token is never persisted.
26
61
  */
27
62
  export declare function resolveOAuth2AccessToken(dev: DevProperties): Promise<string | null>;
63
+ export {};
@@ -1,7 +1,7 @@
1
1
  import http from 'http';
2
2
  import crypto from 'crypto';
3
3
  import { spawn } from 'child_process';
4
- import { makeRequest } from './sitevision-api.js';
4
+ import { makeRequest, summarizeErrorBody } from './sitevision-api.js';
5
5
  import { getOAuth2RefreshToken, setOAuth2RefreshToken, deleteOAuth2RefreshToken, getOAuth2ClientSecret, } from './keychain.js';
6
6
  /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
7
7
  export const DEFAULT_REDIRECT_PORT = 8137;
@@ -36,9 +36,45 @@ async function postToken(config, params, secret) {
36
36
  // client_secret_basic when confidential; public+PKCE clients omit it.
37
37
  auth: secret ? { username: config.clientId, password: secret } : undefined,
38
38
  });
39
+ if (response.statusCode !== 200) {
40
+ return {
41
+ error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
42
+ };
43
+ }
44
+ return { tokens: JSON.parse(response.body.toString()) };
45
+ }
46
+ catch (error) {
47
+ return {
48
+ error: `Token request failed: ${error instanceof Error ? error.message : String(error)}`,
49
+ };
50
+ }
51
+ }
52
+ /** OpenID configuration path (published at the issuer root once the provider is saved). */
53
+ const DISCOVERY_PATH = '/.well-known/openid-configuration';
54
+ /**
55
+ * Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
56
+ * authorization/token endpoints. Returns null if it isn't published (provider
57
+ * not enabled) or the response isn't a valid config, so callers fall back to
58
+ * manual entry.
59
+ */
60
+ export async function discoverOAuth2Config(domain, useHTTP = false) {
61
+ if (!domain)
62
+ return null;
63
+ const protocol = useHTTP ? 'http' : 'https';
64
+ try {
65
+ const response = await makeRequest(`${protocol}://${domain}${DISCOVERY_PATH}`, { method: 'GET' });
39
66
  if (response.statusCode !== 200)
40
67
  return null;
41
- return JSON.parse(response.body.toString());
68
+ const doc = JSON.parse(response.body.toString());
69
+ if (!doc.authorization_endpoint || !doc.token_endpoint)
70
+ return null;
71
+ return {
72
+ authorizationEndpoint: doc.authorization_endpoint,
73
+ tokenEndpoint: doc.token_endpoint,
74
+ scopesSupported: Array.isArray(doc.scopes_supported)
75
+ ? doc.scopes_supported
76
+ : undefined,
77
+ };
42
78
  }
43
79
  catch {
44
80
  return null;
@@ -56,14 +92,37 @@ export function openBrowser(url) {
56
92
  }
57
93
  }
58
94
  /**
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.
95
+ * Turn a redirect's query params into a result, prioritizing the provider's own
96
+ * error (the most useful reason) over a generic "no code". Exported for testing.
62
97
  */
98
+ export function classifyRedirect(params) {
99
+ if (params.error) {
100
+ return {
101
+ error: `The OAuth2 provider rejected the login: ${params.errorDescription
102
+ ? `${params.error} — ${params.errorDescription}`
103
+ : params.error}`,
104
+ };
105
+ }
106
+ if (params.state !== params.expectedState) {
107
+ return {
108
+ error: 'State mismatch — the login response did not match this request (a stale browser tab, or the wrong window).',
109
+ };
110
+ }
111
+ if (params.code) {
112
+ return { code: params.code };
113
+ }
114
+ return { error: 'No authorization code was returned by the provider.' };
115
+ }
116
+ function escapeHtml(text) {
117
+ return text
118
+ .replaceAll('&', '&amp;')
119
+ .replaceAll('<', '&lt;')
120
+ .replaceAll('>', '&gt;');
121
+ }
63
122
  function startLoopback(port, state) {
64
123
  let finish;
65
124
  let settled = false;
66
- const code = new Promise(resolve => {
125
+ const result = new Promise(resolve => {
67
126
  finish = (value) => {
68
127
  if (settled)
69
128
  return;
@@ -79,19 +138,29 @@ function startLoopback(port, state) {
79
138
  res.writeHead(404).end();
80
139
  return;
81
140
  }
82
- const ok = url.searchParams.get('state') === state;
83
- const authCode = url.searchParams.get('code');
84
- const message = ok && authCode
141
+ const outcome = classifyRedirect({
142
+ expectedState: state,
143
+ state: url.searchParams.get('state'),
144
+ error: url.searchParams.get('error'),
145
+ errorDescription: url.searchParams.get('error_description'),
146
+ code: url.searchParams.get('code'),
147
+ });
148
+ const message = outcome.code
85
149
  ? 'Login complete. You can close this window and return to the terminal.'
86
- : 'Login failed. Check the terminal.';
150
+ : `Login failed: ${outcome.error}`;
87
151
  res.writeHead(200, { 'Content-Type': 'text/html' });
88
- res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
89
- finish(ok ? authCode : null);
152
+ res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
153
+ finish(outcome);
90
154
  });
91
- const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
92
- server.on('error', () => finish(null));
155
+ const timer = setTimeout(() => finish({ error: 'Timed out waiting for the login to complete.' }), LOGIN_TIMEOUT_MS);
156
+ server.on('error', error => finish({
157
+ error: `Local login server error: ${error instanceof Error ? error.message : String(error)}`,
158
+ }));
93
159
  server.listen(port, '127.0.0.1');
94
- return { code, close: () => finish(null) };
160
+ return {
161
+ result,
162
+ close: (reason) => finish({ error: reason ?? 'Login cancelled.' }),
163
+ };
95
164
  }
96
165
  /**
97
166
  * Start an interactive OAuth2 login. Returns the authorize URL to open and a
@@ -121,24 +190,28 @@ export function beginOAuth2Login(dev) {
121
190
  }
122
191
  const loopback = startLoopback(port, state);
123
192
  const complete = async () => {
124
- const code = await loopback.code;
125
- if (!code)
126
- return null;
127
- const tokens = await postToken(config, {
193
+ const redirect = await loopback.result;
194
+ if (redirect.error || !redirect.code) {
195
+ return { error: redirect.error ?? 'Login failed.' };
196
+ }
197
+ const { tokens, error } = await postToken(config, {
128
198
  grant_type: 'authorization_code',
129
- code,
199
+ code: redirect.code,
130
200
  redirect_uri: redirectUri,
131
201
  client_id: config.clientId,
132
202
  code_verifier: verifier,
133
203
  }, secret);
134
- if (!tokens?.access_token)
135
- return null;
204
+ if (error)
205
+ return { error };
206
+ if (!tokens?.access_token) {
207
+ return { error: 'The token endpoint did not return an access token.' };
208
+ }
136
209
  if (tokens.refresh_token) {
137
210
  setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
138
211
  }
139
- return tokens.access_token;
212
+ return { token: tokens.access_token };
140
213
  };
141
- return { authUrl: url.href, complete, cancel: loopback.close };
214
+ return { authUrl: url.href, complete, cancel: () => loopback.close() };
142
215
  }
143
216
  /**
144
217
  * Silently resolve an access token by refreshing the keychain refresh token.
@@ -155,7 +228,7 @@ export async function resolveOAuth2AccessToken(dev) {
155
228
  const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
156
229
  if (!storedRefresh)
157
230
  return null;
158
- const tokens = await postToken(config, {
231
+ const { tokens } = await postToken(config, {
159
232
  grant_type: 'refresh_token',
160
233
  refresh_token: storedRefresh,
161
234
  client_id: config.clientId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.13",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"