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 +1 -1
- package/src/server/auth-html.ts +15 -1
- package/src/server/auth.ts +95 -7
- package/src/server/tools.ts +203 -32
- package/src/shared/auth-errors.ts +47 -0
- package/src/tui/dialog.tsx +323 -94
- package/src/tui/index.tsx +9 -1
- package/src/tui/opencode-runtime-extensions.ts +10 -0
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,
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
}
|
package/src/server/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tui/dialog.tsx
CHANGED
|
@@ -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: "
|
|
26
|
+
| { type: "unknown"; message: string }
|
|
27
|
+
| { type: "error"; message: string; url?: string };
|
|
18
28
|
|
|
19
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
148
|
+
context.onSuccess();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await context.logger.error("supabase auth success handler failed", {
|
|
151
|
+
message: getErrorMessage(error),
|
|
36
152
|
});
|
|
37
|
-
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
38
156
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
166
|
+
if (authResponse.error) {
|
|
167
|
+
throw new Error(formatAuthError("start", authResponse.error));
|
|
168
|
+
}
|
|
57
169
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
170
|
+
const instructions = authResponse.data?.instructions;
|
|
171
|
+
if (!instructions) {
|
|
172
|
+
throw new Error("Invalid Supabase auth status response");
|
|
173
|
+
}
|
|
61
174
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
175
|
+
const status = parseAuthStatus(instructions);
|
|
176
|
+
if (status.status === "connected") {
|
|
177
|
+
context.setState({ type: "already_connected" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
67
180
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
80
|
-
|
|
186
|
+
const callbackResponse = (await context.api.client.provider.oauth.callback({
|
|
187
|
+
providerID: "supabase",
|
|
188
|
+
method: 1,
|
|
189
|
+
})) as ApiResponse<boolean>;
|
|
81
190
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
195
|
+
if (callbackResponse.data === true) {
|
|
196
|
+
context.setState({ type: "already_connected" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
93
199
|
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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({
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
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:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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: `
|
|
151
|
-
onConfirm:
|
|
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.
|
|
349
|
+
return props.api.ui.DialogConfirm({
|
|
157
350
|
title: "Authorization Failed",
|
|
158
|
-
message: currentState.
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
+
};
|