opencode-supabase 0.0.7 → 0.1.0

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",
3
+ "version": "0.1.0",
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,
@@ -11,7 +12,8 @@ import type { SupabaseLogger } from "../shared/log.ts";
11
12
  import { buildAuthorizeUrl, generatePKCE, generateState } from "../shared/oauth.ts";
12
13
  import type { FetchLike, SupabaseTokenResponse } from "../shared/types.ts";
13
14
  import { HTML_SUCCESS, htmlError } from "./auth-html.ts";
14
- import { writeSavedAuth } from "./store.ts";
15
+ import { readSavedAuth, writeSavedAuth } from "./store.ts";
16
+ import { NOT_CONNECTED_MESSAGE, disconnectSupabaseAuth, ensureSupabaseToolAuth } from "./tools.ts";
15
17
 
16
18
  const CALLBACK_PATH = "/auth/callback";
17
19
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
@@ -32,9 +34,47 @@ type AuthDeps = {
32
34
  setCallbackTimeout?: typeof setTimeout;
33
35
  };
34
36
 
37
+ type SupabaseAuthInput = Pick<PluginInput, "client" | "directory" | "serverUrl" | "worktree">;
38
+
39
+ type SupabaseStatusInstructions =
40
+ | {
41
+ status: "connected";
42
+ checked: false;
43
+ }
44
+ | {
45
+ status: "disconnected";
46
+ checked: false;
47
+ }
48
+ | {
49
+ status: "refresh_required";
50
+ checked: true;
51
+ };
52
+
35
53
  let server: ReturnType<typeof Bun.serve> | undefined;
36
54
  let serverPort: number | undefined;
37
55
  const pendingAuths = new Map<string, PendingAuth>();
56
+ const REFRESH_BUFFER_MS = 30_000;
57
+
58
+ function isRefreshNeeded(expires: number) {
59
+ return expires <= Date.now() + REFRESH_BUFFER_MS;
60
+ }
61
+
62
+ function encodeStatusInstructions(status: SupabaseStatusInstructions) {
63
+ return JSON.stringify(status);
64
+ }
65
+
66
+ async function getStatusInstructions(input: Pick<SupabaseAuthInput, "directory" | "worktree">) {
67
+ const saved = await readSavedAuth(input);
68
+ if (!saved.auth) {
69
+ return encodeStatusInstructions({ status: "disconnected", checked: false });
70
+ }
71
+
72
+ if (!isRefreshNeeded(saved.auth.expires)) {
73
+ return encodeStatusInstructions({ status: "connected", checked: false });
74
+ }
75
+
76
+ return encodeStatusInstructions({ status: "refresh_required", checked: true });
77
+ }
38
78
 
39
79
  function callbackUrl(port: number) {
40
80
  return `http://localhost:${port}${CALLBACK_PATH}`;
@@ -181,19 +221,17 @@ async function ensureServer(
181
221
  headers: { "Content-Type": "text/html" },
182
222
  });
183
223
  } catch (cause) {
184
- const errorMessage = cause instanceof BrokerClientError
185
- ? `Authorization failed: ${cause.message}`
186
- : "Authorization failed";
224
+ const message = formatAuthError("exchange", cause);
187
225
 
188
226
  await deps.logger?.error("supabase auth failed", {
189
227
  status: cause instanceof BrokerClientError ? cause.status : 400,
190
228
  broker_error: cause instanceof BrokerClientError,
191
229
  });
192
230
 
193
- pending.reject(cause instanceof Error ? cause : new Error(String(cause)));
231
+ pending.reject(cause instanceof Error ? cause : new Error(message));
194
232
  await stopServerIfIdle(deps.logger, "broker_exchange_failed");
195
233
 
196
- return new Response(htmlError(errorMessage), {
234
+ return new Response(htmlError(message), {
197
235
  status: cause instanceof BrokerClientError && cause.status >= 500 ? 502 : 400,
198
236
  headers: { "Content-Type": "text/html" },
199
237
  });
@@ -269,7 +307,7 @@ function waitForCallback(
269
307
  }
270
308
 
271
309
  export function createSupabaseAuth(
272
- input: Pick<PluginInput, "directory" | "worktree">,
310
+ input: SupabaseAuthInput,
273
311
  options?: PluginOptions,
274
312
  deps: AuthDeps = {},
275
313
  ) {
@@ -308,6 +346,56 @@ export function createSupabaseAuth(
308
346
  };
309
347
  },
310
348
  },
349
+ {
350
+ type: "oauth" as const,
351
+ label: "Supabase Status",
352
+ async authorize(inputs?: Record<string, string>) {
353
+ if (inputs?.action === "disconnect") {
354
+ await disconnectSupabaseAuth(input, { fetch: deps.fetch });
355
+ return {
356
+ url: "https://supabase.com/",
357
+ instructions: encodeStatusInstructions({ status: "disconnected", checked: false }),
358
+ method: "auto" as const,
359
+ callback: async () => ({ type: "failed" as const }),
360
+ };
361
+ }
362
+
363
+ const instructions = await getStatusInstructions(input);
364
+ const status = JSON.parse(instructions) as SupabaseStatusInstructions;
365
+
366
+ if (status.status !== "refresh_required") {
367
+ return {
368
+ url: "https://supabase.com/",
369
+ instructions,
370
+ method: "auto" as const,
371
+ callback: async () => ({ type: "failed" as const }),
372
+ };
373
+ }
374
+
375
+ return {
376
+ url: "https://supabase.com/",
377
+ instructions,
378
+ method: "auto" as const,
379
+ callback: async () => {
380
+ try {
381
+ const auth = await ensureSupabaseToolAuth(input, options, deps);
382
+ return {
383
+ type: "success" as const,
384
+ access: auth.access,
385
+ refresh: auth.refresh,
386
+ expires: auth.expires,
387
+ };
388
+ } catch (error) {
389
+ const message = error instanceof Error ? error.message : String(error);
390
+ if (message === NOT_CONNECTED_MESSAGE) {
391
+ return { type: "failed" as const };
392
+ }
393
+ throw error;
394
+ }
395
+ },
396
+ };
397
+ },
398
+ },
311
399
  ],
312
400
  };
313
401
  }
@@ -10,13 +10,19 @@ import {
10
10
  import { readSupabaseConfig } from "../shared/cfg.ts";
11
11
  import type { SupabaseLogger } from "../shared/log.ts";
12
12
  import type { FetchLike } from "../shared/types.ts";
13
- import { type SavedAuth, clearSavedAuth, readSavedAuth, writeSavedAuth } from "./store.ts";
13
+ import { type SavedAuth, clearSavedAuth, getStoreFile, readSavedAuth, writeSavedAuth } from "./store.ts";
14
14
 
15
15
  type ToolDeps = {
16
16
  fetch?: FetchLike;
17
17
  logger?: SupabaseLogger;
18
18
  };
19
19
 
20
+ type InFlightRefresh = {
21
+ promise: Promise<SavedAuth>;
22
+ syncedDirectories: Set<string>;
23
+ syncPromises: Map<string, Promise<void>>;
24
+ };
25
+
20
26
  type HostAuthWriter = {
21
27
  set(input: {
22
28
  path: { id: string };
@@ -44,13 +50,35 @@ type SupabaseToolContext = Pick<
44
50
  "directory" | "worktree" | "abort" | "sessionID" | "messageID" | "agent" | "metadata" | "ask"
45
51
  >;
46
52
 
47
- const NOT_CONNECTED_MESSAGE = "Supabase is not connected. Run /supabase first.";
53
+ export type SupabaseAuthStatus =
54
+ | {
55
+ status: "connected";
56
+ auth: SavedAuth;
57
+ checked: boolean;
58
+ }
59
+ | {
60
+ status: "disconnected";
61
+ checked: boolean;
62
+ }
63
+ | {
64
+ status: "unknown";
65
+ checked: true;
66
+ message: string;
67
+ };
68
+
69
+ export const NOT_CONNECTED_MESSAGE = "Supabase is not connected. Run /supabase first.";
48
70
  const REFRESH_BUFFER_MS = 30_000;
71
+ const inFlightRefreshes = new Map<string, InFlightRefresh>();
49
72
 
50
73
  function isRefreshNeeded(auth: SavedAuth) {
51
74
  return auth.expires <= Date.now() + REFRESH_BUFFER_MS;
52
75
  }
53
76
 
77
+ function isSameAuth(left: SavedAuth | undefined, right: SavedAuth | undefined) {
78
+ if (!left || !right) return left === right;
79
+ return left.access === right.access && left.refresh === right.refresh && left.expires === right.expires;
80
+ }
81
+
54
82
  function generateRandomString(length: number) {
55
83
  const bytes = crypto.getRandomValues(new Uint8Array(length));
56
84
  return btoa(String.fromCharCode(...bytes))
@@ -169,55 +197,181 @@ async function clearHostAuth(
169
197
  }
170
198
  }
171
199
 
200
+ async function syncHostAuthForDirectory(entry: InFlightRefresh, input: SupabaseToolInput, auth: SavedAuth) {
201
+ if (entry.syncedDirectories.has(input.directory)) {
202
+ return;
203
+ }
204
+
205
+ const existing = entry.syncPromises.get(input.directory);
206
+ if (existing) {
207
+ await existing.catch(() => undefined);
208
+ return;
209
+ }
210
+
211
+ const syncPromise = (async () => {
212
+ await setHostAuth(input, auth);
213
+ entry.syncedDirectories.add(input.directory);
214
+ })().finally(() => {
215
+ entry.syncPromises.delete(input.directory);
216
+ });
217
+
218
+ entry.syncPromises.set(input.directory, syncPromise);
219
+ await syncPromise.catch(() => undefined);
220
+ }
221
+
222
+ export async function disconnectSupabaseAuth(
223
+ input: SupabaseToolInput,
224
+ deps: Pick<ToolDeps, "fetch"> = {},
225
+ ) {
226
+ const fetchImpl = deps.fetch ?? fetch;
227
+ await clearSavedAuth(input);
228
+ inFlightRefreshes.delete(getStoreFile(input));
229
+ try {
230
+ await clearHostAuth(input, fetchImpl);
231
+ } catch {}
232
+ }
233
+
234
+ export async function getSupabaseAuthStatus(
235
+ input: SupabaseToolInput,
236
+ options?: PluginOptions,
237
+ deps: ToolDeps = {},
238
+ ): Promise<SupabaseAuthStatus> {
239
+ const saved = await readSavedAuth(input);
240
+ if (!saved.auth) {
241
+ return { status: "disconnected", checked: false };
242
+ }
243
+
244
+ if (!isRefreshNeeded(saved.auth)) {
245
+ return { status: "connected", auth: saved.auth, checked: false };
246
+ }
247
+
248
+ try {
249
+ const auth = await ensureSupabaseToolAuth(input, options, deps);
250
+ return { status: "connected", auth, checked: true };
251
+ } catch (error) {
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ if (message === NOT_CONNECTED_MESSAGE) {
254
+ return { status: "disconnected", checked: true };
255
+ }
256
+
257
+ return { status: "unknown", checked: true, message };
258
+ }
259
+ }
260
+
172
261
  export async function ensureSupabaseToolAuth(
173
262
  input: SupabaseToolInput,
174
263
  options?: PluginOptions,
175
264
  deps: ToolDeps = {},
176
265
  ): Promise<SavedAuth> {
177
- const fetchImpl = deps.fetch ?? fetch;
266
+ const refreshKey = getStoreFile(input);
178
267
  const saved = await readSavedAuth(input);
179
268
  if (!saved.auth) {
180
269
  throw new Error(NOT_CONNECTED_MESSAGE);
181
270
  }
182
271
 
272
+ const inFlight = inFlightRefreshes.get(refreshKey);
273
+ if (inFlight) {
274
+ const fetchImpl = deps.fetch ?? fetch;
275
+ try {
276
+ const auth = await inFlight.promise;
277
+ await syncHostAuthForDirectory(inFlight, input, auth);
278
+ return auth;
279
+ } catch (error) {
280
+ if ((error instanceof Error ? error.message : String(error)) === NOT_CONNECTED_MESSAGE) {
281
+ try {
282
+ await clearHostAuth(input, fetchImpl);
283
+ } catch {}
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+
183
289
  if (!isRefreshNeeded(saved.auth)) {
290
+ try {
291
+ await setHostAuth(input, saved.auth);
292
+ } catch {}
184
293
  return saved.auth;
185
294
  }
186
295
 
187
- const config = readSupabaseConfig(options);
296
+ const refreshEntry: InFlightRefresh = {
297
+ promise: Promise.resolve({ access: "", refresh: "", expires: 0 }),
298
+ syncedDirectories: new Set<string>(),
299
+ syncPromises: new Map<string, Promise<void>>(),
300
+ };
301
+ const refreshPromise = (async () => {
302
+ const fetchImpl = deps.fetch ?? fetch;
303
+ const current = await readSavedAuth(input);
304
+ if (!current.auth) {
305
+ throw new Error(NOT_CONNECTED_MESSAGE);
306
+ }
188
307
 
189
- try {
190
- const refreshed = await refreshTokenThroughBroker(
191
- { baseUrl: config.brokerBaseUrl },
192
- { refresh_token: saved.auth.refresh },
193
- deps.fetch,
194
- deps.logger,
195
- );
308
+ if (!isRefreshNeeded(current.auth)) {
309
+ return current.auth;
310
+ }
311
+
312
+ const config = readSupabaseConfig(options);
196
313
 
197
- const nextAuth: SavedAuth = {
198
- access: refreshed.access_token,
199
- refresh: refreshed.refresh_token,
200
- expires: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
201
- };
202
- await writeSavedAuth(input, nextAuth);
203
314
  try {
204
- await setHostAuth(input, nextAuth);
205
- } catch {}
206
- return nextAuth;
207
- } catch (error) {
208
- if (error instanceof BrokerClientError && (error.status === 401 || error.status === 400)) {
209
- await clearSavedAuth(input);
210
- try {
211
- await clearHostAuth(input, fetchImpl);
212
- } catch {}
213
- throw new Error(NOT_CONNECTED_MESSAGE);
214
- }
315
+ const refreshed = await refreshTokenThroughBroker(
316
+ { baseUrl: config.brokerBaseUrl },
317
+ { refresh_token: current.auth.refresh },
318
+ deps.fetch,
319
+ deps.logger,
320
+ );
215
321
 
216
- if (error instanceof BrokerClientError) {
217
- throw new Error(`Supabase auth refresh failed: ${error.message}`);
322
+ const nextAuth: SavedAuth = {
323
+ access: refreshed.access_token,
324
+ refresh: refreshed.refresh_token,
325
+ expires: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
326
+ };
327
+
328
+ const latest = await readSavedAuth(input);
329
+ if (!latest.auth) {
330
+ throw new Error(NOT_CONNECTED_MESSAGE);
331
+ }
332
+
333
+ if (!isSameAuth(latest.auth, current.auth)) {
334
+ return latest.auth;
335
+ }
336
+
337
+ await writeSavedAuth(input, nextAuth);
338
+ return nextAuth;
339
+ } catch (error) {
340
+ if (error instanceof BrokerClientError && (error.status === 401 || error.status === 400)) {
341
+ const latest = await readSavedAuth(input);
342
+ if (!isSameAuth(latest.auth, current.auth)) {
343
+ if (latest.auth) {
344
+ return latest.auth;
345
+ }
346
+ throw new Error(NOT_CONNECTED_MESSAGE);
347
+ }
348
+
349
+ await clearSavedAuth(input);
350
+ try {
351
+ await clearHostAuth(input, fetchImpl);
352
+ } catch {}
353
+ throw new Error(NOT_CONNECTED_MESSAGE);
354
+ }
355
+
356
+ if (error instanceof BrokerClientError) {
357
+ throw new Error(`Supabase auth refresh failed: ${error.message}`);
358
+ }
359
+ throw error;
218
360
  }
219
- throw error;
220
- }
361
+ })();
362
+ refreshEntry.promise = refreshPromise
363
+ .then(async (auth) => {
364
+ await syncHostAuthForDirectory(refreshEntry, input, auth);
365
+ return auth;
366
+ })
367
+ .finally(() => {
368
+ if (inFlightRefreshes.get(refreshKey)?.promise === refreshEntry.promise) {
369
+ inFlightRefreshes.delete(refreshKey);
370
+ }
371
+ });
372
+
373
+ inFlightRefreshes.set(refreshKey, refreshEntry);
374
+ return refreshEntry.promise;
221
375
  }
222
376
 
223
377
  export function createSupabaseTools(
@@ -256,6 +410,23 @@ export function createSupabaseTools(
256
410
  );
257
411
  },
258
412
  }),
413
+ supabase_list_regions: tool({
414
+ description: "List all available database regions for creating a Supabase project in a specific organization.",
415
+ args: {
416
+ organization_slug: tool.schema.string().describe("Organization slug to list regions for"),
417
+ },
418
+ async execute(args, _context: SupabaseToolContext) {
419
+ return executeSupabaseGet(
420
+ input,
421
+ options,
422
+ deps,
423
+ "supabase_list_regions",
424
+ _context,
425
+ `/projects/available-regions?organization_slug=${encodeURIComponent(args.organization_slug)}`,
426
+ "list regions",
427
+ );
428
+ },
429
+ }),
259
430
  supabase_get_project_api_keys: tool({
260
431
  description: "Get the API keys for a Supabase project.",
261
432
  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,24 +1,32 @@
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
+ preflightPromise?: Promise<void>;
16
+ };
10
17
  };
11
18
 
12
19
  type OAuthState =
20
+ | { type: "checking_auth" }
13
21
  | { type: "idle" }
22
+ | { type: "already_connected" }
14
23
  | { type: "authorizing"; url: string }
15
24
  | { type: "waiting_callback"; url: string }
16
25
  | { type: "success" }
17
- | { type: "error"; message: string };
26
+ | { type: "unknown"; message: string }
27
+ | { type: "error"; message: string; url?: string };
18
28
 
19
- // API response types
20
- type ApiError = { message?: string; [key: string]: unknown };
21
- type ApiResponse<T> = { data?: T; error?: ApiError };
29
+ type ApiResponse<T> = { data?: T; error?: unknown };
22
30
 
23
31
  type AuthData = {
24
32
  url: string;
@@ -26,144 +34,365 @@ type AuthData = {
26
34
  method: string;
27
35
  };
28
36
 
29
- export function SupabaseDialog(props: SupabaseDialogProps) {
30
- const [state, setState] = createSignal<OAuthState>({ type: "idle" });
37
+ type AuthStatus =
38
+ | { status: "connected"; checked: boolean }
39
+ | { status: "disconnected"; checked: boolean }
40
+ | { status: "refresh_required"; checked: true };
41
+
42
+ type AuthFlowContext = {
43
+ api: TuiPluginApi;
44
+ logger: SupabaseLogger;
45
+ setState: (state: OAuthState) => void;
46
+ onSuccess: () => void;
47
+ };
48
+
49
+ function getErrorMessage(error: unknown) {
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
52
+
53
+ function parseAuthStatus(instructions: string): AuthStatus {
54
+ const parsed = JSON.parse(instructions) as Partial<AuthStatus>;
55
+ if (
56
+ parsed.status === "connected" ||
57
+ parsed.status === "disconnected" ||
58
+ parsed.status === "refresh_required"
59
+ ) {
60
+ return parsed as AuthStatus;
61
+ }
62
+
63
+ throw new Error("Invalid Supabase auth status response");
64
+ }
65
+
66
+ async function openBrowser(url: string, logger: SupabaseLogger) {
67
+ try {
68
+ const open = await import("open");
69
+ await open.default(url);
70
+ } catch (error) {
71
+ await logger.warn("supabase browser open failed", {
72
+ message: getErrorMessage(error),
73
+ });
74
+ }
75
+ }
76
+
77
+ export async function runAuthFlow(context: AuthFlowContext) {
78
+ let authURL: string | undefined;
79
+ let completed = false;
80
+
81
+ try {
82
+ await context.logger.info("supabase auth started", {
83
+ phase: "authorize",
84
+ });
85
+ context.setState({ type: "authorizing", url: "" });
86
+
87
+ const authResponse = (await context.api.client.provider.oauth.authorize({
88
+ providerID: "supabase",
89
+ method: 0,
90
+ })) as ApiResponse<AuthData>;
91
+
92
+ if (authResponse.error) {
93
+ throw new Error(formatAuthError("start", authResponse.error));
94
+ }
95
+
96
+ const authData = authResponse.data;
97
+ if (!authData?.url) {
98
+ throw new Error("Invalid OAuth authorization response");
99
+ }
100
+
101
+ const { url, method } = authData;
102
+ authURL = url;
103
+ const safeUrl = new URL(url);
104
+ context.setState({ type: "authorizing", url });
105
+
106
+ await context.logger.debug("supabase auth authorize response received", {
107
+ method,
108
+ url_origin: safeUrl.origin,
109
+ url_path: safeUrl.pathname,
110
+ });
111
+
112
+ if (method === "auto") {
113
+ await openBrowser(url, context.logger);
114
+ }
115
+
116
+ context.setState({ type: "waiting_callback", url });
117
+ await context.logger.debug("supabase auth waiting for callback");
118
+
119
+ const callbackResponse = (await context.api.client.provider.oauth.callback({
120
+ providerID: "supabase",
121
+ method: 0,
122
+ })) as ApiResponse<boolean>;
123
+
124
+ if (callbackResponse.error) {
125
+ throw new Error(formatAuthError("callback", callbackResponse.error));
126
+ }
127
+
128
+ if (callbackResponse.data !== true) {
129
+ throw new Error("OAuth authorization was denied");
130
+ }
31
131
 
32
- const startOAuth = async () => {
132
+ await context.logger.info("supabase auth completed", {
133
+ status: "success",
134
+ });
135
+ context.setState({ type: "success" });
136
+ completed = true;
137
+ } catch (error) {
138
+ const message = formatAuthError("unknown", error);
139
+ await context.logger.error("supabase auth failed", {
140
+ message,
141
+ });
142
+ context.setState({ type: "error", message, url: authURL });
143
+ return;
144
+ }
145
+
146
+ if (completed) {
33
147
  try {
34
- await props.logger.info("supabase auth started", {
35
- phase: "authorize",
148
+ context.onSuccess();
149
+ } catch (error) {
150
+ await context.logger.error("supabase auth success handler failed", {
151
+ message: getErrorMessage(error),
36
152
  });
37
- setState({ type: "authorizing", url: "" });
153
+ }
154
+ }
155
+ }
38
156
 
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
- }
157
+ export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "logger" | "setState">) {
158
+ context.setState({ type: "checking_auth" });
51
159
 
52
- const authData = authResponse.data;
160
+ try {
161
+ const authResponse = (await context.api.client.provider.oauth.authorize({
162
+ providerID: "supabase",
163
+ method: 1,
164
+ })) as ApiResponse<AuthData>;
53
165
 
54
- if (!authData?.url) {
55
- throw new Error("Invalid OAuth authorization response");
56
- }
166
+ if (authResponse.error) {
167
+ throw new Error(formatAuthError("start", authResponse.error));
168
+ }
57
169
 
58
- const { url, method } = authData;
59
- const safeUrl = new URL(url);
60
- setState({ type: "authorizing", url });
170
+ const instructions = authResponse.data?.instructions;
171
+ if (!instructions) {
172
+ throw new Error("Invalid Supabase auth status response");
173
+ }
61
174
 
62
- await props.logger.debug("supabase auth authorize response received", {
63
- method,
64
- url_origin: safeUrl.origin,
65
- url_path: safeUrl.pathname,
66
- });
175
+ const status = parseAuthStatus(instructions);
176
+ if (status.status === "connected") {
177
+ context.setState({ type: "already_connected" });
178
+ return;
179
+ }
67
180
 
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
- }
181
+ if (status.status === "disconnected") {
182
+ context.setState({ type: "idle" });
183
+ return;
184
+ }
78
185
 
79
- setState({ type: "waiting_callback", url });
80
- await props.logger.debug("supabase auth waiting for callback");
186
+ const callbackResponse = (await context.api.client.provider.oauth.callback({
187
+ providerID: "supabase",
188
+ method: 1,
189
+ })) as ApiResponse<boolean>;
81
190
 
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>;
191
+ if (callbackResponse.error) {
192
+ throw new Error(formatAuthError("callback", callbackResponse.error));
193
+ }
87
194
 
88
- if (callbackResponse.error) {
89
- throw new Error(
90
- callbackResponse.error.message || "OAuth callback failed",
91
- );
92
- }
195
+ if (callbackResponse.data === true) {
196
+ context.setState({ type: "already_connected" });
197
+ return;
198
+ }
93
199
 
94
- const callbackSucceeded = callbackResponse.data === true;
200
+ context.setState({ type: "idle" });
201
+ } catch (error) {
202
+ context.setState({
203
+ type: "unknown",
204
+ message: formatAuthError("unknown", error),
205
+ });
206
+ }
207
+ }
95
208
 
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");
209
+ export function SupabaseDialog(props: SupabaseDialogProps) {
210
+ const lifecycle = props.lifecycle ?? { closed: false };
211
+ const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "checking_auth" });
212
+
213
+ const closeDialog = (dismissed = false) => {
214
+ lifecycle.closed = true;
215
+ if (dismissed) {
216
+ lifecycle.dismissed = true;
217
+ }
218
+ props.onClose();
219
+ };
220
+
221
+ const setState = (nextState: OAuthState) => {
222
+ if (lifecycle.closed) {
223
+ return;
224
+ }
225
+
226
+ setStateSignal(nextState);
227
+
228
+ if (nextState.type === "success") {
229
+ if (lifecycle.dismissed) {
230
+ // User dismissed waiting dialog; stay silent
231
+ return;
109
232
  }
233
+ props.api.ui.dialog.replace(() =>
234
+ SupabaseDialog({
235
+ ...props,
236
+ initialState: nextState,
237
+ lifecycle,
238
+ }),
239
+ );
240
+ return;
241
+ }
242
+
243
+ props.api.ui.dialog.replace(() =>
244
+ SupabaseDialog({
245
+ ...props,
246
+ initialState: nextState,
247
+ lifecycle,
248
+ }),
249
+ );
250
+ };
251
+
252
+ const startOAuth = () =>
253
+ runAuthFlow({
254
+ api: props.api,
255
+ logger: props.logger,
256
+ setState,
257
+ onSuccess: () => {
258
+ // Success dialog handles user-facing confirmation
259
+ },
260
+ });
261
+
262
+ const retryPreflight = () => {
263
+ if (lifecycle.preflightPromise) {
264
+ return lifecycle.preflightPromise;
265
+ }
266
+
267
+ lifecycle.preflightPromise = runAuthPreflight({
268
+ api: props.api,
269
+ logger: props.logger,
270
+ setState,
271
+ }).finally(() => {
272
+ lifecycle.preflightPromise = undefined;
273
+ });
274
+
275
+ return lifecycle.preflightPromise;
276
+ };
277
+
278
+ const disconnect = async () => {
279
+ try {
280
+ await props.api.client.provider.oauth.authorize({
281
+ providerID: "supabase",
282
+ method: 1,
283
+ inputs: { action: "disconnect" },
284
+ });
285
+ closeDialog();
110
286
  } catch (error) {
111
- const message =
112
- error instanceof Error ? error.message : "Authorization failed";
113
- await props.logger.error("supabase auth failed", {
114
- message,
287
+ await props.logger.warn("supabase disconnect failed", {
288
+ message: getErrorMessage(error),
115
289
  });
116
- setState({ type: "error", message });
117
- props.api.ui.toast({
118
- variant: "error",
119
- message: `Supabase authorization failed: ${message}`,
290
+ setState({
291
+ type: "unknown",
292
+ message: `Couldn't disconnect from Supabase right now. ${formatAuthError("unknown", error)}`,
120
293
  });
121
- props.onClose();
122
294
  }
123
295
  };
124
296
 
125
297
  const currentState = state();
126
298
 
299
+ if (currentState.type === "checking_auth") {
300
+ queueMicrotask(() => {
301
+ if (lifecycle.closed || lifecycle.preflightPromise) {
302
+ return;
303
+ }
304
+ void retryPreflight();
305
+ });
306
+
307
+ return props.api.ui.DialogAlert({
308
+ title: "Connect Supabase",
309
+ message: "Checking Supabase connection...",
310
+ onConfirm: () => closeDialog(true),
311
+ });
312
+ }
313
+
127
314
  if (currentState.type === "idle") {
128
315
  return props.api.ui.DialogConfirm({
129
316
  title: "Connect Supabase",
130
317
  message:
131
318
  "This will open a browser window to authorize OpenCode to access your Supabase account. Continue?",
132
319
  onConfirm: startOAuth,
133
- onCancel: props.onClose,
320
+ onCancel: closeDialog,
134
321
  });
135
322
  }
136
323
 
137
324
  if (currentState.type === "authorizing") {
325
+ if (!currentState.url) {
138
326
  return props.api.ui.DialogAlert({
139
327
  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,
328
+ message: "Starting authorization...",
329
+ onConfirm: () => closeDialog(true),
330
+ });
331
+ }
332
+
333
+ return props.api.ui.DialogAlert({
334
+ title: "Connect Supabase",
335
+ message: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nWaiting for authorization...`,
336
+ onConfirm: () => closeDialog(true),
144
337
  });
145
338
  }
146
339
 
147
340
  if (currentState.type === "waiting_callback") {
148
341
  return props.api.ui.DialogAlert({
149
342
  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,
343
+ message: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nWaiting for authorization...`,
344
+ onConfirm: () => closeDialog(true),
152
345
  });
153
346
  }
154
347
 
155
348
  if (currentState.type === "error") {
156
- return props.api.ui.DialogAlert({
349
+ return props.api.ui.DialogConfirm({
157
350
  title: "Authorization Failed",
158
- message: currentState.message,
159
- onConfirm: props.onClose,
351
+ message: currentState.url
352
+ ? `${currentState.message}\n\nIf you need to retry manually, visit:\n${currentState.url}`
353
+ : currentState.message,
354
+ onConfirm: async () => {
355
+ await startOAuth();
356
+ },
357
+ onCancel: closeDialog,
160
358
  });
161
359
  }
162
360
 
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,
361
+ if (currentState.type === "already_connected") {
362
+ return props.api.ui.DialogConfirm({
363
+ title: "Already connected to Supabase",
364
+ message: "Your saved Supabase login is ready to use. Continue to close this dialog, or disconnect to sign out.",
365
+ onConfirm: closeDialog,
366
+ onCancel: disconnect,
367
+ label: "Disconnect",
368
+ } as import("./opencode-runtime-extensions.ts").DialogConfirmWithLabel);
369
+ }
370
+
371
+ if (currentState.type === "unknown") {
372
+ return props.api.ui.DialogConfirm({
373
+ title: "Supabase connection status unknown",
374
+ message: `${currentState.message}\n\nConfirm to retry, or cancel to continue without changing saved auth.`,
375
+ onConfirm: retryPreflight,
376
+ onCancel: closeDialog,
377
+ });
378
+ }
379
+
380
+ return props.api.ui.DialogConfirm({
381
+ title: "Connected to Supabase",
382
+ message:
383
+ "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?",
384
+ onConfirm: async () => {
385
+ try {
386
+ await props.api.client.tui.appendPrompt({
387
+ text: "list my Supabase projects",
388
+ });
389
+ } catch (error) {
390
+ await props.logger.warn("supabase append prompt failed", {
391
+ message: getErrorMessage(error),
392
+ });
393
+ }
394
+ closeDialog();
395
+ },
396
+ onCancel: closeDialog,
168
397
  });
169
398
  }
package/src/tui/index.tsx CHANGED
@@ -11,7 +11,15 @@ const tui: TuiPlugin = async (api) => {
11
11
 
12
12
  api.command.register(() => [
13
13
  createSupabaseCommand(() => {
14
- api.ui.dialog.replace(() => SupabaseDialog({ api, logger, onClose: () => api.ui.dialog.clear() }));
14
+ const lifecycle = { closed: false };
15
+ api.ui.dialog.replace(() =>
16
+ SupabaseDialog({
17
+ api,
18
+ logger,
19
+ lifecycle,
20
+ onClose: () => api.ui.dialog.clear(),
21
+ }),
22
+ );
15
23
  }),
16
24
  ]);
17
25
  };
@@ -0,0 +1,10 @@
1
+ // OpenCode host supports `label` on DialogConfirm since ~Mar 2026
2
+ // (commit e2d03ce38 in opencode repo: interactive update flow for non-patch releases).
3
+ // The @opencode-ai/plugin SDK types (up to 1.14.24) never declared it.
4
+ // This module patches the gap locally until the SDK catches up.
5
+
6
+ import type { TuiDialogConfirmProps } from "@opencode-ai/plugin/tui";
7
+
8
+ export type DialogConfirmWithLabel = TuiDialogConfirmProps & {
9
+ label?: string;
10
+ };