opencode-supabase 0.0.8 → 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.8",
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",
@@ -12,7 +12,8 @@ import type { SupabaseLogger } from "../shared/log.ts";
12
12
  import { buildAuthorizeUrl, generatePKCE, generateState } from "../shared/oauth.ts";
13
13
  import type { FetchLike, SupabaseTokenResponse } from "../shared/types.ts";
14
14
  import { HTML_SUCCESS, htmlError } from "./auth-html.ts";
15
- import { writeSavedAuth } from "./store.ts";
15
+ import { readSavedAuth, writeSavedAuth } from "./store.ts";
16
+ import { NOT_CONNECTED_MESSAGE, disconnectSupabaseAuth, ensureSupabaseToolAuth } from "./tools.ts";
16
17
 
17
18
  const CALLBACK_PATH = "/auth/callback";
18
19
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
@@ -33,9 +34,47 @@ type AuthDeps = {
33
34
  setCallbackTimeout?: typeof setTimeout;
34
35
  };
35
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
+
36
53
  let server: ReturnType<typeof Bun.serve> | undefined;
37
54
  let serverPort: number | undefined;
38
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
+ }
39
78
 
40
79
  function callbackUrl(port: number) {
41
80
  return `http://localhost:${port}${CALLBACK_PATH}`;
@@ -268,7 +307,7 @@ function waitForCallback(
268
307
  }
269
308
 
270
309
  export function createSupabaseAuth(
271
- input: Pick<PluginInput, "directory" | "worktree">,
310
+ input: SupabaseAuthInput,
272
311
  options?: PluginOptions,
273
312
  deps: AuthDeps = {},
274
313
  ) {
@@ -307,6 +346,56 @@ export function createSupabaseAuth(
307
346
  };
308
347
  },
309
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
+ },
310
399
  ],
311
400
  };
312
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
+ );
321
+
322
+ const nextAuth: SavedAuth = {
323
+ access: refreshed.access_token,
324
+ refresh: refreshed.refresh_token,
325
+ expires: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
326
+ };
215
327
 
216
- if (error instanceof BrokerClientError) {
217
- throw new Error(`Supabase auth refresh failed: ${error.message}`);
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(
@@ -12,14 +12,18 @@ type SupabaseDialogProps = {
12
12
  lifecycle?: {
13
13
  closed: boolean;
14
14
  dismissed?: boolean;
15
+ preflightPromise?: Promise<void>;
15
16
  };
16
17
  };
17
18
 
18
19
  type OAuthState =
20
+ | { type: "checking_auth" }
19
21
  | { type: "idle" }
22
+ | { type: "already_connected" }
20
23
  | { type: "authorizing"; url: string }
21
24
  | { type: "waiting_callback"; url: string }
22
25
  | { type: "success" }
26
+ | { type: "unknown"; message: string }
23
27
  | { type: "error"; message: string; url?: string };
24
28
 
25
29
  type ApiResponse<T> = { data?: T; error?: unknown };
@@ -30,6 +34,11 @@ type AuthData = {
30
34
  method: string;
31
35
  };
32
36
 
37
+ type AuthStatus =
38
+ | { status: "connected"; checked: boolean }
39
+ | { status: "disconnected"; checked: boolean }
40
+ | { status: "refresh_required"; checked: true };
41
+
33
42
  type AuthFlowContext = {
34
43
  api: TuiPluginApi;
35
44
  logger: SupabaseLogger;
@@ -41,6 +50,19 @@ function getErrorMessage(error: unknown) {
41
50
  return error instanceof Error ? error.message : String(error);
42
51
  }
43
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
+
44
66
  async function openBrowser(url: string, logger: SupabaseLogger) {
45
67
  try {
46
68
  const open = await import("open");
@@ -132,9 +154,61 @@ export async function runAuthFlow(context: AuthFlowContext) {
132
154
  }
133
155
  }
134
156
 
157
+ export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "logger" | "setState">) {
158
+ context.setState({ type: "checking_auth" });
159
+
160
+ try {
161
+ const authResponse = (await context.api.client.provider.oauth.authorize({
162
+ providerID: "supabase",
163
+ method: 1,
164
+ })) as ApiResponse<AuthData>;
165
+
166
+ if (authResponse.error) {
167
+ throw new Error(formatAuthError("start", authResponse.error));
168
+ }
169
+
170
+ const instructions = authResponse.data?.instructions;
171
+ if (!instructions) {
172
+ throw new Error("Invalid Supabase auth status response");
173
+ }
174
+
175
+ const status = parseAuthStatus(instructions);
176
+ if (status.status === "connected") {
177
+ context.setState({ type: "already_connected" });
178
+ return;
179
+ }
180
+
181
+ if (status.status === "disconnected") {
182
+ context.setState({ type: "idle" });
183
+ return;
184
+ }
185
+
186
+ const callbackResponse = (await context.api.client.provider.oauth.callback({
187
+ providerID: "supabase",
188
+ method: 1,
189
+ })) as ApiResponse<boolean>;
190
+
191
+ if (callbackResponse.error) {
192
+ throw new Error(formatAuthError("callback", callbackResponse.error));
193
+ }
194
+
195
+ if (callbackResponse.data === true) {
196
+ context.setState({ type: "already_connected" });
197
+ return;
198
+ }
199
+
200
+ context.setState({ type: "idle" });
201
+ } catch (error) {
202
+ context.setState({
203
+ type: "unknown",
204
+ message: formatAuthError("unknown", error),
205
+ });
206
+ }
207
+ }
208
+
135
209
  export function SupabaseDialog(props: SupabaseDialogProps) {
136
210
  const lifecycle = props.lifecycle ?? { closed: false };
137
- const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "idle" });
211
+ const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "checking_auth" });
138
212
 
139
213
  const closeDialog = (dismissed = false) => {
140
214
  lifecycle.closed = true;
@@ -185,8 +259,58 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
185
259
  },
186
260
  });
187
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();
286
+ } catch (error) {
287
+ await props.logger.warn("supabase disconnect failed", {
288
+ message: getErrorMessage(error),
289
+ });
290
+ setState({
291
+ type: "unknown",
292
+ message: `Couldn't disconnect from Supabase right now. ${formatAuthError("unknown", error)}`,
293
+ });
294
+ }
295
+ };
296
+
188
297
  const currentState = state();
189
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
+
190
314
  if (currentState.type === "idle") {
191
315
  return props.api.ui.DialogConfirm({
192
316
  title: "Connect Supabase",
@@ -234,6 +358,25 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
234
358
  });
235
359
  }
236
360
 
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
+
237
380
  return props.api.ui.DialogConfirm({
238
381
  title: "Connected to Supabase",
239
382
  message:
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
+ };