Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/opencode-v2-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"opencode-supabase": minor
---

Add OpenCode 2.0.27 support while preserving OpenCode 1.17.4+ compatibility. Both server and TUI entrypoints expose the appropriate host hook. V2 uses native integration credentials for the host's active account, account-specific refresh and disconnect, and scoped OAuth cleanup; existing V1 local credentials require a new login on V2. Keep real Zod tool schemas for both hosts, register bundled skills, and port `/supabase` with browser authorization and onboarding. The connected V2 dialog uses OpenCode's native selection component for plugin-defined Continue/Disconnect actions; advanced account management stays in V2's native `/connect` integration UI. Add V2 packed-TUI CI coverage and prune V2's npm cache before uploading diagnostics.

Builds on Rafael Santos's (@rafaelsnv) initial V2 migration in PR #79.
3 changes: 2 additions & 1 deletion .github/opencode-versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"floor": "1.17.4",
"pinned": "1.17.14"
"pinned": "1.17.14",
"v2": "2.0.27"
}
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
pinned: ${{ steps.read.outputs.pinned }}
v2: ${{ steps.read.outputs.v2 }}
matrix: ${{ steps.read.outputs.matrix }}
steps:
- name: Checkout
Expand All @@ -34,6 +35,7 @@ jobs:
run: |
{
echo "pinned=$(jq -r .pinned .github/opencode-versions.json)"
echo "v2=$(jq -r .v2 .github/opencode-versions.json)"
echo "matrix=$(jq -c '[.floor, .pinned] | unique' .github/opencode-versions.json)"
} >>"$GITHUB_OUTPUT"

Expand Down Expand Up @@ -172,6 +174,9 @@ jobs:
fail-fast: false
matrix:
opencode-version: ${{ fromJSON(needs.versions.outputs.matrix) }}
include:
- opencode-version: ${{ needs.versions.outputs.v2 }}
opencode-package: '@opencode/cli'
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -194,7 +199,7 @@ jobs:

- name: Install OpenCode and tmux
run: |
npm install -g opencode-ai@${{ matrix.opencode-version }}
npm install -g "${{ matrix.opencode-package || 'opencode-ai' }}@${{ matrix.opencode-version }}"
sudo apt-get update
sudo apt-get install -y tmux

Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,34 @@ Connect OpenCode to Supabase: account login, Management API tools, bundled Supab

## Quickstart

Requires OpenCode `>= 1.17.4`.
Supports OpenCode 1 (`>= 1.17.4`) and OpenCode 2 (`>= 2.0.27`).

OpenCode 1:

```bash
opencode plugin opencode-supabase
```

OpenCode 2:

```bash
opencode plugin add opencode-supabase
```

Both versions use `/supabase`. The V2 connected dialog uses OpenCode 2's native
selection component and offers **Continue with current account** or
**Disconnect current account**. These actions are plugin-defined; V1 keeps its
existing dialog.

OpenCode 2 stores credentials in its native integration system. Unlike V1's
provider-focused `/connect`, V2's `/connect` also manages service integrations,
including Supabase: add, switch, rename, or remove saved accounts there.
The active Supabase account is shared by projects using the same OpenCode profile,
not bound to a repository. Disconnect removes only the current saved credential
and does not revoke access at Supabase; if another account is saved, OpenCode
makes it active. Existing V1 local credentials are not imported automatically;
reconnect when moving to V2.

Open `opencode` in your project, then run:

```text
Expand Down
587 changes: 584 additions & 3 deletions bun.lock

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 @@ -39,6 +39,9 @@
},
"dependencies": {
"@opencode-ai/plugin": "1.3.13",
"@opencode/plugin": "2.0.27",
"@opencode/schema": "2.0.27",
"effect": "4.0.1",
"open": "11.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ if (!result.success) {

await Bun.write(
"dist/tui.d.ts",
'declare const plugin: { id: "supabase"; tui: unknown };\nexport default plugin;\n',
'declare const plugin: { id: "supabase"; tui: unknown; setup: unknown };\nexport default plugin;\n',
);
11 changes: 5 additions & 6 deletions scripts/test-opencode-tui-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ diagnostics() {
# npm's cacache and the host's dependency installs are also >100MB of
# non-diagnostic junk. All evidence files (pane, logs, metadata, tarball,
# lockfiles) live outside these trees, so drop them before evidence upload.
rm -rf "$ARTIFACT_DIR/cache/opencode/packages" "$ARTIFACT_DIR/npm/cache" "$ARTIFACT_DIR/config/opencode/node_modules" "$ARTIFACT_DIR/work/.opencode/node_modules" "$ARTIFACT_DIR/tmp"
rm -rf "$ARTIFACT_DIR/cache/opencode/packages" "$ARTIFACT_DIR/cache/opencode/npm" "$ARTIFACT_DIR/npm/cache" "$ARTIFACT_DIR/config/opencode/node_modules" "$ARTIFACT_DIR/work/.opencode/node_modules" "$ARTIFACT_DIR/tmp"
}
trap diagnostics EXIT
trap 'exit 130' INT
Expand Down Expand Up @@ -153,11 +153,10 @@ clean_env=(

[[ ! -e "$ARTIFACT_DIR/home/.opencode" ]]
if [[ "${OPENCODE_MAJOR:-0}" -ge 2 ]]; then
# V2: `opencode plugin add` is an interactive TUI wizard (hangs non-interactively),
# so register the plugin the way V2 reads it: project .opencode/opencode.json
# plugin array (verified with `opencode debug config`). No tui.json in V2.
# V2's installer accepts registry/Git specs, not local tarballs. Load the
# packed artifact through project config using the current `plugins` key.
mkdir -p "$ARTIFACT_DIR/work/.opencode"
jq -n --arg spec "file:$TARBALL" '{plugin:[$spec]}' >"$ARTIFACT_DIR/work/.opencode/opencode.json"
jq -n --arg spec "file:$TARBALL" '{plugins:[$spec]}' >"$ARTIFACT_DIR/work/.opencode/opencode.json"
printf 'Registered V2 plugin config: %s\n' "$(cat "$ARTIFACT_DIR/work/.opencode/opencode.json")" >"$ARTIFACT_DIR/install.stdout"
else
if ! (
Expand All @@ -173,7 +172,7 @@ fi
metadata_files=(opencode.json)
[[ "${OPENCODE_MAJOR:-0}" -lt 2 ]] && metadata_files+=(tui.json)
for metadata in "${metadata_files[@]}"; do
jq -e --arg spec "file:$TARBALL" '.plugin == [$spec]' "$ARTIFACT_DIR/work/.opencode/$metadata" >/dev/null
jq -e --arg spec "file:$TARBALL" '(.plugins // .plugin) == [$spec]' "$ARTIFACT_DIR/work/.opencode/$metadata" >/dev/null
done

if [[ "${OPENCODE_MAJOR:-0}" -ge 2 ]]; then
Expand Down
118 changes: 67 additions & 51 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type PendingAuth = {
brokerConfig: BrokerConfig;
fetch?: FetchLike;
logger?: SupabaseLogger;
owner?: object;
persist: boolean;
resolve: (result: { tokens: SupabaseTokenResponse; expires: number }) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
Expand All @@ -39,6 +41,9 @@ type AuthDeps = {
fetch?: FetchLike;
logger?: SupabaseLogger;
setCallbackTimeout?: typeof setTimeout;
owner?: object;
// V2 returns credentials to the host; do not also save a V1 local copy.
persist?: boolean;
};

type SupabaseAuthInput = Pick<PluginInput, "client" | "directory" | "serverUrl" | "worktree">;
Expand Down Expand Up @@ -210,11 +215,13 @@ async function ensureServer(
);

const expires = Date.now() + (tokens.expires_in || 3600) * 1000;
await writeSavedAuth(pending.input, {
access: tokens.access_token,
refresh: tokens.refresh_token,
expires,
});
if (pending.persist) {
await writeSavedAuth(pending.input, {
access: tokens.access_token,
refresh: tokens.refresh_token,
expires,
});
}

pending.resolve({ tokens, expires });

Expand Down Expand Up @@ -312,61 +319,74 @@ function waitForCallback(
brokerConfig,
fetch: deps.fetch,
logger: deps.logger,
owner: deps.owner,
persist: deps.persist !== false,
resolve,
reject,
timeout,
});
});
}

export function createSupabaseAuth(
input: SupabaseAuthInput,
export function createSupabaseOAuth(
input: Pick<PluginInput, "directory" | "worktree">,
options?: PluginOptions,
deps: AuthDeps = {},
) {
const config = readSupabaseConfig(options);
const authCallbackPorts = normalizeCallbackPorts(deps.callbackPorts ?? CALLBACK_PORTS);

return {
provider: "supabase",
methods: [
{
type: "oauth" as const,
label: "Supabase",
async authorize() {
const brokerConfig: BrokerConfig = { baseUrl: config.brokerBaseUrl };
const port = await ensureServer(authCallbackPorts, deps);
await deps.logger?.info("supabase auth started", {
port,
});
const pkce = await generatePKCE();
const state = generateState();
const redirectUri = callbackUrl(port);
const callbackPromise = waitForCallback(
state,
pkce.verifier,
redirectUri,
input,
brokerConfig,
deps,
);

type: "oauth" as const,
label: "Supabase",
async authorize() {
const port = await ensureServer(authCallbackPorts, deps);
await deps.logger?.info("supabase auth started", { port });
const pkce = await generatePKCE();
const state = generateState();
const redirectUri = callbackUrl(port);
const callbackPromise = waitForCallback(
state, pkce.verifier, redirectUri, input,
{ baseUrl: config.brokerBaseUrl }, deps,
);
// A callback may fail before the host starts awaiting it.
void callbackPromise.catch(() => undefined);
return {
url: buildAuthorizeUrl(config, redirectUri, pkce, state),
instructions: "Complete Supabase authorization in your browser.",
method: "auto" as const,
callback: async () => {
const { tokens, expires } = await callbackPromise;
return {
url: buildAuthorizeUrl(config, redirectUri, pkce, state),
instructions: "Complete Supabase authorization in your browser.",
method: "auto" as const,
callback: async () => {
const { tokens, expires } = await callbackPromise;
return {
type: "success" as const,
access: tokens.access_token,
refresh: tokens.refresh_token,
expires,
};
},
type: "success" as const,
access: tokens.access_token,
refresh: tokens.refresh_token,
expires,
};
},
},
cancel: () => cancelSupabaseOAuth(state),
};
},
};
}

export async function cancelSupabaseOAuth(state: string) {
const pending = pendingAuths.get(state);
if (!pending) return;
clearTimeout(pending.timeout);
pendingAuths.delete(state);
pending.reject(new Error("OAuth authorization cancelled"));
await stopServerIfIdle(pending.logger, "cancelled");
}

export function createSupabaseAuth(
input: SupabaseAuthInput,
options?: PluginOptions,
deps: AuthDeps = {},
) {
return {
provider: "supabase",
methods: [
createSupabaseOAuth(input, options, deps),
{
type: "oauth" as const,
label: "Supabase Status",
Expand Down Expand Up @@ -421,16 +441,12 @@ export function createSupabaseAuth(
};
}

export async function stopSupabaseAuthServer() {
if (server) {
server.stop();
server = undefined;
serverPort = undefined;
}

export async function stopSupabaseAuthServer(owner?: object) {
for (const [state, pending] of pendingAuths) {
if (owner && pending.owner !== owner) continue;
clearTimeout(pending.timeout);
pending.reject(new Error("OAuth callback server stopped"));
pendingAuths.delete(state);
}
await stopServerIfIdle(undefined, "disposed");
}
4 changes: 3 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createServerLogWriter, createSupabaseLogger } from "../shared/log.ts";
import { createSupabaseAuth } from "./auth.ts";
import { registerSupabaseSkillPaths } from "./skills.ts";
import { createSupabaseTools } from "./tools.ts";
import { setupV2Server } from "./v2.ts";

const server: Plugin = async (input, options) => {
const logger = createSupabaseLogger({
Expand All @@ -21,4 +22,5 @@ const server: Plugin = async (input, options) => {
};
};

export default { id: "supabase", server };
// Both hosts resolve ./server. Each consumes only its own registration hook.
export default { id: "supabase", server, effect: setupV2Server };
24 changes: 15 additions & 9 deletions src/server/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ export type SupabaseToolInput = {
worktree: string;
};

type SupabaseToolContext = Pick<
ToolContext,
"directory" | "worktree" | "abort" | "sessionID" | "messageID" | "agent" | "metadata" | "ask"
>;
type SupabaseToolContext = Pick<ToolContext, "sessionID" | "messageID" | "agent">;

// V2 owns credentials and account selection; V1 keeps its existing local store.
type SupabaseToolSource = SupabaseToolInput | {
resolveAuth: () => Promise<SavedAuth>;
};

export type SupabaseAuthStatus =
| {
Expand Down Expand Up @@ -119,8 +121,12 @@ function sanitizeToolArgs(name: string, args: Record<string, unknown>) {
return next;
}

async function resolveToolAuth(input: SupabaseToolSource, options: PluginOptions | undefined, deps: ToolDeps) {
return "resolveAuth" in input ? input.resolveAuth() : ensureSupabaseToolAuth(input, options, deps);
}

async function executeSupabaseRequest(
input: SupabaseToolInput,
input: SupabaseToolSource,
options: PluginOptions | undefined,
deps: ToolDeps,
toolName: string,
Expand All @@ -138,7 +144,7 @@ async function executeSupabaseRequest(
});
try {
const config = readSupabaseConfig(options);
const auth = await ensureSupabaseToolAuth(input, options, deps);
const auth = await resolveToolAuth(input, options, deps);
const response = await supabaseManagementApiFetch(
config,
auth.access,
Expand Down Expand Up @@ -182,7 +188,7 @@ async function executeSupabaseRequest(
}

async function executeSupabaseGet(
input: SupabaseToolInput,
input: SupabaseToolSource,
options: PluginOptions | undefined,
deps: ToolDeps,
toolName: string,
Expand Down Expand Up @@ -453,7 +459,7 @@ function formatMcpSetupResult(url: string) {
}

export function createSupabaseTools(
input: SupabaseToolInput,
input: SupabaseToolSource,
options?: PluginOptions,
deps: ToolDeps = {},
) {
Expand Down Expand Up @@ -546,7 +552,7 @@ export function createSupabaseTools(
project_ref: tool.schema.string().describe("Supabase project reference ID"),
},
async execute(args, _context: SupabaseToolContext) {
await ensureSupabaseToolAuth(input, options, deps);
await resolveToolAuth(input, options, deps);
const url = createMcpSetupUrl(args.project_ref);
const openBrowser = deps.open ?? open;
await openBrowser(url);
Expand Down
Loading
Loading