opencode-supabase 0.0.7-alpha.0 → 0.0.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/package.json +1 -1
- package/src/server/auth-html.ts +15 -1
- package/src/server/auth.ts +4 -5
- package/src/server/tools.ts +17 -0
- package/src/shared/auth-errors.ts +47 -0
- package/src/tui/dialog.tsx +185 -99
package/package.json
CHANGED
package/src/server/auth-html.ts
CHANGED
|
@@ -68,6 +68,18 @@ const SHARED_STYLES = `
|
|
|
68
68
|
.icon-error { background: #ef4444; color: #fff; }
|
|
69
69
|
h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; text-align: center; }
|
|
70
70
|
p { font-size: 16px; color: #EDEDED; text-align: center; line-height: 1.5; max-width: 280px; }
|
|
71
|
+
.prompt-label { font-size: 13px; color: #8b8b8b; text-align: center; }
|
|
72
|
+
.prompt-box {
|
|
73
|
+
width: 100%;
|
|
74
|
+
background: #1b1b1b;
|
|
75
|
+
border: 1px solid #2e2e2e;
|
|
76
|
+
border-radius: 10px;
|
|
77
|
+
padding: 12px 14px;
|
|
78
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
color: #EDEDED;
|
|
81
|
+
text-align: center;
|
|
82
|
+
}
|
|
71
83
|
.footer { margin-top: 8px; line-height: 1.5; text-align: center; color: #666; font-size: 12px; }
|
|
72
84
|
.footer a { color: #8b8b8b; text-decoration: underline; text-underline-offset: 2px; }
|
|
73
85
|
.footer a:hover { color: #EDEDED; }
|
|
@@ -89,6 +101,8 @@ export const HTML_SUCCESS = `<!doctype html>
|
|
|
89
101
|
<h1>Authorization Successful</h1>
|
|
90
102
|
</div>
|
|
91
103
|
<p>You can <strong>close this window</strong> and return to OpenCode.</p>
|
|
104
|
+
<div class="prompt-label">Try this next:</div>
|
|
105
|
+
<div class="prompt-box">list my Supabase projects</div>
|
|
92
106
|
<div class="footer">Having troubles or found a bug?<br><a href="${REPO_URL}" target="_blank" rel="noopener">Report it on GitHub</a></div>
|
|
93
107
|
</div>
|
|
94
108
|
<script>setTimeout(function(){window.close()},2000)</script>
|
|
@@ -116,4 +130,4 @@ export function htmlError(message: string): string {
|
|
|
116
130
|
</div>
|
|
117
131
|
</body>
|
|
118
132
|
</html>`;
|
|
119
|
-
}
|
|
133
|
+
}
|
package/src/server/auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createConnection } from "node:net";
|
|
2
2
|
import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
|
|
3
3
|
|
|
4
|
+
import { formatAuthError } from "../shared/auth-errors.ts";
|
|
4
5
|
import {
|
|
5
6
|
BrokerClientError,
|
|
6
7
|
type BrokerConfig,
|
|
@@ -181,19 +182,17 @@ async function ensureServer(
|
|
|
181
182
|
headers: { "Content-Type": "text/html" },
|
|
182
183
|
});
|
|
183
184
|
} catch (cause) {
|
|
184
|
-
const
|
|
185
|
-
? `Authorization failed: ${cause.message}`
|
|
186
|
-
: "Authorization failed";
|
|
185
|
+
const message = formatAuthError("exchange", cause);
|
|
187
186
|
|
|
188
187
|
await deps.logger?.error("supabase auth failed", {
|
|
189
188
|
status: cause instanceof BrokerClientError ? cause.status : 400,
|
|
190
189
|
broker_error: cause instanceof BrokerClientError,
|
|
191
190
|
});
|
|
192
191
|
|
|
193
|
-
pending.reject(cause instanceof Error ? cause : new Error(
|
|
192
|
+
pending.reject(cause instanceof Error ? cause : new Error(message));
|
|
194
193
|
await stopServerIfIdle(deps.logger, "broker_exchange_failed");
|
|
195
194
|
|
|
196
|
-
return new Response(htmlError(
|
|
195
|
+
return new Response(htmlError(message), {
|
|
197
196
|
status: cause instanceof BrokerClientError && cause.status >= 500 ? 502 : 400,
|
|
198
197
|
headers: { "Content-Type": "text/html" },
|
|
199
198
|
});
|
package/src/server/tools.ts
CHANGED
|
@@ -256,6 +256,23 @@ export function createSupabaseTools(
|
|
|
256
256
|
);
|
|
257
257
|
},
|
|
258
258
|
}),
|
|
259
|
+
supabase_list_regions: tool({
|
|
260
|
+
description: "List all available database regions for creating a Supabase project in a specific organization.",
|
|
261
|
+
args: {
|
|
262
|
+
organization_slug: tool.schema.string().describe("Organization slug to list regions for"),
|
|
263
|
+
},
|
|
264
|
+
async execute(args, _context: SupabaseToolContext) {
|
|
265
|
+
return executeSupabaseGet(
|
|
266
|
+
input,
|
|
267
|
+
options,
|
|
268
|
+
deps,
|
|
269
|
+
"supabase_list_regions",
|
|
270
|
+
_context,
|
|
271
|
+
`/projects/available-regions?organization_slug=${encodeURIComponent(args.organization_slug)}`,
|
|
272
|
+
"list regions",
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
259
276
|
supabase_get_project_api_keys: tool({
|
|
260
277
|
description: "Get the API keys for a Supabase project.",
|
|
261
278
|
args: {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type AuthErrorStage = "start" | "callback" | "exchange" | "unknown";
|
|
2
|
+
|
|
3
|
+
const FALLBACKS: Record<AuthErrorStage, string> = {
|
|
4
|
+
start: "Failed to start OAuth authorization",
|
|
5
|
+
callback: "OAuth callback failed",
|
|
6
|
+
exchange: "Authorization failed",
|
|
7
|
+
unknown: "Authorization failed",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function getObjectMessage(value: unknown): string | undefined {
|
|
11
|
+
if (!value || typeof value !== "object") return undefined;
|
|
12
|
+
|
|
13
|
+
if ("message" in value) {
|
|
14
|
+
const message = (value as { message: unknown }).message;
|
|
15
|
+
if (typeof message === "string") return message || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extractErrorMessage(error: unknown): string | undefined {
|
|
22
|
+
if (error instanceof Error) return error.message || undefined;
|
|
23
|
+
if (typeof error === "string") return error || undefined;
|
|
24
|
+
const message = getObjectMessage(error);
|
|
25
|
+
if (message) return message;
|
|
26
|
+
|
|
27
|
+
if (error && typeof error === "object") {
|
|
28
|
+
const dataMessage = getObjectMessage((error as { data?: unknown }).data);
|
|
29
|
+
if (dataMessage) return dataMessage;
|
|
30
|
+
|
|
31
|
+
const nestedData = (error as { data?: { data?: unknown } }).data?.data;
|
|
32
|
+
const nestedDataMessage = getObjectMessage(nestedData);
|
|
33
|
+
if (nestedDataMessage) return nestedDataMessage;
|
|
34
|
+
|
|
35
|
+
const firstError = Array.isArray((error as { errors?: unknown }).errors)
|
|
36
|
+
? (error as { errors: unknown[] }).errors[0]
|
|
37
|
+
: undefined;
|
|
38
|
+
const firstErrorMessage = getObjectMessage(firstError);
|
|
39
|
+
if (firstErrorMessage) return firstErrorMessage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatAuthError(stage: AuthErrorStage, error: unknown): string {
|
|
46
|
+
return extractErrorMessage(error) || FALLBACKS[stage];
|
|
47
|
+
}
|
package/src/tui/dialog.tsx
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
2
|
import { createSignal } from "solid-js";
|
|
3
3
|
|
|
4
|
+
import { formatAuthError } from "../shared/auth-errors.ts";
|
|
4
5
|
import type { SupabaseLogger } from "../shared/log.ts";
|
|
5
6
|
|
|
6
7
|
type SupabaseDialogProps = {
|
|
7
8
|
api: TuiPluginApi;
|
|
8
9
|
onClose: () => void;
|
|
9
10
|
logger: SupabaseLogger;
|
|
11
|
+
initialState?: OAuthState;
|
|
12
|
+
lifecycle?: {
|
|
13
|
+
closed: boolean;
|
|
14
|
+
dismissed?: boolean;
|
|
15
|
+
};
|
|
10
16
|
};
|
|
11
17
|
|
|
12
18
|
type OAuthState =
|
|
@@ -14,11 +20,9 @@ type OAuthState =
|
|
|
14
20
|
| { type: "authorizing"; url: string }
|
|
15
21
|
| { type: "waiting_callback"; url: string }
|
|
16
22
|
| { type: "success" }
|
|
17
|
-
| { type: "error"; message: string };
|
|
23
|
+
| { type: "error"; message: string; url?: string };
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
type ApiError = { message?: string; [key: string]: unknown };
|
|
21
|
-
type ApiResponse<T> = { data?: T; error?: ApiError };
|
|
25
|
+
type ApiResponse<T> = { data?: T; error?: unknown };
|
|
22
26
|
|
|
23
27
|
type AuthData = {
|
|
24
28
|
url: string;
|
|
@@ -26,102 +30,161 @@ type AuthData = {
|
|
|
26
30
|
method: string;
|
|
27
31
|
};
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
type AuthFlowContext = {
|
|
34
|
+
api: TuiPluginApi;
|
|
35
|
+
logger: SupabaseLogger;
|
|
36
|
+
setState: (state: OAuthState) => void;
|
|
37
|
+
onSuccess: () => void;
|
|
38
|
+
};
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
phase: "authorize",
|
|
36
|
-
});
|
|
37
|
-
setState({ type: "authorizing", url: "" });
|
|
38
|
-
|
|
39
|
-
// Start OAuth authorization
|
|
40
|
-
const authResponse = (await props.api.client.provider.oauth.authorize({
|
|
41
|
-
providerID: "supabase",
|
|
42
|
-
method: 0,
|
|
43
|
-
})) as unknown as ApiResponse<AuthData>;
|
|
44
|
-
|
|
45
|
-
// Handle the response shape from the plugin API
|
|
46
|
-
if (authResponse.error) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
authResponse.error.message || "Failed to start OAuth authorization",
|
|
49
|
-
);
|
|
50
|
-
}
|
|
40
|
+
function getErrorMessage(error: unknown) {
|
|
41
|
+
return error instanceof Error ? error.message : String(error);
|
|
42
|
+
}
|
|
51
43
|
|
|
52
|
-
|
|
44
|
+
async function openBrowser(url: string, logger: SupabaseLogger) {
|
|
45
|
+
try {
|
|
46
|
+
const open = await import("open");
|
|
47
|
+
await open.default(url);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
await logger.warn("supabase browser open failed", {
|
|
50
|
+
message: getErrorMessage(error),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
export async function runAuthFlow(context: AuthFlowContext) {
|
|
56
|
+
let authURL: string | undefined;
|
|
57
|
+
let completed = false;
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
try {
|
|
60
|
+
await context.logger.info("supabase auth started", {
|
|
61
|
+
phase: "authorize",
|
|
62
|
+
});
|
|
63
|
+
context.setState({ type: "authorizing", url: "" });
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
65
|
+
const authResponse = (await context.api.client.provider.oauth.authorize({
|
|
66
|
+
providerID: "supabase",
|
|
67
|
+
method: 0,
|
|
68
|
+
})) as ApiResponse<AuthData>;
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const open = await import("open");
|
|
72
|
-
await open.default(url);
|
|
73
|
-
} catch {
|
|
74
|
-
await props.logger.warn("supabase browser open failed");
|
|
75
|
-
// Browser auto-open failed, user can click the URL manually
|
|
76
|
-
}
|
|
77
|
-
}
|
|
70
|
+
if (authResponse.error) {
|
|
71
|
+
throw new Error(formatAuthError("start", authResponse.error));
|
|
72
|
+
}
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
const authData = authResponse.data;
|
|
75
|
+
if (!authData?.url) {
|
|
76
|
+
throw new Error("Invalid OAuth authorization response");
|
|
77
|
+
}
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
})) as unknown as ApiResponse<boolean>;
|
|
79
|
+
const { url, method } = authData;
|
|
80
|
+
authURL = url;
|
|
81
|
+
const safeUrl = new URL(url);
|
|
82
|
+
context.setState({ type: "authorizing", url });
|
|
87
83
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
await context.logger.debug("supabase auth authorize response received", {
|
|
85
|
+
method,
|
|
86
|
+
url_origin: safeUrl.origin,
|
|
87
|
+
url_path: safeUrl.pathname,
|
|
88
|
+
});
|
|
93
89
|
|
|
94
|
-
|
|
90
|
+
if (method === "auto") {
|
|
91
|
+
await openBrowser(url, context.logger);
|
|
92
|
+
}
|
|
95
93
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
94
|
+
context.setState({ type: "waiting_callback", url });
|
|
95
|
+
await context.logger.debug("supabase auth waiting for callback");
|
|
96
|
+
|
|
97
|
+
const callbackResponse = (await context.api.client.provider.oauth.callback({
|
|
98
|
+
providerID: "supabase",
|
|
99
|
+
method: 0,
|
|
100
|
+
})) as ApiResponse<boolean>;
|
|
101
|
+
|
|
102
|
+
if (callbackResponse.error) {
|
|
103
|
+
throw new Error(formatAuthError("callback", callbackResponse.error));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (callbackResponse.data !== true) {
|
|
107
|
+
throw new Error("OAuth authorization was denied");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await context.logger.info("supabase auth completed", {
|
|
111
|
+
status: "success",
|
|
112
|
+
});
|
|
113
|
+
context.setState({ type: "success" });
|
|
114
|
+
completed = true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = formatAuthError("unknown", error);
|
|
117
|
+
await context.logger.error("supabase auth failed", {
|
|
118
|
+
message,
|
|
119
|
+
});
|
|
120
|
+
context.setState({ type: "error", message, url: authURL });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (completed) {
|
|
125
|
+
try {
|
|
126
|
+
context.onSuccess();
|
|
110
127
|
} catch (error) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await props.logger.error("supabase auth failed", {
|
|
114
|
-
message,
|
|
115
|
-
});
|
|
116
|
-
setState({ type: "error", message });
|
|
117
|
-
props.api.ui.toast({
|
|
118
|
-
variant: "error",
|
|
119
|
-
message: `Supabase authorization failed: ${message}`,
|
|
128
|
+
await context.logger.error("supabase auth success handler failed", {
|
|
129
|
+
message: getErrorMessage(error),
|
|
120
130
|
});
|
|
121
|
-
props.onClose();
|
|
122
131
|
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
136
|
+
const lifecycle = props.lifecycle ?? { closed: false };
|
|
137
|
+
const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "idle" });
|
|
138
|
+
|
|
139
|
+
const closeDialog = (dismissed = false) => {
|
|
140
|
+
lifecycle.closed = true;
|
|
141
|
+
if (dismissed) {
|
|
142
|
+
lifecycle.dismissed = true;
|
|
143
|
+
}
|
|
144
|
+
props.onClose();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const setState = (nextState: OAuthState) => {
|
|
148
|
+
if (lifecycle.closed) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setStateSignal(nextState);
|
|
153
|
+
|
|
154
|
+
if (nextState.type === "success") {
|
|
155
|
+
if (lifecycle.dismissed) {
|
|
156
|
+
// User dismissed waiting dialog; stay silent
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
props.api.ui.dialog.replace(() =>
|
|
160
|
+
SupabaseDialog({
|
|
161
|
+
...props,
|
|
162
|
+
initialState: nextState,
|
|
163
|
+
lifecycle,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
props.api.ui.dialog.replace(() =>
|
|
170
|
+
SupabaseDialog({
|
|
171
|
+
...props,
|
|
172
|
+
initialState: nextState,
|
|
173
|
+
lifecycle,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
123
176
|
};
|
|
124
177
|
|
|
178
|
+
const startOAuth = () =>
|
|
179
|
+
runAuthFlow({
|
|
180
|
+
api: props.api,
|
|
181
|
+
logger: props.logger,
|
|
182
|
+
setState,
|
|
183
|
+
onSuccess: () => {
|
|
184
|
+
// Success dialog handles user-facing confirmation
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
125
188
|
const currentState = state();
|
|
126
189
|
|
|
127
190
|
if (currentState.type === "idle") {
|
|
@@ -130,40 +193,63 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
130
193
|
message:
|
|
131
194
|
"This will open a browser window to authorize OpenCode to access your Supabase account. Continue?",
|
|
132
195
|
onConfirm: startOAuth,
|
|
133
|
-
onCancel:
|
|
196
|
+
onCancel: closeDialog,
|
|
134
197
|
});
|
|
135
198
|
}
|
|
136
199
|
|
|
137
200
|
if (currentState.type === "authorizing") {
|
|
201
|
+
if (!currentState.url) {
|
|
138
202
|
return props.api.ui.DialogAlert({
|
|
139
203
|
title: "Connect Supabase",
|
|
140
|
-
message:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
204
|
+
message: "Starting authorization...",
|
|
205
|
+
onConfirm: () => closeDialog(true),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return props.api.ui.DialogAlert({
|
|
210
|
+
title: "Connect Supabase",
|
|
211
|
+
message: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nWaiting for authorization...`,
|
|
212
|
+
onConfirm: () => closeDialog(true),
|
|
144
213
|
});
|
|
145
214
|
}
|
|
146
215
|
|
|
147
216
|
if (currentState.type === "waiting_callback") {
|
|
148
217
|
return props.api.ui.DialogAlert({
|
|
149
218
|
title: "Connect Supabase",
|
|
150
|
-
message: `
|
|
151
|
-
onConfirm:
|
|
219
|
+
message: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nWaiting for authorization...`,
|
|
220
|
+
onConfirm: () => closeDialog(true),
|
|
152
221
|
});
|
|
153
222
|
}
|
|
154
223
|
|
|
155
224
|
if (currentState.type === "error") {
|
|
156
|
-
return props.api.ui.
|
|
225
|
+
return props.api.ui.DialogConfirm({
|
|
157
226
|
title: "Authorization Failed",
|
|
158
|
-
message: currentState.
|
|
159
|
-
|
|
227
|
+
message: currentState.url
|
|
228
|
+
? `${currentState.message}\n\nIf you need to retry manually, visit:\n${currentState.url}`
|
|
229
|
+
: currentState.message,
|
|
230
|
+
onConfirm: async () => {
|
|
231
|
+
await startOAuth();
|
|
232
|
+
},
|
|
233
|
+
onCancel: closeDialog,
|
|
160
234
|
});
|
|
161
235
|
}
|
|
162
236
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
onConfirm:
|
|
237
|
+
return props.api.ui.DialogConfirm({
|
|
238
|
+
title: "Connected to Supabase",
|
|
239
|
+
message:
|
|
240
|
+
"Your account is ready. Try asking:\n\n list my Supabase projects\n list my Supabase organizations\n for organization <name>, list available regions\n\nRun an example?",
|
|
241
|
+
onConfirm: async () => {
|
|
242
|
+
try {
|
|
243
|
+
await props.api.client.tui.appendPrompt({
|
|
244
|
+
text: "list my Supabase projects",
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
await props.logger.warn("supabase append prompt failed", {
|
|
248
|
+
message: getErrorMessage(error),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
closeDialog();
|
|
252
|
+
},
|
|
253
|
+
onCancel: closeDialog,
|
|
168
254
|
});
|
|
169
255
|
}
|