opencode-supabase 0.0.3 → 0.0.4-alpha.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/README.md CHANGED
@@ -20,6 +20,16 @@ Launch `opencode` in your project, then run:
20
20
 
21
21
  Connect your account and ask your agent about Supabase capabilities.
22
22
 
23
+ ## Debug Logging
24
+
25
+ If you hit auth or tool errors and need logs for an issue, run OpenCode like this and share `opencode-supabase-debug.log`:
26
+
27
+ ```bash
28
+ opencode --log-level DEBUG --print-logs 2>opencode-supabase-debug.log
29
+ ```
30
+
31
+ Without `--print-logs`, OpenCode writes logs to its default log directory, documented as `~/.local/share/opencode/log/` on macOS/Linux and `%USERPROFILE%\.local\share\opencode\log` on Windows.
32
+
23
33
  ## Available today
24
34
 
25
35
  - **Connect** your Supabase account from OpenCode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-supabase",
3
- "version": "0.0.3",
3
+ "version": "0.0.4-alpha.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Supabase integration with server and TUI components",
6
6
  "license": "Apache-2.0",
@@ -7,6 +7,7 @@ import {
7
7
  type BrokerConfig,
8
8
  } from "../shared/broker.ts";
9
9
  import { readSupabaseConfig } from "../shared/cfg.ts";
10
+ import type { SupabaseLogger } from "../shared/log.ts";
10
11
  import { buildAuthorizeUrl, generatePKCE, generateState } from "../shared/oauth.ts";
11
12
  import type { FetchLike, SupabaseTokenResponse } from "../shared/types.ts";
12
13
  import { HTML_SUCCESS, htmlError } from "./auth-html.ts";
@@ -25,6 +26,8 @@ type PendingAuth = {
25
26
 
26
27
  type AuthDeps = {
27
28
  fetch?: FetchLike;
29
+ logger?: SupabaseLogger;
30
+ setCallbackTimeout?: typeof setTimeout;
28
31
  };
29
32
 
30
33
  let server: ReturnType<typeof Bun.serve> | undefined;
@@ -69,6 +72,10 @@ async function ensureServer(
69
72
  baseUrl: config.brokerBaseUrl,
70
73
  };
71
74
 
75
+ await deps.logger?.info("supabase callback server started", {
76
+ port,
77
+ });
78
+
72
79
  server = Bun.serve({
73
80
  port,
74
81
  async fetch(req) {
@@ -78,6 +85,11 @@ async function ensureServer(
78
85
  }
79
86
 
80
87
  const state = url.searchParams.get("state");
88
+ await deps.logger?.debug("supabase auth callback received", {
89
+ has_state: Boolean(state),
90
+ has_code: Boolean(url.searchParams.get("code")),
91
+ has_error: Boolean(url.searchParams.get("error")),
92
+ });
81
93
  if (!state) {
82
94
  return new Response(htmlError("Missing required state parameter - potential CSRF attack"), {
83
95
  status: 400,
@@ -98,6 +110,9 @@ async function ensureServer(
98
110
  if (error) {
99
111
  clearTimeout(pending.timeout);
100
112
  pendingAuths.delete(state);
113
+ await deps.logger?.error("supabase auth failed", {
114
+ reason: "provider_denied",
115
+ });
101
116
  pending.reject(new Error(errorDescription || error));
102
117
  return new Response(htmlError(errorDescription || error), {
103
118
  headers: { "Content-Type": "text/html" },
@@ -108,6 +123,9 @@ async function ensureServer(
108
123
  if (!code) {
109
124
  clearTimeout(pending.timeout);
110
125
  pendingAuths.delete(state);
126
+ await deps.logger?.error("supabase auth failed", {
127
+ reason: "missing_code",
128
+ });
111
129
  pending.reject(new Error("Missing authorization code"));
112
130
  return new Response(htmlError("Missing authorization code"), {
113
131
  status: 400,
@@ -127,6 +145,7 @@ async function ensureServer(
127
145
  code_verifier: pending.codeVerifier,
128
146
  },
129
147
  deps.fetch,
148
+ deps.logger,
130
149
  );
131
150
 
132
151
  const expires = Date.now() + (tokens.expires_in || 3600) * 1000;
@@ -138,6 +157,10 @@ async function ensureServer(
138
157
 
139
158
  pending.resolve({ tokens, expires });
140
159
 
160
+ await deps.logger?.info("supabase auth completed", {
161
+ status: "success",
162
+ });
163
+
141
164
  return new Response(HTML_SUCCESS, {
142
165
  headers: { "Content-Type": "text/html" },
143
166
  });
@@ -146,6 +169,11 @@ async function ensureServer(
146
169
  ? `Authorization failed: ${cause.message}`
147
170
  : "Authorization failed";
148
171
 
172
+ await deps.logger?.error("supabase auth failed", {
173
+ status: cause instanceof BrokerClientError ? cause.status : 400,
174
+ broker_error: cause instanceof BrokerClientError,
175
+ });
176
+
149
177
  pending.reject(cause instanceof Error ? cause : new Error(String(cause)));
150
178
 
151
179
  return new Response(htmlError(errorMessage), {
@@ -159,11 +187,20 @@ async function ensureServer(
159
187
  serverPort = port;
160
188
  }
161
189
 
162
- function waitForCallback(state: string, codeVerifier: string, redirectUri: string) {
190
+ function waitForCallback(
191
+ state: string,
192
+ codeVerifier: string,
193
+ redirectUri: string,
194
+ deps: AuthDeps,
195
+ ) {
163
196
  return new Promise<{ tokens: SupabaseTokenResponse; expires: number }>((resolve, reject) => {
164
- const timeout = setTimeout(() => {
197
+ const scheduleTimeout = deps.setCallbackTimeout ?? setTimeout;
198
+ const timeout = scheduleTimeout(() => {
165
199
  if (!pendingAuths.has(state)) return;
166
200
  pendingAuths.delete(state);
201
+ void deps.logger?.error("supabase auth callback timed out", {
202
+ reason: "timeout",
203
+ });
167
204
  reject(new Error("OAuth callback timeout - authorization took too long"));
168
205
  }, CALLBACK_TIMEOUT_MS);
169
206
 
@@ -192,10 +229,13 @@ export function createSupabaseAuth(
192
229
  label: "Supabase",
193
230
  async authorize() {
194
231
  await ensureServer(config.oauthPort, config, input, deps);
232
+ await deps.logger?.info("supabase auth started", {
233
+ port: config.oauthPort,
234
+ });
195
235
  const pkce = await generatePKCE();
196
236
  const state = generateState();
197
237
  const redirectUri = callbackUrl(config.oauthPort);
198
- const callbackPromise = waitForCallback(state, pkce.verifier, redirectUri);
238
+ const callbackPromise = waitForCallback(state, pkce.verifier, redirectUri, deps);
199
239
 
200
240
  return {
201
241
  url: buildAuthorizeUrl(config, redirectUri, pkce, state),
@@ -1,12 +1,17 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
 
3
+ import { createServerLogWriter, createSupabaseLogger } from "../shared/log.ts";
3
4
  import { createSupabaseAuth } from "./auth.ts";
4
5
  import { createSupabaseTools } from "./tools.ts";
5
6
 
6
7
  const server: Plugin = async (input, options) => {
8
+ const logger = createSupabaseLogger({
9
+ write: createServerLogWriter(input.client),
10
+ });
11
+
7
12
  return {
8
- auth: createSupabaseAuth(input, options),
9
- tool: createSupabaseTools(input, options),
13
+ auth: createSupabaseAuth(input, options, { logger }),
14
+ tool: createSupabaseTools(input, options, { logger }),
10
15
  };
11
16
  };
12
17
 
@@ -8,11 +8,13 @@ import {
8
8
  } from "../shared/broker.ts";
9
9
  import { supabaseManagementApiFetch } from "../shared/api.ts";
10
10
  import { readSupabaseConfig } from "../shared/cfg.ts";
11
+ import type { SupabaseLogger } from "../shared/log.ts";
11
12
  import type { FetchLike } from "../shared/types.ts";
12
13
  import { clearSavedAuth, readSavedAuth, writeSavedAuth, type SavedAuth } from "./store.ts";
13
14
 
14
15
  type ToolDeps = {
15
16
  fetch?: FetchLike;
17
+ logger?: SupabaseLogger;
16
18
  };
17
19
 
18
20
  type HostAuthWriter = {
@@ -58,40 +60,86 @@ function generateRandomString(length: number) {
58
60
  .slice(0, length);
59
61
  }
60
62
 
63
+ function sanitizeToolArgs(name: string, args: Record<string, unknown>) {
64
+ const next = { ...args };
65
+ if (name === "supabase_create_project" && typeof next.db_pass === "string") {
66
+ next.db_pass = "[redacted]";
67
+ }
68
+ return next;
69
+ }
70
+
61
71
  async function executeSupabaseRequest(
62
72
  input: SupabaseToolInput,
63
73
  options: PluginOptions | undefined,
64
74
  deps: ToolDeps,
75
+ toolName: string,
76
+ context: SupabaseToolContext,
65
77
  path: string,
66
78
  errorLabel: string,
67
79
  init?: RequestInit,
68
80
  ) {
69
- const config = readSupabaseConfig(options);
70
- const auth = await ensureSupabaseToolAuth(input, options, deps);
71
- const response = await supabaseManagementApiFetch(
72
- config,
73
- auth.access,
74
- path,
75
- init,
76
- deps.fetch,
77
- );
81
+ const startedAt = Date.now();
82
+ await deps.logger?.info("supabase tool started", {
83
+ tool: toolName,
84
+ sessionID: context.sessionID,
85
+ messageID: context.messageID,
86
+ agent: context.agent,
87
+ });
88
+ try {
89
+ const config = readSupabaseConfig(options);
90
+ const auth = await ensureSupabaseToolAuth(input, options, deps);
91
+ const response = await supabaseManagementApiFetch(
92
+ config,
93
+ auth.access,
94
+ path,
95
+ init,
96
+ deps.fetch,
97
+ );
78
98
 
79
- if (!response.ok) {
80
- const body = await response.text().catch(() => "");
81
- throw new Error(`Failed to ${errorLabel}: ${response.status} ${body}`.trim());
82
- }
99
+ await deps.logger?.debug("supabase api response received", {
100
+ tool: toolName,
101
+ path,
102
+ status: response.status,
103
+ });
104
+
105
+ if (!response.ok) {
106
+ const body = await response.text().catch(() => "");
107
+ await deps.logger?.error("supabase tool failed", {
108
+ tool: toolName,
109
+ path,
110
+ status: response.status,
111
+ });
112
+ throw new Error(`Failed to ${errorLabel}: ${response.status} ${body}`.trim());
113
+ }
114
+
115
+ const payload = await response.json();
116
+
117
+ await deps.logger?.info("supabase tool completed", {
118
+ tool: toolName,
119
+ duration_ms: Date.now() - startedAt,
120
+ });
83
121
 
84
- return JSON.stringify(await response.json(), null, 2);
122
+ return JSON.stringify(payload, null, 2);
123
+ } catch (error) {
124
+ await deps.logger?.error("supabase tool failed", {
125
+ tool: toolName,
126
+ path,
127
+ reason: error instanceof Error ? error.message : String(error),
128
+ });
129
+ throw error;
130
+ }
85
131
  }
86
132
 
87
133
  async function executeSupabaseGet(
88
134
  input: SupabaseToolInput,
89
135
  options: PluginOptions | undefined,
90
136
  deps: ToolDeps,
137
+ toolName: string,
138
+ context: SupabaseToolContext,
91
139
  path: string,
92
140
  errorLabel: string,
93
141
  ) {
94
- return executeSupabaseRequest(input, options, deps, path, errorLabel);
142
+ return executeSupabaseRequest(input, options, deps, toolName, context, path, errorLabel);
95
143
  }
96
144
 
97
145
  async function setHostAuth(
@@ -143,6 +191,7 @@ export async function ensureSupabaseToolAuth(
143
191
  { baseUrl: config.brokerBaseUrl },
144
192
  { refresh_token: saved.auth.refresh },
145
193
  deps.fetch,
194
+ deps.logger,
146
195
  );
147
196
 
148
197
  const nextAuth: SavedAuth = {
@@ -181,14 +230,30 @@ export function createSupabaseTools(
181
230
  description: "List all Supabase organizations for the authenticated user.",
182
231
  args: {},
183
232
  async execute(_args, _context: SupabaseToolContext) {
184
- return executeSupabaseGet(input, options, deps, "/organizations", "list organizations");
233
+ return executeSupabaseGet(
234
+ input,
235
+ options,
236
+ deps,
237
+ "supabase_list_organizations",
238
+ _context,
239
+ "/organizations",
240
+ "list organizations",
241
+ );
185
242
  },
186
243
  }),
187
244
  supabase_list_projects: tool({
188
245
  description: "List all Supabase projects for the authenticated user.",
189
246
  args: {},
190
247
  async execute(_args, _context: SupabaseToolContext) {
191
- return executeSupabaseGet(input, options, deps, "/projects", "list projects");
248
+ return executeSupabaseGet(
249
+ input,
250
+ options,
251
+ deps,
252
+ "supabase_list_projects",
253
+ _context,
254
+ "/projects",
255
+ "list projects",
256
+ );
192
257
  },
193
258
  }),
194
259
  supabase_get_project_api_keys: tool({
@@ -201,6 +266,8 @@ export function createSupabaseTools(
201
266
  input,
202
267
  options,
203
268
  deps,
269
+ "supabase_get_project_api_keys",
270
+ _context,
204
271
  `/projects/${args.project_ref}/api-keys`,
205
272
  "get API keys",
206
273
  );
@@ -215,10 +282,16 @@ export function createSupabaseTools(
215
282
  db_pass: tool.schema.string().describe("Database password").optional(),
216
283
  },
217
284
  async execute(args, _context: SupabaseToolContext) {
285
+ await deps.logger?.debug("supabase tool args prepared", {
286
+ tool: "supabase_create_project",
287
+ args: sanitizeToolArgs("supabase_create_project", args),
288
+ });
218
289
  return executeSupabaseRequest(
219
290
  input,
220
291
  options,
221
292
  deps,
293
+ "supabase_create_project",
294
+ _context,
222
295
  "/projects",
223
296
  "create project",
224
297
  {
@@ -1,4 +1,5 @@
1
1
  import type { FetchLike, SupabaseTokenResponse } from "./types.ts";
2
+ import type { SupabaseLogger } from "./log.ts";
2
3
 
3
4
  export type BrokerConfig = {
4
5
  baseUrl: string;
@@ -95,9 +96,14 @@ async function makeBrokerRequest(
95
96
  endpoint: string,
96
97
  body: unknown,
97
98
  fetchImpl: FetchLike,
99
+ logger?: SupabaseLogger,
98
100
  ): Promise<SupabaseTokenResponse> {
99
101
  const url = `${config.baseUrl.replace(/\/$/, "")}${endpoint}`;
100
102
 
103
+ await logger?.debug("supabase broker request started", {
104
+ endpoint,
105
+ });
106
+
101
107
  let response: Response;
102
108
 
103
109
  try {
@@ -110,6 +116,10 @@ async function makeBrokerRequest(
110
116
  body: JSON.stringify(body),
111
117
  });
112
118
  } catch (cause) {
119
+ await logger?.error("supabase broker request failed", {
120
+ endpoint,
121
+ status: 502,
122
+ });
113
123
  throw new BrokerClientError({
114
124
  code: "upstream_error",
115
125
  message: "broker request failed",
@@ -122,6 +132,10 @@ async function makeBrokerRequest(
122
132
  try {
123
133
  payload = await response.json();
124
134
  } catch {
135
+ await logger?.error("supabase broker response invalid", {
136
+ endpoint,
137
+ status: response.status,
138
+ });
125
139
  throw new BrokerClientError({
126
140
  code: "upstream_error",
127
141
  message: "broker returned an invalid response",
@@ -129,6 +143,11 @@ async function makeBrokerRequest(
129
143
  });
130
144
  }
131
145
 
146
+ await logger?.debug("supabase broker response received", {
147
+ endpoint,
148
+ status: response.status,
149
+ });
150
+
132
151
  if (!response.ok) {
133
152
  const errorBody = payload as Record<string, unknown> | undefined;
134
153
  const error = errorBody?.error as Record<string, unknown> | undefined;
@@ -136,6 +155,15 @@ async function makeBrokerRequest(
136
155
  const code = (error?.code as BrokerErrorCode) || "upstream_error";
137
156
  const message = (error?.message as string) || "broker request failed";
138
157
 
158
+ await logger?.error(
159
+ endpoint === "/exchange" ? "supabase broker exchange failed" : "supabase broker refresh failed",
160
+ {
161
+ endpoint,
162
+ status: response.status,
163
+ code,
164
+ },
165
+ );
166
+
139
167
  throw new BrokerClientError({
140
168
  code,
141
169
  message,
@@ -150,6 +178,7 @@ export async function exchangeCodeThroughBroker(
150
178
  config: BrokerConfig,
151
179
  input: ExchangeRequest,
152
180
  fetchImpl: FetchLike = fetch,
181
+ logger?: SupabaseLogger,
153
182
  ): Promise<SupabaseTokenResponse> {
154
183
  return makeBrokerRequest(
155
184
  config,
@@ -160,6 +189,7 @@ export async function exchangeCodeThroughBroker(
160
189
  redirect_uri: input.redirect_uri,
161
190
  },
162
191
  fetchImpl,
192
+ logger,
163
193
  );
164
194
  }
165
195
 
@@ -167,6 +197,7 @@ export async function refreshTokenThroughBroker(
167
197
  config: BrokerConfig,
168
198
  input: RefreshRequest,
169
199
  fetchImpl: FetchLike = fetch,
200
+ logger?: SupabaseLogger,
170
201
  ): Promise<SupabaseTokenResponse> {
171
202
  return makeBrokerRequest(
172
203
  config,
@@ -175,5 +206,6 @@ export async function refreshTokenThroughBroker(
175
206
  refresh_token: input.refresh_token,
176
207
  },
177
208
  fetchImpl,
209
+ logger,
178
210
  );
179
211
  }
@@ -0,0 +1,57 @@
1
+ export type SupabaseLogLevel = "debug" | "info" | "warn" | "error";
2
+
3
+ export type SupabaseLogger = ReturnType<typeof createSupabaseLogger>;
4
+
5
+ export type LogEntry = {
6
+ service: string;
7
+ level: SupabaseLogLevel;
8
+ message: string;
9
+ extra?: Record<string, unknown>;
10
+ };
11
+
12
+ type LogWriter = (entry: LogEntry) => Promise<unknown>;
13
+
14
+ export function createSupabaseLogger(input: { write: LogWriter }) {
15
+ async function emit(
16
+ level: SupabaseLogLevel,
17
+ message: string,
18
+ extra?: Record<string, unknown>,
19
+ ) {
20
+ try {
21
+ const result = await input.write({
22
+ service: "opencode-supabase",
23
+ level,
24
+ message,
25
+ extra,
26
+ });
27
+ if (result && typeof result === "object" && "error" in result) {
28
+ console.error("[opencode-supabase] host log rejected:", (result as { error: unknown }).error);
29
+ }
30
+ } catch (error) {
31
+ console.error("[opencode-supabase] host log failed:", error instanceof Error ? error.message : error);
32
+ }
33
+ }
34
+
35
+ return {
36
+ debug(message: string, extra?: Record<string, unknown>) {
37
+ return emit("debug", message, extra);
38
+ },
39
+ info(message: string, extra?: Record<string, unknown>) {
40
+ return emit("info", message, extra);
41
+ },
42
+ warn(message: string, extra?: Record<string, unknown>) {
43
+ return emit("warn", message, extra);
44
+ },
45
+ error(message: string, extra?: Record<string, unknown>) {
46
+ return emit("error", message, extra);
47
+ },
48
+ };
49
+ }
50
+
51
+ export function createServerLogWriter(client: { app: { log: (input: { body: LogEntry }) => Promise<unknown> } }) {
52
+ return (entry: LogEntry) => client.app.log({ body: entry });
53
+ }
54
+
55
+ export function createTuiLogWriter(client: { app: { log: (input: LogEntry, options?: { throwOnError?: boolean }) => Promise<unknown> } }) {
56
+ return (entry: LogEntry) => client.app.log(entry, { throwOnError: true });
57
+ }
@@ -1,9 +1,12 @@
1
1
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
2
  import { createSignal } from "solid-js";
3
3
 
4
+ import type { SupabaseLogger } from "../shared/log.ts";
5
+
4
6
  type SupabaseDialogProps = {
5
7
  api: TuiPluginApi;
6
8
  onClose: () => void;
9
+ logger: SupabaseLogger;
7
10
  };
8
11
 
9
12
  type OAuthState =
@@ -28,6 +31,9 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
28
31
 
29
32
  const startOAuth = async () => {
30
33
  try {
34
+ await props.logger.info("supabase auth started", {
35
+ phase: "authorize",
36
+ });
31
37
  setState({ type: "authorizing", url: "" });
32
38
 
33
39
  // Start OAuth authorization
@@ -50,19 +56,28 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
50
56
  }
51
57
 
52
58
  const { url, method } = authData;
59
+ const safeUrl = new URL(url);
53
60
  setState({ type: "authorizing", url });
54
61
 
62
+ await props.logger.debug("supabase auth authorize response received", {
63
+ method,
64
+ url_origin: safeUrl.origin,
65
+ url_path: safeUrl.pathname,
66
+ });
67
+
55
68
  // Attempt to open browser automatically
56
69
  if (method === "auto") {
57
70
  try {
58
71
  const open = await import("open");
59
72
  await open.default(url);
60
73
  } catch {
74
+ await props.logger.warn("supabase browser open failed");
61
75
  // Browser auto-open failed, user can click the URL manually
62
76
  }
63
77
  }
64
78
 
65
79
  setState({ type: "waiting_callback", url });
80
+ await props.logger.debug("supabase auth waiting for callback");
66
81
 
67
82
  // Wait for callback
68
83
  const callbackResponse = (await props.api.client.provider.oauth.callback({
@@ -79,6 +94,9 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
79
94
  const callbackSucceeded = callbackResponse.data === true;
80
95
 
81
96
  if (callbackSucceeded) {
97
+ await props.logger.info("supabase auth completed", {
98
+ status: "success",
99
+ });
82
100
  setState({ type: "success" });
83
101
  props.api.ui.toast({
84
102
  variant: "success",
@@ -92,6 +110,9 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
92
110
  } catch (error) {
93
111
  const message =
94
112
  error instanceof Error ? error.message : "Authorization failed";
113
+ await props.logger.error("supabase auth failed", {
114
+ message,
115
+ });
95
116
  setState({ type: "error", message });
96
117
  props.api.ui.toast({
97
118
  variant: "error",
package/src/tui/index.tsx CHANGED
@@ -1,12 +1,17 @@
1
1
  import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
2
 
3
+ import { createSupabaseLogger, createTuiLogWriter } from "../shared/log.ts";
3
4
  import { createSupabaseCommand } from "./commands";
4
5
  import { SupabaseDialog } from "./dialog";
5
6
 
6
7
  const tui: TuiPlugin = async (api) => {
8
+ const logger = createSupabaseLogger({
9
+ write: createTuiLogWriter(api.client),
10
+ });
11
+
7
12
  api.command.register(() => [
8
13
  createSupabaseCommand(() => {
9
- api.ui.dialog.replace(() => SupabaseDialog({ api, onClose: () => api.ui.dialog.clear() }));
14
+ api.ui.dialog.replace(() => SupabaseDialog({ api, logger, onClose: () => api.ui.dialog.clear() }));
10
15
  }),
11
16
  ]);
12
17
  };