opencode-supabase 0.0.8 → 0.1.1
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 +2 -2
- package/src/server/auth.ts +91 -2
- package/src/server/tools.ts +186 -32
- package/src/tui/commands.ts +1 -1
- package/src/tui/dialog.tsx +386 -40
- package/src/tui/index.tsx +9 -1
- package/src/tui/opencode-runtime-extensions.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode plugin for Supabase integration with server and TUI components",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"lint": "biome check .",
|
|
27
27
|
"verify:pack": "npm pack --dry-run",
|
|
28
28
|
"typecheck": "bunx tsc --noEmit",
|
|
29
|
-
"test": "bun test",
|
|
29
|
+
"test": "bun test --preload @opentui/solid/preload",
|
|
30
30
|
"changeset": "changeset",
|
|
31
31
|
"version-packages": "changeset version",
|
|
32
32
|
"release": "changeset publish"
|
package/src/server/auth.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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(
|
package/src/tui/commands.ts
CHANGED
package/src/tui/dialog.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
|
-
import {
|
|
2
|
+
import { RGBA, SyntaxStyle, TextAttributes } from "@opentui/core";
|
|
3
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
4
|
+
import type { JSX } from "solid-js";
|
|
3
5
|
|
|
4
6
|
import { formatAuthError } from "../shared/auth-errors.ts";
|
|
5
7
|
import type { SupabaseLogger } from "../shared/log.ts";
|
|
@@ -12,14 +14,33 @@ type SupabaseDialogProps = {
|
|
|
12
14
|
lifecycle?: {
|
|
13
15
|
closed: boolean;
|
|
14
16
|
dismissed?: boolean;
|
|
17
|
+
preflightPromise?: Promise<void>;
|
|
18
|
+
onboardingPromptSent?: boolean;
|
|
19
|
+
chatSessionID?: string;
|
|
15
20
|
};
|
|
16
21
|
};
|
|
17
22
|
|
|
23
|
+
const ONBOARDING_MESSAGE = `Supabase is connected.
|
|
24
|
+
|
|
25
|
+
You can ask me about:
|
|
26
|
+
- your organizations and projects
|
|
27
|
+
- API keys for a project
|
|
28
|
+
- available database regions
|
|
29
|
+
- creating a new project
|
|
30
|
+
|
|
31
|
+
Try this:
|
|
32
|
+
list my Supabase projects`;
|
|
33
|
+
|
|
34
|
+
const onboardedSessionIDsByApi = new WeakMap<TuiPluginApi, Set<string>>();
|
|
35
|
+
|
|
18
36
|
type OAuthState =
|
|
37
|
+
| { type: "checking_auth" }
|
|
19
38
|
| { type: "idle" }
|
|
39
|
+
| { type: "already_connected" }
|
|
20
40
|
| { type: "authorizing"; url: string }
|
|
21
41
|
| { type: "waiting_callback"; url: string }
|
|
22
42
|
| { type: "success" }
|
|
43
|
+
| { type: "unknown"; message: string }
|
|
23
44
|
| { type: "error"; message: string; url?: string };
|
|
24
45
|
|
|
25
46
|
type ApiResponse<T> = { data?: T; error?: unknown };
|
|
@@ -30,17 +51,137 @@ type AuthData = {
|
|
|
30
51
|
method: string;
|
|
31
52
|
};
|
|
32
53
|
|
|
54
|
+
type AuthStatus =
|
|
55
|
+
| { status: "connected"; checked: boolean }
|
|
56
|
+
| { status: "disconnected"; checked: boolean }
|
|
57
|
+
| { status: "refresh_required"; checked: true };
|
|
58
|
+
|
|
33
59
|
type AuthFlowContext = {
|
|
34
60
|
api: TuiPluginApi;
|
|
35
61
|
logger: SupabaseLogger;
|
|
36
62
|
setState: (state: OAuthState) => void;
|
|
37
|
-
onSuccess: () => void
|
|
63
|
+
onSuccess: () => void | Promise<void>;
|
|
38
64
|
};
|
|
39
65
|
|
|
66
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
67
|
+
|
|
68
|
+
const FALLBACK_THEME = {
|
|
69
|
+
primary: RGBA.fromHex("#347d95"),
|
|
70
|
+
selectedListItemText: RGBA.fromHex("#ffffff"),
|
|
71
|
+
text: RGBA.fromHex("#f8f5ea"),
|
|
72
|
+
textMuted: RGBA.fromHex("#9f97aa"),
|
|
73
|
+
backgroundPanel: RGBA.fromHex("#f8f5ea"),
|
|
74
|
+
markdownText: RGBA.fromHex("#5f5875"),
|
|
75
|
+
markdownHeading: RGBA.fromHex("#5f5875"),
|
|
76
|
+
markdownLink: RGBA.fromHex("#347d95"),
|
|
77
|
+
markdownStrong: RGBA.fromHex("#5f5875"),
|
|
78
|
+
markdownEmph: RGBA.fromHex("#8a6f00"),
|
|
79
|
+
markdownCode: RGBA.fromHex("#2e7d32"),
|
|
80
|
+
markdownListItem: RGBA.fromHex("#347d95"),
|
|
81
|
+
markdownBlockQuote: RGBA.fromHex("#8a6f00"),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type DialogTheme = typeof FALLBACK_THEME;
|
|
85
|
+
|
|
40
86
|
function getErrorMessage(error: unknown) {
|
|
41
87
|
return error instanceof Error ? error.message : String(error);
|
|
42
88
|
}
|
|
43
89
|
|
|
90
|
+
function getDialogTheme(api: TuiPluginApi): DialogTheme {
|
|
91
|
+
return {
|
|
92
|
+
...FALLBACK_THEME,
|
|
93
|
+
...((api as { theme?: { current?: Partial<DialogTheme> } }).theme?.current ?? {}),
|
|
94
|
+
} as DialogTheme;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createMarkdownSyntax(theme: DialogTheme) {
|
|
98
|
+
return SyntaxStyle.fromTheme([
|
|
99
|
+
{ scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
|
|
100
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
|
|
101
|
+
{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
|
|
102
|
+
{ scope: ["markup.raw", "markup.raw.block", "markup.raw.inline"], style: { foreground: theme.markdownCode } },
|
|
103
|
+
{ scope: ["markup.link", "markup.link.url"], style: { foreground: theme.markdownLink, underline: true } },
|
|
104
|
+
{ scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
|
|
105
|
+
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
106
|
+
{ scope: ["conceal"], style: { foreground: theme.textMuted } },
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function SpinnerLabel(props: { text: string; color: DialogTheme["textMuted"] }) {
|
|
111
|
+
const [frame, setFrame] = createSignal(0);
|
|
112
|
+
const interval = setInterval(() => {
|
|
113
|
+
setFrame((index) => (index + 1) % SPINNER_FRAMES.length);
|
|
114
|
+
}, 80).unref();
|
|
115
|
+
|
|
116
|
+
onCleanup(() => clearInterval(interval));
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<box flexDirection="row" gap={1}>
|
|
120
|
+
<text fg={props.color}>{SPINNER_FRAMES[frame()]}</text>
|
|
121
|
+
<text fg={props.color}>{props.text}</text>
|
|
122
|
+
</box>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function SupabaseSpinnerDialog(props: {
|
|
127
|
+
api: TuiPluginApi;
|
|
128
|
+
title: string;
|
|
129
|
+
status: string;
|
|
130
|
+
body?: string;
|
|
131
|
+
dismissible?: boolean;
|
|
132
|
+
size?: "medium" | "large" | "xlarge";
|
|
133
|
+
onClose: () => void;
|
|
134
|
+
}): JSX.Element {
|
|
135
|
+
const theme = getDialogTheme(props.api);
|
|
136
|
+
const syntax = createMarkdownSyntax(theme);
|
|
137
|
+
props.api.ui.dialog.setSize(props.size ?? "medium");
|
|
138
|
+
|
|
139
|
+
return Object.assign(() => (
|
|
140
|
+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
|
141
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
142
|
+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
|
143
|
+
{props.title}
|
|
144
|
+
</text>
|
|
145
|
+
{props.dismissible ? (
|
|
146
|
+
<text fg={theme.textMuted} onMouseUp={props.onClose}>
|
|
147
|
+
esc
|
|
148
|
+
</text>
|
|
149
|
+
) : (
|
|
150
|
+
<text fg={theme.textMuted}> </text>
|
|
151
|
+
)}
|
|
152
|
+
</box>
|
|
153
|
+
<box paddingTop={1} paddingBottom={props.body ? 0 : 1}>
|
|
154
|
+
<SpinnerLabel text={props.status} color={theme.textMuted} />
|
|
155
|
+
</box>
|
|
156
|
+
{props.body ? (
|
|
157
|
+
<box paddingBottom={props.dismissible ? 0 : 1}>
|
|
158
|
+
<markdown content={props.body} syntaxStyle={syntax} fg={theme.markdownText} bg={theme.backgroundPanel} />
|
|
159
|
+
</box>
|
|
160
|
+
) : undefined}
|
|
161
|
+
{props.dismissible ? (
|
|
162
|
+
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
163
|
+
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={props.onClose}>
|
|
164
|
+
<text fg={theme.selectedListItemText}>Dismiss</text>
|
|
165
|
+
</box>
|
|
166
|
+
</box>
|
|
167
|
+
) : undefined}
|
|
168
|
+
</box>
|
|
169
|
+
), { onClose: props.dismissible ? props.onClose : () => undefined });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseAuthStatus(instructions: string): AuthStatus {
|
|
173
|
+
const parsed = JSON.parse(instructions) as Partial<AuthStatus>;
|
|
174
|
+
if (
|
|
175
|
+
parsed.status === "connected" ||
|
|
176
|
+
parsed.status === "disconnected" ||
|
|
177
|
+
parsed.status === "refresh_required"
|
|
178
|
+
) {
|
|
179
|
+
return parsed as AuthStatus;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new Error("Invalid Supabase auth status response");
|
|
183
|
+
}
|
|
184
|
+
|
|
44
185
|
async function openBrowser(url: string, logger: SupabaseLogger) {
|
|
45
186
|
try {
|
|
46
187
|
const open = await import("open");
|
|
@@ -52,6 +193,71 @@ async function openBrowser(url: string, logger: SupabaseLogger) {
|
|
|
52
193
|
}
|
|
53
194
|
}
|
|
54
195
|
|
|
196
|
+
async function ensureChatSession(api: TuiPluginApi) {
|
|
197
|
+
const currentRoute = api.route.current;
|
|
198
|
+
let sessionID =
|
|
199
|
+
currentRoute.name === "session" ? (currentRoute.params as { sessionID?: string } | undefined)?.sessionID : undefined;
|
|
200
|
+
|
|
201
|
+
if (!sessionID && currentRoute.name === "home") {
|
|
202
|
+
const response = await api.client.session.create({});
|
|
203
|
+
sessionID = (response.data as { id?: string } | undefined)?.id;
|
|
204
|
+
if (sessionID) {
|
|
205
|
+
api.route.navigate("session", { sessionID });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return sessionID;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function injectOnboardingPrompt(
|
|
213
|
+
api: TuiPluginApi,
|
|
214
|
+
logger: SupabaseLogger,
|
|
215
|
+
lifecycle: NonNullable<SupabaseDialogProps["lifecycle"]>,
|
|
216
|
+
) {
|
|
217
|
+
if (lifecycle.onboardingPromptSent) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!lifecycle.chatSessionID) {
|
|
222
|
+
await logger.warn("supabase onboarding prompt skipped", {
|
|
223
|
+
reason: "missing_session",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const sessionID = lifecycle.chatSessionID;
|
|
229
|
+
const onboardedSessionIDs = onboardedSessionIDsByApi.get(api) ?? new Set<string>();
|
|
230
|
+
onboardedSessionIDsByApi.set(api, onboardedSessionIDs);
|
|
231
|
+
|
|
232
|
+
if (onboardedSessionIDs.has(sessionID)) {
|
|
233
|
+
lifecycle.onboardingPromptSent = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lifecycle.onboardingPromptSent = true;
|
|
238
|
+
onboardedSessionIDs.add(sessionID);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await api.client.session.promptAsync({
|
|
242
|
+
sessionID,
|
|
243
|
+
noReply: true,
|
|
244
|
+
parts: [
|
|
245
|
+
{
|
|
246
|
+
type: "text",
|
|
247
|
+
text: ONBOARDING_MESSAGE,
|
|
248
|
+
ignored: true,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
lifecycle.onboardingPromptSent = false;
|
|
254
|
+
onboardedSessionIDs.delete(sessionID);
|
|
255
|
+
await logger.warn("supabase onboarding prompt failed", {
|
|
256
|
+
message: getErrorMessage(error),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
55
261
|
export async function runAuthFlow(context: AuthFlowContext) {
|
|
56
262
|
let authURL: string | undefined;
|
|
57
263
|
let completed = false;
|
|
@@ -123,7 +329,7 @@ export async function runAuthFlow(context: AuthFlowContext) {
|
|
|
123
329
|
|
|
124
330
|
if (completed) {
|
|
125
331
|
try {
|
|
126
|
-
context.onSuccess();
|
|
332
|
+
await context.onSuccess();
|
|
127
333
|
} catch (error) {
|
|
128
334
|
await context.logger.error("supabase auth success handler failed", {
|
|
129
335
|
message: getErrorMessage(error),
|
|
@@ -132,9 +338,61 @@ export async function runAuthFlow(context: AuthFlowContext) {
|
|
|
132
338
|
}
|
|
133
339
|
}
|
|
134
340
|
|
|
341
|
+
export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "logger" | "setState">) {
|
|
342
|
+
context.setState({ type: "checking_auth" });
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const authResponse = (await context.api.client.provider.oauth.authorize({
|
|
346
|
+
providerID: "supabase",
|
|
347
|
+
method: 1,
|
|
348
|
+
})) as ApiResponse<AuthData>;
|
|
349
|
+
|
|
350
|
+
if (authResponse.error) {
|
|
351
|
+
throw new Error(formatAuthError("start", authResponse.error));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const instructions = authResponse.data?.instructions;
|
|
355
|
+
if (!instructions) {
|
|
356
|
+
throw new Error("Invalid Supabase auth status response");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const status = parseAuthStatus(instructions);
|
|
360
|
+
if (status.status === "connected") {
|
|
361
|
+
context.setState({ type: "already_connected" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (status.status === "disconnected") {
|
|
366
|
+
context.setState({ type: "idle" });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const callbackResponse = (await context.api.client.provider.oauth.callback({
|
|
371
|
+
providerID: "supabase",
|
|
372
|
+
method: 1,
|
|
373
|
+
})) as ApiResponse<boolean>;
|
|
374
|
+
|
|
375
|
+
if (callbackResponse.error) {
|
|
376
|
+
throw new Error(formatAuthError("callback", callbackResponse.error));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (callbackResponse.data === true) {
|
|
380
|
+
context.setState({ type: "already_connected" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
context.setState({ type: "idle" });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
context.setState({
|
|
387
|
+
type: "unknown",
|
|
388
|
+
message: formatAuthError("unknown", error),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
135
393
|
export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
136
394
|
const lifecycle = props.lifecycle ?? { closed: false };
|
|
137
|
-
const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "
|
|
395
|
+
const [state, setStateSignal] = createSignal<OAuthState>(props.initialState ?? { type: "checking_auth" });
|
|
138
396
|
|
|
139
397
|
const closeDialog = (dismissed = false) => {
|
|
140
398
|
lifecycle.closed = true;
|
|
@@ -153,7 +411,6 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
153
411
|
|
|
154
412
|
if (nextState.type === "success") {
|
|
155
413
|
if (lifecycle.dismissed) {
|
|
156
|
-
// User dismissed waiting dialog; stay silent
|
|
157
414
|
return;
|
|
158
415
|
}
|
|
159
416
|
props.api.ui.dialog.replace(() =>
|
|
@@ -175,23 +432,89 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
175
432
|
);
|
|
176
433
|
};
|
|
177
434
|
|
|
178
|
-
const startOAuth = () =>
|
|
179
|
-
|
|
435
|
+
const startOAuth = async () => {
|
|
436
|
+
lifecycle.dismissed = false;
|
|
437
|
+
if (!lifecycle.chatSessionID) {
|
|
438
|
+
lifecycle.chatSessionID = await ensureChatSession(props.api);
|
|
439
|
+
}
|
|
440
|
+
return runAuthFlow({
|
|
180
441
|
api: props.api,
|
|
181
442
|
logger: props.logger,
|
|
182
443
|
setState,
|
|
183
444
|
onSuccess: () => {
|
|
184
|
-
|
|
445
|
+
if (lifecycle.dismissed) {
|
|
446
|
+
props.api.ui.toast({ message: "Supabase connected" });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (lifecycle.closed) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return injectOnboardingPrompt(props.api, props.logger, lifecycle);
|
|
185
455
|
},
|
|
186
456
|
});
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const retryPreflight = () => {
|
|
460
|
+
if (lifecycle.preflightPromise) {
|
|
461
|
+
return lifecycle.preflightPromise;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
lifecycle.preflightPromise = runAuthPreflight({
|
|
465
|
+
api: props.api,
|
|
466
|
+
logger: props.logger,
|
|
467
|
+
setState,
|
|
468
|
+
}).finally(() => {
|
|
469
|
+
lifecycle.preflightPromise = undefined;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return lifecycle.preflightPromise;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const disconnect = async () => {
|
|
476
|
+
try {
|
|
477
|
+
await props.api.client.provider.oauth.authorize({
|
|
478
|
+
providerID: "supabase",
|
|
479
|
+
method: 1,
|
|
480
|
+
inputs: { action: "disconnect" },
|
|
481
|
+
});
|
|
482
|
+
props.api.ui.toast({ message: "Disconnected from Supabase" });
|
|
483
|
+
closeDialog();
|
|
484
|
+
} catch (error) {
|
|
485
|
+
await props.logger.warn("supabase disconnect failed", {
|
|
486
|
+
message: getErrorMessage(error),
|
|
487
|
+
});
|
|
488
|
+
setState({
|
|
489
|
+
type: "unknown",
|
|
490
|
+
message: `Couldn't disconnect from Supabase right now. ${formatAuthError("unknown", error)}`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
};
|
|
187
494
|
|
|
188
495
|
const currentState = state();
|
|
189
496
|
|
|
497
|
+
if (currentState.type === "checking_auth") {
|
|
498
|
+
queueMicrotask(() => {
|
|
499
|
+
if (lifecycle.closed || lifecycle.preflightPromise) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
void retryPreflight();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
return SupabaseSpinnerDialog({
|
|
506
|
+
api: props.api,
|
|
507
|
+
title: "Connect to Supabase",
|
|
508
|
+
status: "Checking Supabase connection...",
|
|
509
|
+
body: "No action needed. This should only take a few seconds.",
|
|
510
|
+
onClose: () => undefined,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
190
514
|
if (currentState.type === "idle") {
|
|
191
515
|
return props.api.ui.DialogConfirm({
|
|
192
|
-
title: "Connect Supabase",
|
|
193
|
-
message:
|
|
194
|
-
"This will open a browser window to authorize OpenCode to access your Supabase account. Continue?",
|
|
516
|
+
title: "Connect your Supabase account",
|
|
517
|
+
message: "Open your browser to authorize OpenCode to access your Supabase account.",
|
|
195
518
|
onConfirm: startOAuth,
|
|
196
519
|
onCancel: closeDialog,
|
|
197
520
|
});
|
|
@@ -199,25 +522,36 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
199
522
|
|
|
200
523
|
if (currentState.type === "authorizing") {
|
|
201
524
|
if (!currentState.url) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
525
|
+
return SupabaseSpinnerDialog({
|
|
526
|
+
api: props.api,
|
|
527
|
+
title: "Connect to Supabase",
|
|
528
|
+
status: "Starting authorization...",
|
|
529
|
+
body: "Opening your browser. You can close this dialog; auth completes only after browser approval.",
|
|
530
|
+
dismissible: true,
|
|
531
|
+
onClose: () => closeDialog(true),
|
|
532
|
+
});
|
|
207
533
|
}
|
|
208
534
|
|
|
209
|
-
return
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
535
|
+
return SupabaseSpinnerDialog({
|
|
536
|
+
api: props.api,
|
|
537
|
+
title: "Connect to Supabase",
|
|
538
|
+
status: "Waiting for browser authorization...",
|
|
539
|
+
body: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nYou can close this dialog; auth completes only after browser approval.`,
|
|
540
|
+
dismissible: true,
|
|
541
|
+
size: "large",
|
|
542
|
+
onClose: () => closeDialog(true),
|
|
213
543
|
});
|
|
214
544
|
}
|
|
215
545
|
|
|
216
546
|
if (currentState.type === "waiting_callback") {
|
|
217
|
-
return
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
547
|
+
return SupabaseSpinnerDialog({
|
|
548
|
+
api: props.api,
|
|
549
|
+
title: "Connect to Supabase",
|
|
550
|
+
status: "Waiting for browser authorization...",
|
|
551
|
+
body: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nYou can close this dialog; auth completes only after browser approval.`,
|
|
552
|
+
dismissible: true,
|
|
553
|
+
size: "large",
|
|
554
|
+
onClose: () => closeDialog(true),
|
|
221
555
|
});
|
|
222
556
|
}
|
|
223
557
|
|
|
@@ -234,22 +568,34 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
234
568
|
});
|
|
235
569
|
}
|
|
236
570
|
|
|
237
|
-
|
|
571
|
+
if (currentState.type === "already_connected") {
|
|
572
|
+
return props.api.ui.DialogConfirm({
|
|
573
|
+
title: "You're all set",
|
|
574
|
+
message: "Your Supabase account is connected and ready to go.\n\nClose this dialog to continue, or disconnect to sign out.",
|
|
575
|
+
onConfirm: async () => {
|
|
576
|
+
if (!lifecycle.chatSessionID) {
|
|
577
|
+
lifecycle.chatSessionID = await ensureChatSession(props.api);
|
|
578
|
+
}
|
|
579
|
+
await injectOnboardingPrompt(props.api, props.logger, lifecycle);
|
|
580
|
+
closeDialog();
|
|
581
|
+
},
|
|
582
|
+
onCancel: disconnect,
|
|
583
|
+
label: "Disconnect",
|
|
584
|
+
} as import("./opencode-runtime-extensions.ts").DialogConfirmWithLabel);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (currentState.type === "unknown") {
|
|
588
|
+
return props.api.ui.DialogConfirm({
|
|
589
|
+
title: "Supabase connection status unknown",
|
|
590
|
+
message: `${currentState.message}\n\nConfirm to retry, or cancel to continue without changing saved auth.`,
|
|
591
|
+
onConfirm: retryPreflight,
|
|
592
|
+
onCancel: closeDialog,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return props.api.ui.DialogAlert({
|
|
238
597
|
title: "Connected to Supabase",
|
|
239
|
-
message:
|
|
240
|
-
|
|
241
|
-
onConfirm: async () => {
|
|
242
|
-
try {
|
|
243
|
-
await props.api.client.tui.appendPrompt({
|
|
244
|
-
text: "list my Supabase projects",
|
|
245
|
-
});
|
|
246
|
-
} catch (error) {
|
|
247
|
-
await props.logger.warn("supabase append prompt failed", {
|
|
248
|
-
message: getErrorMessage(error),
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
closeDialog();
|
|
252
|
-
},
|
|
253
|
-
onCancel: closeDialog,
|
|
598
|
+
message: "Your account is ready. Close this dialog and ask me to list your Supabase projects.",
|
|
599
|
+
onConfirm: closeDialog,
|
|
254
600
|
});
|
|
255
601
|
}
|
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
|
+
};
|