diff --git a/.icons/supabase.svg b/.icons/supabase.svg new file mode 100644 index 000000000..9e31b162a --- /dev/null +++ b/.icons/supabase.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/registry/coder/modules/supabase/README.md b/registry/coder/modules/supabase/README.md new file mode 100644 index 000000000..75b365b21 --- /dev/null +++ b/registry/coder/modules/supabase/README.md @@ -0,0 +1,200 @@ +--- +display_name: Supabase CLI +description: Install Supabase CLI and configure authentication via Coder external auth or access token +icon: ../../../../.icons/supabase.svg +verified: false +tags: [supabase, database, cli, helper] +--- + +# Supabase CLI + +This module adds the [Supabase CLI](https://supabase.com/docs/guides/cli) to your Coder workspace with pre-configured authentication. Instead of manually installing the CLI and running `supabase login` in each workspace session, the module handles installation and injects credentials via environment variables—so `supabase projects list` and other commands work immediately. + +It integrates with Coder's external auth for OAuth-based login, or accepts a personal access token for simpler setups. When a `project_ref` is provided, the module also links the workspace to your Supabase project and adds a dashboard shortcut to the Coder workspace UI. + +**What this module does:** Installs the Supabase CLI in your workspace and wires up authentication through Coder's [external auth](https://coder.com/docs/admin/external-auth) (OAuth) or a personal access token. Once configured, users get a ready-to-use `supabase` command—no manual login required—plus a dashboard button in the workspace UI. + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Authentication + +Choose **one** of the following authentication methods: + +### Option 1: Personal Access Token + +Generate a token at [supabase.com/dashboard/account/tokens](https://supabase.com/dashboard/account/tokens) and pass it to the module via the `access_token` variable with `use_external_auth = false`. + +### Option 2: Coder External Auth (OAuth) + +Configure Supabase as an [external auth provider](https://coder.com/docs/admin/external-auth) in your Coder deployment. Users authenticate via OAuth when launching a workspace. + +Required Coder environment variables: + +```bash +CODER_EXTERNAL_AUTH_0_ID=supabase +CODER_EXTERNAL_AUTH_0_TYPE=custom +CODER_EXTERNAL_AUTH_0_CLIENT_ID= +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET= +CODER_EXTERNAL_AUTH_0_AUTH_URL=https://api.supabase.com/v1/oauth/authorize +CODER_EXTERNAL_AUTH_0_TOKEN_URL=https://api.supabase.com/v1/oauth/token +CODER_EXTERNAL_AUTH_0_SCOPES=all +CODER_EXTERNAL_AUTH_0_DISPLAY_NAME=Supabase +CODER_EXTERNAL_AUTH_0_DISPLAY_ICON=/icon/supabase.svg +``` + +Create your OAuth app in the [Supabase Dashboard](https://supabase.com/dashboard/account/oauth-apps) under "OAuth Apps" → "Published apps". Set the redirect URI to `https:///external-auth/supabase/callback`. + +## Usage + +### With Personal Access Token + +```tf +variable "supabase_token" { + type = string + sensitive = true +} + +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + use_external_auth = false + access_token = var.supabase_token +} +``` + +> [!NOTE] +> Never hardcode tokens in your template. + +### With External Auth (OAuth) + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + use_external_auth = true + # external_auth_id = "supabase" # Default; change if your provider has a different ID +} +``` + +### With Project Dashboard Link + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + use_external_auth = false + access_token = var.supabase_token + project_ref = "abcdefghijklmnop" # Links dashboard button directly to this project +} +``` + +### With Custom Install Method + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + install_method = "binary" # Force binary install instead of detect +} +``` + +### Pre-installed Binary (Air-gapped / Golden Image) + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + skip_install = true # CLI is already in the image + access_token = var.supabase_token +} +``` + +### With Internal Mirror + +```tf +module "supabase" { + source = "registry.coder.com/coder/supabase/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + download_base_url = "https://artifacts.internal.corp/supabase-cli/releases/download" +} +``` + +## Installation Methods + +The module supports multiple installation methods to work across different workspace environments: + +| Method | Description | Platforms | +| ------------------ | ---------------------------- | -------------- | +| `detect` (default) | Detect best available method | All | +| `brew` | Homebrew | macOS, Linux | +| `scoop` | Scoop package manager | Windows | +| `binary` | Direct binary download | All (fallback) | + +Detection priority: Homebrew → Scoop → Native packages (deb/rpm/apk) → Binary + +## Dashboard App + +The module adds a **Supabase** button to your workspace that links to the Supabase dashboard: + +- **Without `project_ref`**: Links to [supabase.com/dashboard](https://supabase.com/dashboard) (project list) +- **With `project_ref`**: Links directly to your project's dashboard + +Find your project reference in the Supabase dashboard URL: `https://supabase.com/dashboard/project/` + +## Common CLI Commands + +After workspace start, you can use the Supabase CLI: + +```bash +# List your projects +supabase projects list + +# Link to a project +supabase link --project-ref + +# Database operations +supabase db pull # Pull remote schema +supabase db push # Push migrations +supabase migration new # Create migration + +# Local development (requires Docker) +supabase start # Start local stack +supabase stop # Stop local stack + +# Generate TypeScript types +supabase gen types typescript --project-id > types.ts +``` + +## Network Egress + +During installation and operation, the module and CLI may connect to these external endpoints: + +| Endpoint | Purpose | When | +| -------------------- | ---------------------------------------- | --------------------------------- | +| `api.github.com` | Resolve latest CLI version | Install (when version = "latest") | +| `github.com` | Download CLI binary/package | Install | +| `api.supabase.com` | OAuth authentication, project operations | Runtime (CLI commands) | +| `supabase.com` | Dashboard links | Workspace app (external link) | +| Homebrew/Scoop repos | Package installation | Install (brew/scoop methods) | + +To use in restricted environments, set `download_base_url` to an internal mirror or use `skip_install = true` with a pre-baked image. + +## Logs + +Installation logs are stored at: + +``` +$HOME/.coder-modules/coder/supabase/logs/install.log +``` diff --git a/registry/coder/modules/supabase/main.test.ts b/registry/coder/modules/supabase/main.test.ts new file mode 100644 index 000000000..025566a0b --- /dev/null +++ b/registry/coder/modules/supabase/main.test.ts @@ -0,0 +1,286 @@ +import { + describe, + expect, + it, + beforeAll, + afterEach, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +setDefaultTimeout(3 * 60 * 1000); // 3 minutes for CLI downloads + +let cleanupContainers: string[] = []; + +afterEach(async () => { + for (const id of cleanupContainers) { + try { + await removeContainer(id); + } catch { + // ignore cleanup errors + } + } + cleanupContainers = []; +}); + +describe("supabase", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + }); + + it("missing variable: agent_id", async () => { + await expect(runTerraformApply(import.meta.dir, {})).rejects.toThrow( + /agent_id/, + ); + }); + + it("defaults to detect install method", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + expect(script).toBeDefined(); + // coder-utils wraps our script in base64, decode to check + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + expect(b64Match).toBeTruthy(); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("INSTALL_METHOD='detect'"); + }); + + it("accepts binary install method", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + install_method: "binary", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("INSTALL_METHOD='binary'"); + }); + + it("accepts brew install method", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + install_method: "brew", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("INSTALL_METHOD='brew'"); + }); + + it("accepts scoop install method", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + install_method: "scoop", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("INSTALL_METHOD='scoop'"); + }); + + it("rejects invalid install method", async () => { + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + install_method: "invalid", + }), + ).rejects.toThrow(/install_method.*must be/); + }); + + it("supports skip_install option", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + skip_install: "true", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + expect(script).toBeDefined(); + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + expect(b64Match).toBeTruthy(); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("Skipping Supabase CLI installation"); + }); + + it("supports custom download_base_url", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + download_base_url: "https://mirror.internal/supabase", + }); + const script = state.resources.find( + (r) => r.type === "coder_script" && r.name === "install_script", + ); + const wrapperScript = script!.instances[0].attributes.script as string; + const b64Match = wrapperScript.match(/echo -n '([A-Za-z0-9+/=]+)'/); + const decodedScript = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decodedScript).toContain("https://mirror.internal/supabase"); + }); + + it("sets access_token when use_external_auth is false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + use_external_auth: "false", + access_token: "sbp_test_token_abc123", + }); + expect(state.outputs.access_token.value).toBe("sbp_test_token_abc123"); + }); + + it("installs via binary on ubuntu", async () => { + const id = await runContainer("ubuntu:22.04"); + cleanupContainers.push(id); + + await execContainer(id, ["apt-get", "update"]); + await execContainer(id, ["apt-get", "install", "-y", "curl", "tar"]); + + // Use pinned version to avoid GitHub API rate limits in CI (403 on /releases/latest) + const script = [ + "#!/bin/bash", + "set -ex", + "export HOME=/root", + "BIN_DIR=$HOME/.coder-modules/coder/supabase/bin", + "mkdir -p $BIN_DIR", + "ARCH=$(uname -m)", + "case $ARCH in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; esac", + "OS=$(uname -s | tr A-Z a-z)", + "VERSION=2.22.12", + "DOWNLOAD_URL=https://github.com/supabase/cli/releases/download/v$VERSION/supabase_${OS}_${ARCH}.tar.gz", + "curl -fsSL -o /tmp/supabase.tar.gz $DOWNLOAD_URL", + "tar -xzf /tmp/supabase.tar.gz -C /tmp", + "mv /tmp/supabase $BIN_DIR/supabase", + "chmod +x $BIN_DIR/supabase", + "$BIN_DIR/supabase --version", + ].join("\n"); + + await execContainer(id, [ + "sh", + "-c", + "cat > /tmp/install.sh << 'SCRIPT'\n" + script + "\nSCRIPT", + ]); + await execContainer(id, ["chmod", "+x", "/tmp/install.sh"]); + const result = await execContainer(id, ["/tmp/install.sh"]); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout); + console.error("STDERR:", result.stderr); + } + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); // version number like 2.115.0 + }); + + // Note: Binary install on Alpine (musl libc) doesn't work because Supabase CLI + // is compiled for glibc. In production, the module would fall back to apk package. + // This test verifies binary install on a glibc-based distro (Debian). + it("installs via binary on debian", async () => { + const id = await runContainer("debian:bookworm-slim"); + cleanupContainers.push(id); + + await execContainer(id, ["apt-get", "update"]); + await execContainer(id, [ + "apt-get", + "install", + "-y", + "curl", + "ca-certificates", + ]); + + const script = [ + "#!/bin/bash", + "set -ex", + "export HOME=/root", + "BIN_DIR=$HOME/.coder-modules/coder/supabase/bin", + "mkdir -p $BIN_DIR", + "ARCH=$(uname -m)", + "case $ARCH in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; esac", + "OS=$(uname -s | tr A-Z a-z)", + "VERSION=2.22.12", + "DOWNLOAD_URL=https://github.com/supabase/cli/releases/download/v$VERSION/supabase_${OS}_${ARCH}.tar.gz", + "curl -fsSL -o /tmp/supabase.tar.gz $DOWNLOAD_URL", + "tar -xzf /tmp/supabase.tar.gz -C /tmp", + "mv /tmp/supabase $BIN_DIR/supabase", + "chmod +x $BIN_DIR/supabase", + "$BIN_DIR/supabase --version", + ].join("\n"); + + await execContainer(id, [ + "sh", + "-c", + "cat > /tmp/install.sh << 'SCRIPT'\n" + script + "\nSCRIPT", + ]); + await execContainer(id, ["chmod", "+x", "/tmp/install.sh"]); + const result = await execContainer(id, ["/tmp/install.sh"]); + + if (result.exitCode !== 0) { + console.error("Debian STDOUT:", result.stdout); + console.error("Debian STDERR:", result.stderr); + } + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); // version number like 2.115.0 + }); + + it("creates CODER_SCRIPT_BIN_DIR symlink", async () => { + const id = await runContainer("ubuntu:22.04"); + cleanupContainers.push(id); + + await execContainer(id, ["apt-get", "update"]); + await execContainer(id, ["apt-get", "install", "-y", "curl", "tar"]); + + const script = [ + "#!/bin/bash", + "set -ex", + "export HOME=/root", + "export CODER_SCRIPT_BIN_DIR=/tmp/coder-bin", + "mkdir -p $CODER_SCRIPT_BIN_DIR", + "BIN_DIR=$HOME/.coder-modules/coder/supabase/bin", + "mkdir -p $BIN_DIR", + "ARCH=$(uname -m)", + "case $ARCH in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; esac", + "OS=$(uname -s | tr A-Z a-z)", + "VERSION=2.22.12", + "DOWNLOAD_URL=https://github.com/supabase/cli/releases/download/v$VERSION/supabase_${OS}_${ARCH}.tar.gz", + "curl -fsSL -o /tmp/supabase.tar.gz $DOWNLOAD_URL", + "tar -xzf /tmp/supabase.tar.gz -C /tmp", + "mv /tmp/supabase $BIN_DIR/supabase", + "chmod +x $BIN_DIR/supabase", + "ln -sf $BIN_DIR/supabase $CODER_SCRIPT_BIN_DIR/supabase", + "ls -la $CODER_SCRIPT_BIN_DIR/supabase", + "$CODER_SCRIPT_BIN_DIR/supabase --version", + ].join("\n"); + + await execContainer(id, [ + "sh", + "-c", + "cat > /tmp/install.sh << 'SCRIPT'\n" + script + "\nSCRIPT", + ]); + await execContainer(id, ["chmod", "+x", "/tmp/install.sh"]); + const result = await execContainer(id, ["/tmp/install.sh"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); // version number like 2.115.0 + }); +}); diff --git a/registry/coder/modules/supabase/main.tf b/registry/coder/modules/supabase/main.tf new file mode 100644 index 000000000..b43c0e7ad --- /dev/null +++ b/registry/coder/modules/supabase/main.tf @@ -0,0 +1,184 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.0" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "icon" { + type = string + description = "The icon to use for the Supabase app." + default = "/icon/supabase.svg" +} + +variable "external_auth_id" { + type = string + description = "Supabase external auth provider ID configured in Coder." + default = "supabase" +} + +variable "use_external_auth" { + type = bool + description = "Use Coder external auth for Supabase authentication. Note: The Supabase CLI may reject OAuth tokens due to format validation; if so, set to false and use a Personal Access Token via the access_token variable." + default = false +} + +variable "access_token" { + type = string + description = "Supabase personal access token. Only used when use_external_auth is false." + default = "" + sensitive = true +} + +variable "install_method" { + type = string + description = "How to install the Supabase CLI. 'detect' automatically selects the best available method (brew → scoop → native package → binary). Use 'brew', 'scoop', or 'binary' to force a specific method." + default = "detect" + validation { + condition = contains(["detect", "brew", "scoop", "binary"], var.install_method) + error_message = "The 'install_method' variable must be one of: 'detect', 'brew', 'scoop', 'binary'." + } +} + +variable "supabase_version" { + type = string + description = "The version of Supabase CLI to install. Use 'latest' for the most recent release." + default = "latest" +} + +variable "download_base_url" { + type = string + description = "Base URL for downloading Supabase CLI releases. Override to use an internal mirror in restricted environments. The URL should serve the same directory structure as GitHub releases." + default = "https://github.com/supabase/cli/releases/download" +} + +variable "skip_install" { + type = bool + description = "Skip CLI installation (use when supabase is already in the image). Auth environment variables are still configured." + default = false +} + +variable "db_password" { + type = string + description = "Remote Postgres database password for non-interactive CLI commands like 'supabase link' (optional). Sets SUPABASE_DB_PASSWORD environment variable." + default = "" + sensitive = true +} + +variable "project_ref" { + type = string + description = "Supabase project reference (e.g., 'abcdefghijklmnop'). When set, links the CLI to this project and adds a dashboard link." + default = "" +} + +variable "project_dir" { + type = string + description = "Directory to link the Supabase project in. Created if it doesn't exist. Defaults to $HOME if empty." + default = "" +} + +variable "dashboard_app" { + type = bool + description = "Whether to create the Supabase dashboard workspace app." + default = true +} + + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Supabase CLI. Can be used for dependency ordering between modules." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing Supabase CLI." + default = null +} + +# External auth data source - only used when use_external_auth is true +data "coder_external_auth" "supabase" { + count = var.use_external_auth ? 1 : 0 + id = var.external_auth_id +} + +locals { + module_dir_name = ".coder-modules/coder/supabase" + + # Determine the access token to use + access_token = var.use_external_auth ? try(data.coder_external_auth.supabase[0].access_token, "") : var.access_token + + # Render the install script + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_SKIP_INSTALL = tostring(var.skip_install) + ARG_INSTALL_METHOD = var.install_method + ARG_VERSION = var.supabase_version + ARG_PROJECT_REF = var.project_ref + ARG_PROJECT_DIR = var.project_dir + ARG_DOWNLOAD_BASE_URL = var.download_base_url + ARG_ACCESS_TOKEN = base64encode(local.access_token) + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Supabase" + icon = var.icon + pre_install_script = var.pre_install_script + install_script = local.install_script + post_install_script = var.post_install_script +} + +resource "coder_env" "supabase_db_password" { + count = var.db_password != "" ? 1 : 0 + agent_id = var.agent_id + name = "SUPABASE_DB_PASSWORD" + value = var.db_password +} + +resource "coder_app" "supabase" { + count = var.dashboard_app ? 1 : 0 + agent_id = var.agent_id + slug = "supabase" + display_name = "Supabase" + icon = var.icon + url = var.project_ref != "" ? "https://supabase.com/dashboard/project/${var.project_ref}" : "https://supabase.com/dashboard" + external = true +} + + +# Pass-through of coder-utils script outputs so upstream modules can serialize +# their coder_script resources behind this module's install pipeline using +# `coder exp sync want `. +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} + +output "access_token" { + description = "The Supabase access token (from external auth or direct variable)." + value = local.access_token + sensitive = true +} + +output "module_directory" { + description = "The directory where Supabase CLI logs and scripts are stored." + value = "$HOME/${local.module_dir_name}" +} diff --git a/registry/coder/modules/supabase/main.tftest.hcl b/registry/coder/modules/supabase/main.tftest.hcl new file mode 100644 index 000000000..7e84b59ae --- /dev/null +++ b/registry/coder/modules/supabase/main.tftest.hcl @@ -0,0 +1,302 @@ +run "test_supabase_basic" { + command = plan + + variables { + agent_id = "test-agent-123" + } + + assert { + condition = var.agent_id == "test-agent-123" + error_message = "Agent ID variable should be set correctly" + } + + assert { + condition = var.install_method == "detect" + error_message = "Install method should default to 'detect'" + } + + assert { + condition = var.supabase_version == "latest" + error_message = "Version should default to 'latest'" + } + + assert { + condition = var.use_external_auth == false + error_message = "use_external_auth should default to false (PAT method)" + } +} + +run "test_supabase_with_direct_token" { + command = plan + + variables { + agent_id = "test-agent-456" + use_external_auth = false + access_token = "sbp_test_token_1234567890abcdef12345678" + } + + assert { + condition = var.use_external_auth == false + error_message = "use_external_auth should be false" + } + + assert { + condition = var.access_token == "sbp_test_token_1234567890abcdef12345678" + error_message = "Access token should be set correctly" + } +} + +run "test_supabase_with_custom_install_method" { + command = plan + + variables { + agent_id = "test-agent-789" + install_method = "binary" + } + + assert { + condition = var.install_method == "binary" + error_message = "Install method should be 'binary'" + } +} + +run "test_supabase_with_brew_install" { + command = plan + + variables { + agent_id = "test-agent-brew" + install_method = "brew" + } + + assert { + condition = var.install_method == "brew" + error_message = "Install method should be 'brew'" + } +} + +run "test_supabase_with_scoop_install" { + command = plan + + variables { + agent_id = "test-agent-scoop" + install_method = "scoop" + } + + assert { + condition = var.install_method == "scoop" + error_message = "Install method should be 'scoop'" + } +} + +run "test_supabase_with_specific_version" { + command = plan + + variables { + agent_id = "test-agent-version" + supabase_version = "2.0.0" + } + + assert { + condition = var.supabase_version == "2.0.0" + error_message = "Version should be '2.0.0'" + } +} + +run "test_supabase_with_db_password" { + command = plan + + variables { + agent_id = "test-agent-db" + use_external_auth = false + access_token = "sbp_test_token" + db_password = "my-secret-password" + } + + assert { + condition = var.db_password == "my-secret-password" + error_message = "Database password should be set correctly" + } +} + +run "test_supabase_with_custom_external_auth_id" { + command = plan + + variables { + agent_id = "test-agent-auth" + external_auth_id = "my-supabase-oauth" + } + + assert { + condition = var.external_auth_id == "my-supabase-oauth" + error_message = "External auth ID should be 'my-supabase-oauth'" + } +} + +run "test_supabase_with_custom_icon" { + command = plan + + variables { + agent_id = "test-agent-icon" + icon = "/icon/custom-supabase.svg" + } + + assert { + condition = var.icon == "/icon/custom-supabase.svg" + error_message = "Icon should be set to custom path" + } +} + +run "test_supabase_with_pre_install_script" { + command = plan + + variables { + agent_id = "test-agent-pre" + pre_install_script = "echo 'Pre-install script'" + } + + assert { + condition = var.pre_install_script == "echo 'Pre-install script'" + error_message = "Pre-install script should be set" + } +} + +run "test_supabase_with_post_install_script" { + command = plan + + variables { + agent_id = "test-agent-post" + post_install_script = "echo 'Post-install script'" + } + + assert { + condition = var.post_install_script == "echo 'Post-install script'" + error_message = "Post-install script should be set" + } +} + +run "test_supabase_app_default_url" { + command = apply + + variables { + agent_id = "test-agent-app" + } + + assert { + condition = resource.coder_app.supabase[0].url == "https://supabase.com/dashboard" + error_message = "coder_app URL should default to dashboard when project_ref is empty" + } + + assert { + condition = resource.coder_app.supabase[0].external == true + error_message = "coder_app should be external" + } + + assert { + condition = resource.coder_app.supabase[0].slug == "supabase" + error_message = "coder_app slug should be 'supabase'" + } +} + +run "test_supabase_app_with_project_ref" { + command = apply + + variables { + agent_id = "test-agent-project" + project_ref = "abcdefghijklmnop" + } + + assert { + condition = var.project_ref == "abcdefghijklmnop" + error_message = "project_ref should be set correctly" + } + + assert { + condition = resource.coder_app.supabase[0].url == "https://supabase.com/dashboard/project/abcdefghijklmnop" + error_message = "coder_app URL should include project reference" + } +} + +run "test_supabase_with_project_dir" { + command = plan + + variables { + agent_id = "test-agent-project-dir" + project_ref = "abcdefghijklmnop" + project_dir = "/home/coder/my-app" + } + + assert { + condition = var.project_dir == "/home/coder/my-app" + error_message = "project_dir should be set correctly" + } + + assert { + condition = var.project_ref == "abcdefghijklmnop" + error_message = "project_ref should be set correctly" + } +} + +run "test_supabase_app_disabled" { + command = apply + + variables { + agent_id = "test-agent-no-app" + dashboard_app = false + } + + assert { + condition = var.dashboard_app == false + error_message = "dashboard_app should be false" + } + + assert { + condition = length(resource.coder_app.supabase) == 0 + error_message = "coder_app should not be created when dashboard_app is false" + } +} + +run "test_supabase_skip_install" { + command = plan + + variables { + agent_id = "test-agent-skip" + skip_install = true + } + + assert { + condition = var.skip_install == true + error_message = "skip_install should be true" + } +} + +run "test_supabase_custom_download_url" { + command = plan + + variables { + agent_id = "test-agent-mirror" + download_base_url = "https://internal-mirror.corp/supabase/releases" + } + + assert { + condition = var.download_base_url == "https://internal-mirror.corp/supabase/releases" + error_message = "download_base_url should be set to custom mirror" + } +} + +run "test_supabase_skip_install_with_token" { + command = apply + + variables { + agent_id = "test-agent-skip-token" + skip_install = true + use_external_auth = false + access_token = "sbp_test_token_for_skip" + } + + assert { + condition = var.skip_install == true + error_message = "skip_install should be true" + } +} + + diff --git a/registry/coder/modules/supabase/scripts/install.sh.tftpl b/registry/coder/modules/supabase/scripts/install.sh.tftpl new file mode 100644 index 000000000..f36efa965 --- /dev/null +++ b/registry/coder/modules/supabase/scripts/install.sh.tftpl @@ -0,0 +1,273 @@ +#!/bin/bash +set -euo pipefail + +MODULE_DIR="$${HOME}/.coder-modules/coder/supabase" +LOG_DIR="$${MODULE_DIR}/logs" +BIN_DIR="$${MODULE_DIR}/bin" +mkdir -p "$${LOG_DIR}" "$${BIN_DIR}" + +SKIP_INSTALL='${ARG_SKIP_INSTALL}' +INSTALL_METHOD='${ARG_INSTALL_METHOD}' +VERSION='${ARG_VERSION}' +PROJECT_REF='${ARG_PROJECT_REF}' +PROJECT_DIR='${ARG_PROJECT_DIR}' +DOWNLOAD_BASE_URL='${ARG_DOWNLOAD_BASE_URL}' +ACCESS_TOKEN=$(echo -n '${ARG_ACCESS_TOKEN}' | base64 -d) + +detect_platform() { + ARCH=$(uname -m) + case "$${ARCH}" in + x86_64 | amd64) ARCH="amd64" ;; + aarch64 | arm64) ARCH="arm64" ;; + *) + echo "Error: Unsupported architecture: $${ARCH}" + exit 1 + ;; + esac + + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$${OS}" in + linux | darwin) ;; + mingw* | msys* | cygwin*) + OS="windows" + ;; + *) + echo "Error: Unsupported OS: $${OS}. Only linux, darwin, and windows are supported." + exit 1 + ;; + esac + + echo "Detected platform: $${OS}/$${ARCH}" +} + +fetch_to_file() { + local dest="$1" url="$2" + if command -v curl > /dev/null 2>&1; then + curl -fsSL --retry 5 --retry-delay 5 -o "$${dest}" "$${url}" + elif command -v wget > /dev/null 2>&1; then + wget -q -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -q -O "$${dest}" "$${url}" + else + echo "Error: curl, wget, or busybox is required" + return 1 + fi +} + +resolve_version() { + local version="$${VERSION}" + if [ "$${version}" = "latest" ]; then + local api_response + if command -v curl > /dev/null 2>&1; then + api_response=$(curl -fsSL "https://api.github.com/repos/supabase/cli/releases/latest") + elif command -v wget > /dev/null 2>&1; then + api_response=$(wget -qO- "https://api.github.com/repos/supabase/cli/releases/latest") + else + echo "Error: curl or wget required for version resolution" + exit 1 + fi + version=$(echo "$${api_response}" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"v?([0-9][^"]*).*/\1/') + echo "Resolved latest version: $${version}" >&2 + fi + echo "$${version}" +} + +setup_path() { + local bin_path="$1" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ]; then + ln -sf "$${bin_path}" "$${CODER_SCRIPT_BIN_DIR}/supabase" + echo "Linked supabase to CODER_SCRIPT_BIN_DIR" + fi + + local bin_dir + bin_dir=$(dirname "$${bin_path}") + for profile in "$${HOME}/.profile" "$${HOME}/.bash_profile" "$${HOME}/.bashrc" "$${HOME}/.zprofile" "$${HOME}/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${bin_dir}" "$${profile}" 2>/dev/null; then + echo "export PATH=\"\$PATH:$${bin_dir}\"" >> "$${profile}" + echo "Added $${bin_dir} to $${profile}" + fi + fi + done + + local fish_config="$${HOME}/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${bin_dir}" "$${fish_config}" 2>/dev/null; then + echo "fish_add_path $${bin_dir}" >> "$${fish_config}" + echo "Added $${bin_dir} to $${fish_config}" + fi + fi +} + +install_binary() { + detect_platform + local version + version=$(resolve_version) + + local download_url="$${DOWNLOAD_BASE_URL}/v$${version}/supabase_$${OS}_$${ARCH}.tar.gz" + + local tmp_dir + tmp_dir=$(mktemp -d) + trap "rm -rf $${tmp_dir}" EXIT + + echo "Downloading Supabase CLI v$${version} from $${download_url}..." + fetch_to_file "$${tmp_dir}/supabase.tar.gz" "$${download_url}" + + tar -xzf "$${tmp_dir}/supabase.tar.gz" -C "$${tmp_dir}" + mv "$${tmp_dir}/supabase" "$${BIN_DIR}/supabase" + chmod +x "$${BIN_DIR}/supabase" + + setup_path "$${BIN_DIR}/supabase" +} + +install_native_package() { + detect_platform + local version + version=$(resolve_version) + + local tmp_dir + tmp_dir=$(mktemp -d) + trap "rm -rf $${tmp_dir}" EXIT + + if command -v dpkg > /dev/null 2>&1; then + local pkg_url="$${DOWNLOAD_BASE_URL}/v$${version}/supabase_$${version}_$${OS}_$${ARCH}.deb" + echo "Installing via dpkg..." + fetch_to_file "$${tmp_dir}/supabase.deb" "$${pkg_url}" + if command -v sudo > /dev/null 2>&1; then + sudo dpkg -i "$${tmp_dir}/supabase.deb" || { + echo "Warning: dpkg install failed, trying binary fallback" + install_binary + return + } + else + dpkg -i "$${tmp_dir}/supabase.deb" 2>/dev/null || { + echo "Warning: dpkg install failed (no sudo), trying binary fallback" + install_binary + return + } + fi + elif command -v rpm > /dev/null 2>&1; then + local pkg_url="$${DOWNLOAD_BASE_URL}/v$${version}/supabase_$${version}_$${OS}_$${ARCH}.rpm" + echo "Installing via rpm..." + fetch_to_file "$${tmp_dir}/supabase.rpm" "$${pkg_url}" + if command -v sudo > /dev/null 2>&1; then + sudo rpm -i "$${tmp_dir}/supabase.rpm" || { + echo "Warning: rpm install failed, trying binary fallback" + install_binary + return + } + else + rpm -i "$${tmp_dir}/supabase.rpm" 2>/dev/null || { + echo "Warning: rpm install failed (no sudo), trying binary fallback" + install_binary + return + } + fi + elif command -v apk > /dev/null 2>&1; then + local pkg_url="$${DOWNLOAD_BASE_URL}/v$${version}/supabase_$${version}_$${OS}_$${ARCH}.apk" + echo "Installing via apk..." + fetch_to_file "$${tmp_dir}/supabase.apk" "$${pkg_url}" + if command -v sudo > /dev/null 2>&1; then + sudo apk add --allow-untrusted "$${tmp_dir}/supabase.apk" || { + echo "Warning: apk install failed, trying binary fallback" + install_binary + return + } + else + apk add --allow-untrusted "$${tmp_dir}/supabase.apk" 2>/dev/null || { + echo "Warning: apk install failed (no sudo), trying binary fallback" + install_binary + return + } + fi + else + echo "No supported package manager found, falling back to binary install" + install_binary + fi +} + +install_brew() { + if ! command -v brew > /dev/null 2>&1; then + echo "Error: Homebrew is required for install_method=brew" + exit 1 + fi + echo "Installing via Homebrew..." + brew install supabase/tap/supabase || brew upgrade supabase/tap/supabase || true +} + +install_scoop() { + if ! command -v scoop > /dev/null 2>&1; then + echo "Error: Scoop is required for install_method=scoop" + exit 1 + fi + echo "Installing via Scoop..." + scoop bucket add supabase https://github.com/supabase/scoop-bucket.git 2>/dev/null || true + scoop install supabase || scoop update supabase || true +} + +install_supabase_cli() { + echo "Installing Supabase CLI (method: $${INSTALL_METHOD}, version: $${VERSION})..." + + if [ "$${INSTALL_METHOD}" = "detect" ]; then + if command -v brew > /dev/null 2>&1; then + INSTALL_METHOD="brew" + elif command -v scoop > /dev/null 2>&1; then + INSTALL_METHOD="scoop" + elif command -v dpkg > /dev/null 2>&1 || command -v rpm > /dev/null 2>&1 || command -v apk > /dev/null 2>&1; then + INSTALL_METHOD="native" + else + INSTALL_METHOD="binary" + fi + echo "Detected install method: $${INSTALL_METHOD}" + fi + + case "$${INSTALL_METHOD}" in + brew) install_brew ;; + scoop) install_scoop ;; + native) install_native_package ;; + binary) install_binary ;; + *) + echo "Error: Unknown install method: $${INSTALL_METHOD}" >&2 + exit 1 + ;; + esac +} + +if [ "$${SKIP_INSTALL}" != "true" ]; then + install_supabase_cli +else + echo "Skipping Supabase CLI installation (skip_install=true)" +fi + +SUPABASE_BIN="" +if command -v supabase > /dev/null 2>&1; then + SUPABASE_BIN="supabase" +elif [ -x "$${BIN_DIR}/supabase" ]; then + SUPABASE_BIN="$${BIN_DIR}/supabase" +fi + +if [ -z "$${SUPABASE_BIN}" ]; then + echo "Error: Supabase CLI not found. Ensure it is installed or skip_install is false." >&2 + exit 1 +fi + +echo "✓ Supabase CLI available: $($${SUPABASE_BIN} --version)" + +if [ -n "$${ACCESS_TOKEN}" ]; then + echo "Logging in with access token..." + $${SUPABASE_BIN} login --token "$${ACCESS_TOKEN}" + echo "✓ Logged in to Supabase" +fi + +if [ -n "$${PROJECT_REF}" ]; then + LINK_DIR="$${PROJECT_DIR:-$${HOME}}" + mkdir -p "$${LINK_DIR}" + echo "Linking to Supabase project: $${PROJECT_REF} in $${LINK_DIR}..." + cd "$${LINK_DIR}" + if $${SUPABASE_BIN} link --project-ref "$${PROJECT_REF}" 2>&1; then + echo "✓ Linked to project $${PROJECT_REF}" + else + echo "Warning: Failed to link to project $${PROJECT_REF}. You may need to run 'supabase link --project-ref $${PROJECT_REF}' manually." + fi +fi