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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-supabase",
3
- "version": "0.0.7-alpha.0",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Supabase integration with server and TUI components",
6
6
  "license": "Apache-2.0",
@@ -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
+ }
@@ -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 errorMessage = cause instanceof BrokerClientError
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(String(cause)));
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(errorMessage), {
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
  });
@@ -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
+ }
@@ -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
- // API response types
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
- export function SupabaseDialog(props: SupabaseDialogProps) {
30
- const [state, setState] = createSignal<OAuthState>({ type: "idle" });
33
+ type AuthFlowContext = {
34
+ api: TuiPluginApi;
35
+ logger: SupabaseLogger;
36
+ setState: (state: OAuthState) => void;
37
+ onSuccess: () => void;
38
+ };
31
39
 
32
- const startOAuth = async () => {
33
- try {
34
- await props.logger.info("supabase auth started", {
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
- const authData = authResponse.data;
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
- if (!authData?.url) {
55
- throw new Error("Invalid OAuth authorization response");
56
- }
55
+ export async function runAuthFlow(context: AuthFlowContext) {
56
+ let authURL: string | undefined;
57
+ let completed = false;
57
58
 
58
- const { url, method } = authData;
59
- const safeUrl = new URL(url);
60
- setState({ type: "authorizing", url });
59
+ try {
60
+ await context.logger.info("supabase auth started", {
61
+ phase: "authorize",
62
+ });
63
+ context.setState({ type: "authorizing", url: "" });
61
64
 
62
- await props.logger.debug("supabase auth authorize response received", {
63
- method,
64
- url_origin: safeUrl.origin,
65
- url_path: safeUrl.pathname,
66
- });
65
+ const authResponse = (await context.api.client.provider.oauth.authorize({
66
+ providerID: "supabase",
67
+ method: 0,
68
+ })) as ApiResponse<AuthData>;
67
69
 
68
- // Attempt to open browser automatically
69
- if (method === "auto") {
70
- try {
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
- setState({ type: "waiting_callback", url });
80
- await props.logger.debug("supabase auth waiting for callback");
74
+ const authData = authResponse.data;
75
+ if (!authData?.url) {
76
+ throw new Error("Invalid OAuth authorization response");
77
+ }
81
78
 
82
- // Wait for callback
83
- const callbackResponse = (await props.api.client.provider.oauth.callback({
84
- providerID: "supabase",
85
- method: 0,
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
- if (callbackResponse.error) {
89
- throw new Error(
90
- callbackResponse.error.message || "OAuth callback failed",
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
- const callbackSucceeded = callbackResponse.data === true;
90
+ if (method === "auto") {
91
+ await openBrowser(url, context.logger);
92
+ }
95
93
 
96
- if (callbackSucceeded) {
97
- await props.logger.info("supabase auth completed", {
98
- status: "success",
99
- });
100
- setState({ type: "success" });
101
- props.api.ui.toast({
102
- variant: "success",
103
- message:
104
- "Connected to Supabase! Tools are ready to use. Ask your agent about supabase.",
105
- });
106
- props.onClose();
107
- } else {
108
- throw new Error("OAuth authorization was denied");
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
- const message =
112
- error instanceof Error ? error.message : "Authorization failed";
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: props.onClose,
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: currentState.url
141
- ? `Opening browser to authorize Supabase...\n\nIf the browser doesn't open automatically, visit:\n${currentState.url}`
142
- : "Starting authorization...",
143
- onConfirm: props.onClose,
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: `Waiting for authorization in your browser...\n\nIf you need to complete authorization manually, visit:\n${currentState.url}`,
151
- onConfirm: props.onClose,
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.DialogAlert({
225
+ return props.api.ui.DialogConfirm({
157
226
  title: "Authorization Failed",
158
- message: currentState.message,
159
- onConfirm: props.onClose,
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
- // Success state (should close immediately via onClose)
164
- return props.api.ui.DialogAlert({
165
- title: "Connected",
166
- message: "Successfully connected to Supabase.",
167
- onConfirm: props.onClose,
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
  }