Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
605 changes: 602 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

5,925 changes: 5,925 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
},
"main": "./index.ts",
"exports": {
".": "./index.ts",
"./server": "./src/server/index.ts",
"./v2": "./src/index.v2.ts",
"./tui": "./dist/tui.js"
},
"files": [
Expand Down Expand Up @@ -39,6 +41,7 @@
},
"dependencies": {
"@opencode-ai/plugin": "1.3.13",
"@opencode/plugin": "^2.0.16",
"open": "11.0.0"
},
"devDependencies": {
Expand Down
197 changes: 197 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// V2 plugin entrypoint for opencode-supabase.
//
// Ports the V1 server plugin (src/server/index.ts) to the V2 plugin API:
// - `config` hook -> `ctx.skill.transform()` (skills are first-class in V2)
// - `auth` hook -> `ctx.integration.transform()` OAuth method registration
// - `tool` map -> `ctx.tool.transform()` with JSON Schema tool definitions
// - plugin input/options -> `ctx.location` and `ctx.options`
//
// OAuth, token refresh, the local callback server, and the Supabase Management
// API tools are preserved from the V1 implementation; only the host
// integration points changed.

import fs from "node:fs";
import path from "node:path";

import { Plugin } from "@opencode/plugin";
import type { Credential } from "@opencode/schema/credential";
import type { Skill } from "@opencode/schema/skill";

import { createSupabaseAuth, stopSupabaseAuthServer } from "./server/auth.ts";
import {
defaultSkillsRoot,
resolveEnabledSupabaseSkills,
} from "./server/skills.ts";
import { createSupabaseTools, ensureSupabaseToolAuth } from "./server/tools.ts";
import { createSupabaseLogger } from "./shared/log.ts";
import { type PluginOptions, type ToolDefinition, toJsonSchemaInput } from "./shared/plugin-compat.ts";

type V1AuthMethod = {
label: string;
authorize(inputs?: Record<string, string>): Promise<{
url: string;
instructions: string;
method: "auto";
callback: () => Promise<
| { type: "success"; access: string; refresh: string; expires: number }
| { type: "failed" }
>;
}>;
};

function parseSkillFrontmatter(content: string, fallbackId: string) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
const meta: Record<string, string> = {};
if (match?.[1]) {
for (const line of match[1].split(/\r?\n/)) {
const separator = line.indexOf(":");
if (separator > 0) {
meta[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
}
}
}
return {
name: meta.name || fallbackId,
description: meta.description || "",
};
}

export default Plugin.define({
id: "supabase",
async setup(ctx) {
const options = (ctx.options ?? undefined) as PluginOptions;
const directory = ctx.location.directory as string;
const projectDirectory = ctx.location.project.directory as string;
const worktree = projectDirectory !== directory ? projectDirectory : undefined;

// Logging goes through the host when the context exposes the app domain;
// otherwise entries are dropped (log.ts still surfaces writer failures).
const appDomain = (ctx as unknown as {
app?: { log?: (input: { body: unknown }) => Promise<unknown> };
}).app;
const logger = createSupabaseLogger({
write: (entry) =>
appDomain?.log ? appDomain.log({ body: entry }) : Promise.resolve(undefined),
});

// V2 keeps durable state in plugin storage instead of the V1 host auth
// endpoints (`PUT/DELETE /auth/supabase`). The tool layer talks to this
// writer through the same narrow interface the V1 client exposed.
const client = {
auth: {
set: async (input: {
body: { type: string; access: string; refresh: string; expires: number };
}) => {
await ctx.storage.set("auth/supabase", input.body);
},
},
};
const clearHostAuth = () => ctx.storage.remove("auth/supabase");

const toolInput = { client, directory, worktree: worktree ?? directory, clearHostAuth };

// Skills: register the bundled skill directories through the skill
// transform instead of mutating a global config object.
const enabledSkills = resolveEnabledSupabaseSkills(options, {
warn: (message, data) => void logger.warn(message, data),
});
const skillsRoot = defaultSkillsRoot();

await ctx.skill.transform((editor) => {
for (const skill of enabledSkills) {
const skillPath = path.join(skillsRoot, skill, "SKILL.md");
if (!fs.existsSync(skillPath)) {
void logger.warn("bundled Supabase skill directory not found", {
skill,
path: skillPath,
});
continue;
}
const content = fs.readFileSync(skillPath, "utf8");
const meta = parseSkillFrontmatter(content, skill);
editor.add({
id: skill as Skill.Info["id"],
name: meta.name as Skill.Info["name"],
description: meta.description,
path: skillPath as Skill.Info["path"],
content,
});
}
});

// Tools: the V1 definitions are reused; only the registration mechanism
// and the argument encoding (JSON Schema) changed.
const tools = createSupabaseTools(toolInput, options, { logger });
await ctx.tool.transform((editor) => {
for (const [name, definition] of Object.entries(tools)) {
const def = definition as ToolDefinition;
editor.add({
name,
description: def.description,
input: toJsonSchemaInput(def.args),
async execute(input, context) {
const result = await def.execute(input, context);
return {
content:
typeof result === "string" ? result : JSON.stringify(result, null, 2),
};
},
});
}
});

// OAuth: register the Supabase integration with an auto (browser) OAuth
// method backed by the existing local callback server and PKCE flow, plus
// a refresh handler that reuses the tool-layer token refresh.
const { methods } = createSupabaseAuth({ directory, worktree }, options, {
logger,
toolInput,
});
const loginMethod = methods.find((method) => method.label === "Supabase") as
| V1AuthMethod
| undefined;
if (!loginMethod) {
throw new Error("Supabase OAuth method was not produced by the auth module");
}

await ctx.integration.transform((editor) => {
editor.update("supabase", (integration) => {
integration.name ??= "Supabase";
});
editor.method.update({
integrationID: "supabase",
method: { id: "supabase", type: "oauth", label: "Supabase" },
authorize: async () => {
// Starts the local callback server and browser authorization; the
// pending authorization resolves through the OAuth redirect.
const authorization = await loginMethod.authorize();
return {
mode: "auto" as const,
url: authorization.url,
instructions: authorization.instructions,
callback: authorization.callback().then((result) => {
if (result.type !== "success") {
throw new Error("Supabase authorization failed");
}
return {
type: "oauth" as const,
methodID: "supabase" as Credential.OAuth["methodID"],
access: result.access,
refresh: result.refresh,
expires: result.expires,
};
}),
};
},
refresh: async (credential) => {
const auth = await ensureSupabaseToolAuth(toolInput, options, { logger });
return { ...credential, access: auth.access, refresh: auth.refresh, expires: auth.expires };
},
});
});

return () => {
void stopSupabaseAuthServer();
};
},
});
19 changes: 15 additions & 4 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createConnection } from "node:net";
import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
import type { PluginInput, PluginOptions } from "../shared/plugin-compat.ts";

import { formatAuthError } from "../shared/auth-errors.ts";
import {
Expand All @@ -14,6 +14,7 @@ import type { FetchLike, SupabaseTokenResponse } from "../shared/types.ts";
import { HTML_SUCCESS, htmlError } from "./auth-html.ts";
import type { SavedStateNotice } from "./store.ts";
import { readSavedAuth, writeSavedAuth } from "./store.ts";
import type { SupabaseToolInput } from "./tools.ts";
import { NOT_CONNECTED_MESSAGE, disconnectSupabaseAuth, ensureSupabaseToolAuth } from "./tools.ts";

const CALLBACK_PATH = "/auth/callback";
Expand All @@ -33,9 +34,13 @@ type AuthDeps = {
fetch?: FetchLike;
logger?: SupabaseLogger;
setCallbackTimeout?: typeof setTimeout;
// Host-auth plumbing shared with the tool layer; the V2 entrypoint builds it
// from plugin storage, the V1 entrypoint from the host auth endpoints. Only
// the status/disconnect OAuth method needs it.
toolInput?: SupabaseToolInput;
};

type SupabaseAuthInput = Pick<PluginInput, "client" | "directory" | "serverUrl" | "worktree">;
type SupabaseAuthInput = Pick<PluginInput, "directory" | "worktree">;

type SupabaseStatusInstructions =
| {
Expand Down Expand Up @@ -357,7 +362,10 @@ export function createSupabaseAuth(
label: "Supabase Status",
async authorize(inputs?: Record<string, string>) {
if (inputs?.action === "disconnect") {
await disconnectSupabaseAuth(input, { fetch: deps.fetch });
if (!deps.toolInput) {
throw new Error("Supabase disconnect requires tool host auth access");
}
await disconnectSupabaseAuth(deps.toolInput);
return {
url: "https://supabase.com/",
instructions: encodeStatusInstructions({ status: "disconnected", checked: false }),
Expand All @@ -384,7 +392,10 @@ export function createSupabaseAuth(
method: "auto" as const,
callback: async () => {
try {
const auth = await ensureSupabaseToolAuth(input, options, deps);
if (!deps.toolInput) {
throw new Error("Supabase auth status requires tool host auth access");
}
const auth = await ensureSupabaseToolAuth(deps.toolInput, options, deps);
return {
type: "success" as const,
access: auth.access,
Expand Down
36 changes: 30 additions & 6 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
import type { Plugin } from "@opencode-ai/plugin";
// Legacy V1 server entrypoint. Kept for OpenCode 1 compatibility; the V2
// implementation lives in src/index.v2.ts. Note that tool argument schemas now
// use the shared compat encoding (converted to JSON Schema by the V2
// entrypoint), so V1 hosts should consume a V1-era release of this package.
import type { PluginInput } from "@opencode-ai/plugin";

import { createServerLogWriter, createSupabaseLogger } from "../shared/log.ts";
import { createSupabaseAuth } from "./auth.ts";
import { registerSupabaseSkillPaths } from "./skills.ts";
import { createSupabaseTools } from "./tools.ts";
import { type SupabaseToolInput, createSupabaseTools } from "./tools.ts";

const server: Plugin = async (input, options) => {
const server = async (input: PluginInput, options?: Record<string, unknown>) => {
const logger = createSupabaseLogger({
write: createServerLogWriter(input.client),
});

const toolInput: SupabaseToolInput = {
client: input.client as SupabaseToolInput["client"],
directory: input.directory,
worktree: input.worktree ?? input.directory,
clearHostAuth: async () => {
const url = new URL(
`/auth/supabase?directory=${encodeURIComponent(input.directory)}`,
input.serverUrl,
);
const response = await fetch(url.toString(), { method: "DELETE" });
if (!response.ok) {
throw new Error(`Failed to clear host auth: ${response.status}`);
}
},
};

return {
config: async (config) => {
config: async (config: object) => {
registerSupabaseSkillPaths(config, options, {
warn: logger.warn,
});
},
auth: createSupabaseAuth(input, options, { logger }),
tool: createSupabaseTools(input, options, { logger }),
auth: createSupabaseAuth(
{ directory: input.directory, worktree: input.worktree },
options,
{ logger, toolInput },
),
tool: createSupabaseTools(toolInput, options, { logger }),
};
};

Expand Down
2 changes: 1 addition & 1 deletion src/server/store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdir, open, rename, unlink } from "node:fs/promises";
import { dirname, join, posix, win32 } from "node:path";
import type { PluginInput } from "@opencode-ai/plugin";
import type { PluginInput } from "../shared/plugin-compat.ts";

import type { SupabaseLogger } from "../shared/log.ts";

Expand Down
Loading
Loading