opencode-supabase 0.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-supabase",
3
- "version": "0.1.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"
@@ -1,6 +1,6 @@
1
1
  export function createSupabaseCommand(openDialog: () => void) {
2
2
  return {
3
- title: "Connect Supabase",
3
+ title: "Connect to Supabase",
4
4
  value: "supabase.connect",
5
5
  slash: { name: "supabase" },
6
6
  onSelect: openDialog,
@@ -1,5 +1,7 @@
1
1
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
- import { createSignal } from "solid-js";
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";
@@ -13,9 +15,24 @@ type SupabaseDialogProps = {
13
15
  closed: boolean;
14
16
  dismissed?: boolean;
15
17
  preflightPromise?: Promise<void>;
18
+ onboardingPromptSent?: boolean;
19
+ chatSessionID?: string;
16
20
  };
17
21
  };
18
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
+
19
36
  type OAuthState =
20
37
  | { type: "checking_auth" }
21
38
  | { type: "idle" }
@@ -43,13 +60,115 @@ type AuthFlowContext = {
43
60
  api: TuiPluginApi;
44
61
  logger: SupabaseLogger;
45
62
  setState: (state: OAuthState) => void;
46
- onSuccess: () => void;
63
+ onSuccess: () => void | Promise<void>;
47
64
  };
48
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
+
49
86
  function getErrorMessage(error: unknown) {
50
87
  return error instanceof Error ? error.message : String(error);
51
88
  }
52
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
+
53
172
  function parseAuthStatus(instructions: string): AuthStatus {
54
173
  const parsed = JSON.parse(instructions) as Partial<AuthStatus>;
55
174
  if (
@@ -74,6 +193,71 @@ async function openBrowser(url: string, logger: SupabaseLogger) {
74
193
  }
75
194
  }
76
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
+
77
261
  export async function runAuthFlow(context: AuthFlowContext) {
78
262
  let authURL: string | undefined;
79
263
  let completed = false;
@@ -145,7 +329,7 @@ export async function runAuthFlow(context: AuthFlowContext) {
145
329
 
146
330
  if (completed) {
147
331
  try {
148
- context.onSuccess();
332
+ await context.onSuccess();
149
333
  } catch (error) {
150
334
  await context.logger.error("supabase auth success handler failed", {
151
335
  message: getErrorMessage(error),
@@ -227,7 +411,6 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
227
411
 
228
412
  if (nextState.type === "success") {
229
413
  if (lifecycle.dismissed) {
230
- // User dismissed waiting dialog; stay silent
231
414
  return;
232
415
  }
233
416
  props.api.ui.dialog.replace(() =>
@@ -249,15 +432,29 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
249
432
  );
250
433
  };
251
434
 
252
- const startOAuth = () =>
253
- runAuthFlow({
435
+ const startOAuth = async () => {
436
+ lifecycle.dismissed = false;
437
+ if (!lifecycle.chatSessionID) {
438
+ lifecycle.chatSessionID = await ensureChatSession(props.api);
439
+ }
440
+ return runAuthFlow({
254
441
  api: props.api,
255
442
  logger: props.logger,
256
443
  setState,
257
444
  onSuccess: () => {
258
- // Success dialog handles user-facing confirmation
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);
259
455
  },
260
456
  });
457
+ };
261
458
 
262
459
  const retryPreflight = () => {
263
460
  if (lifecycle.preflightPromise) {
@@ -282,6 +479,7 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
282
479
  method: 1,
283
480
  inputs: { action: "disconnect" },
284
481
  });
482
+ props.api.ui.toast({ message: "Disconnected from Supabase" });
285
483
  closeDialog();
286
484
  } catch (error) {
287
485
  await props.logger.warn("supabase disconnect failed", {
@@ -304,18 +502,19 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
304
502
  void retryPreflight();
305
503
  });
306
504
 
307
- return props.api.ui.DialogAlert({
308
- title: "Connect Supabase",
309
- message: "Checking Supabase connection...",
310
- onConfirm: () => closeDialog(true),
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,
311
511
  });
312
512
  }
313
513
 
314
514
  if (currentState.type === "idle") {
315
515
  return props.api.ui.DialogConfirm({
316
- title: "Connect Supabase",
317
- message:
318
- "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.",
319
518
  onConfirm: startOAuth,
320
519
  onCancel: closeDialog,
321
520
  });
@@ -323,25 +522,36 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
323
522
 
324
523
  if (currentState.type === "authorizing") {
325
524
  if (!currentState.url) {
326
- return props.api.ui.DialogAlert({
327
- title: "Connect Supabase",
328
- message: "Starting authorization...",
329
- onConfirm: () => closeDialog(true),
330
- });
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
+ });
331
533
  }
332
534
 
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),
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),
337
543
  });
338
544
  }
339
545
 
340
546
  if (currentState.type === "waiting_callback") {
341
- return props.api.ui.DialogAlert({
342
- title: "Connect Supabase",
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),
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),
345
555
  });
346
556
  }
347
557
 
@@ -360,9 +570,15 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
360
570
 
361
571
  if (currentState.type === "already_connected") {
362
572
  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,
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
+ },
366
582
  onCancel: disconnect,
367
583
  label: "Disconnect",
368
584
  } as import("./opencode-runtime-extensions.ts").DialogConfirmWithLabel);
@@ -377,22 +593,9 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
377
593
  });
378
594
  }
379
595
 
380
- return props.api.ui.DialogConfirm({
596
+ return props.api.ui.DialogAlert({
381
597
  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,
598
+ message: "Your account is ready. Close this dialog and ask me to list your Supabase projects.",
599
+ onConfirm: closeDialog,
397
600
  });
398
601
  }