From 64e4f7a722c11b6a20744ff8ebf7af7616efbb19 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 30 Sep 2026 12:58:44 +0000 Subject: [PATCH 1/2] refactor(registry/coder-labs/modules/opencode)!: remove agentapi, tasks and start logic - Replace the agentapi module with coder-utils and an install-only scripts/install.sh.tftpl using the official installer. - Drop Tasks/AgentAPI inputs, the start script, the task-reporting MCP server, and the task_app_id output; add the scripts output. - Make workdir optional; merge config_json and the new mcp variable into ~/.config/opencode/opencode.json (existing MCP servers win). - Deliver auth_json via coder_env and merge it into auth.json (0600). - Add managed_settings written as root to /etc/opencode/opencode.json. - Rewrite README and tests; bump to 1.0.0. --- .../coder-labs/modules/opencode/README.md | 179 +++-- .../coder-labs/modules/opencode/main.test.ts | 622 +++++++++++------- registry/coder-labs/modules/opencode/main.tf | 221 +++---- .../modules/opencode/main.tftest.hcl | 321 +++------ .../modules/opencode/scripts/install.sh | 131 ---- .../modules/opencode/scripts/install.sh.tftpl | 273 ++++++++ .../modules/opencode/scripts/start.sh | 71 -- .../opencode/testdata/opencode-mock.sh | 28 +- 8 files changed, 944 insertions(+), 902 deletions(-) delete mode 100755 registry/coder-labs/modules/opencode/scripts/install.sh create mode 100644 registry/coder-labs/modules/opencode/scripts/install.sh.tftpl delete mode 100755 registry/coder-labs/modules/opencode/scripts/start.sh diff --git a/registry/coder-labs/modules/opencode/README.md b/registry/coder-labs/modules/opencode/README.md index e462b6884..ab8385597 100644 --- a/registry/coder-labs/modules/opencode/README.md +++ b/registry/coder-labs/modules/opencode/README.md @@ -1,103 +1,164 @@ --- display_name: OpenCode icon: ../../../../.icons/opencode.svg -description: Run OpenCode AI coding assistant for AI-powered terminal assistance +description: Install and configure the OpenCode AI coding agent in your workspace. verified: false -tags: [agent, opencode, ai, tasks] +tags: [agent, opencode, ai] --- # OpenCode -Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for intelligent code generation, analysis, and development assistance. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for seamless task reporting in the Coder UI. +Install and configure [OpenCode](https://opencode.ai) in your workspace. ```tf module "opencode" { source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" + version = "1.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder/project" } ``` -## Prerequisites - -- **Authentication credentials** - OpenCode auth.json file is required for non-interactive authentication, you can find this file on your system: `$HOME/.local/share/opencode/auth.json` +> [!WARNING] +> If upgrading from v0.x.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures OpenCode; launch it with your own `coder_app`. `workdir` is now optional. `ai_prompt`, `continue`, and `session_id` are removed in favor of the `--prompt`, `--continue`, and `--session` CLI flags in your launcher. `config_json` is now merged into `~/.config/opencode/opencode.json` instead of replacing it, and MCP servers move from `config_json` to the new `mcp` variable. `auth_json` is merged into `auth.json` instead of replacing it. Keep using v0.x.x if you depend on Coder Tasks. ## Examples -### Basic Usage +### Standalone mode with a launcher app ```tf -module "opencode" { - source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - - auth_json = <<-EOT -{ - "google": { - "type": "api", - "key": "gem-xxx-xxxx" - }, - "anthropic": { - "type": "api", - "key": "sk-ant-api03-xxx-xxxxxxx" - } - +locals { + opencode_workdir = "/home/coder/project" } -EOT + +module "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = local.opencode_workdir + auth_json = var.opencode_auth_json config_json = jsonencode({ - "$schema" = "https://opencode.ai/config.json" - mcp = { - filesystem = { - command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"] - enabled = true - type = "local" - environment = { - SOME_VARIABLE_X = "value" - } - } - playwright = { - command = ["npx", "-y", "@playwright/mcp@latest", "--headless", "--isolated"] - enabled = true - type = "local" - } - } - model = "anthropic/claude-sonnet-4-20250514" + model = "anthropic/claude-sonnet-4-5" }) +} - pre_install_script = <<-EOT +resource "coder_app" "opencode" { + agent_id = coder_agent.main.id + slug = "opencode" + display_name = "OpenCode" + icon = "/icon/opencode.svg" + open_in = "slim-window" + command = <<-EOT #!/usr/bin/env bash - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + set -e + cd "${local.opencode_workdir}" + exec opencode --continue EOT } ``` -### Standalone CLI Mode +When `workdir` is set, the module creates it if missing. Pass `--model`, `--continue`, `--session`, `--agent`, or any other [CLI flag](https://opencode.ai/docs/cli/) in the launcher command. -Run OpenCode as a command-line tool without web interface or task reporting: +> [!NOTE] +> The `coder_app` command re-executes on every pane reconnect. This works for the interactive `opencode` TUI, but one-shot commands like `opencode run` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (for example, with tmux or `opencode attach`). + +### MCP servers ```tf module "opencode" { - source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + mcp = jsonencode({ + playwright = { + type = "local" + command = ["npx", "-y", "@playwright/mcp@latest", "--headless", "--isolated"] + enabled = true + } + context7 = { + type = "remote" + url = "https://mcp.context7.com/mcp" + } + }) +} +``` + +`mcp` uses the format of the [`mcp` key in `opencode.json`](https://opencode.ai/docs/mcp-servers/) and is merged into `~/.config/opencode/opencode.json`. Servers already in that file win on duplicate names, so changes made inside the workspace (for example with `opencode mcp add`) are not overwritten. + +> [!NOTE] +> The official installer ships a self-contained binary and does not install Node.js. MCP servers whose `command` is `npx` or `uvx` need that runtime available in the workspace image, or installed with `pre_install_script`. + +### Managed settings + +```tf +module "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + managed_settings = { + share = "disabled" + autoupdate = false + permission = { + bash = "ask" + } + } +} +``` + +`managed_settings` is written as root to `/etc/opencode/opencode.json`. OpenCode loads [managed settings](https://opencode.ai/docs/config/#managed-settings) last, so user and project config cannot override them. Writing the file requires root or passwordless `sudo`; otherwise the module logs a warning and skips it. + +### Serialize a downstream `coder_script` after the install pipeline + +The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. + +```tf +module "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} + +resource "coder_script" "post_opencode" { agent_id = coder_agent.main.id - workdir = "/home/coder" - report_tasks = false - cli_app = true + display_name = "Run after OpenCode install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-opencode' EXIT + coder exp sync want post-opencode ${join(" ", module.opencode.scripts)} + coder exp sync start post-opencode + + opencode --version + EOT } ``` +## Configuration + +`config_json` is deep-merged into the global `~/.config/opencode/opencode.json`: keys you set win, and everything else in the file is preserved. If the existing file is not valid JSON (for example, JSONC with comments), it is backed up to `opencode.json.bak` and replaced. See the [OpenCode config reference](https://opencode.ai/docs/config/) for available keys. + +`auth_json` uses the format of `~/.local/share/opencode/auth.json` (the file `opencode providers login` writes). Its provider entries are merged into that file with mode `0600`, and other credentials on disk are preserved. The value is exported to the workspace as `CODER_OPENCODE_AUTH_JSON` so it is never rendered into the install script. Alternatively, skip `auth_json` and set the provider's API key environment variable (for example `ANTHROPIC_API_KEY`) with a `coder_env`. See [OpenCode providers](https://opencode.ai/docs/providers/). + +The module installs OpenCode with the [official installer](https://opencode.ai/install) into `~/.opencode/bin`. Set `opencode_version` to pin a release; when a different version is already installed, the installer runs again. With `latest`, the install is skipped when `opencode` is already on `PATH`. OpenCode updates itself on startup by default; set `autoupdate = false` in `config_json` or `managed_settings` to keep a pinned version. If `install_opencode = false`, a working `opencode` must already be available on `PATH`, or workspace startup fails. + ## Troubleshooting -If you encounter any issues, check the log files in the `~/.opencode-module` directory within your workspace for detailed information. +Check the log files in `~/.coder-modules/coder-labs/opencode/logs/` for detailed information. + +```bash +cat ~/.coder-modules/coder-labs/opencode/logs/install.log +cat ~/.coder-modules/coder-labs/opencode/logs/pre_install.log +cat ~/.coder-modules/coder-labs/opencode/logs/post_install.log +``` + +Run `opencode debug config` in the workspace to see the resolved configuration. ## References -- [Opencode JSON Config](https://opencode.ai/docs/config/) -- [OpenCode Documentation](https://opencode.ai/docs) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) +- [OpenCode documentation](https://opencode.ai/docs) +- [OpenCode config](https://opencode.ai/docs/config/) +- [OpenCode MCP servers](https://opencode.ai/docs/mcp-servers/) +- [OpenCode CLI](https://opencode.ai/docs/cli/) diff --git a/registry/coder-labs/modules/opencode/main.test.ts b/registry/coder-labs/modules/opencode/main.test.ts index dec42a592..f2bd4f47c 100644 --- a/registry/coder-labs/modules/opencode/main.test.ts +++ b/registry/coder-labs/modules/opencode/main.test.ts @@ -6,15 +6,67 @@ import { beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - loadTestFile, + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { + extractCoderEnvVars, writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, } from "../../../coder/modules/agentapi/test-util"; -import dedent from "dedent"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `OpenCode: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -33,35 +85,106 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipOpencodeMock?: boolean; moduleVariables?: Record; - agentapiMockScript?: string; } -const setup = async (props?: SetupProps): Promise<{ id: string }> => { - const projectDir = "/home/coder/project"; - const { id } = await setupUtil({ - moduleDir: import.meta.dir, - moduleVariables: { - install_opencode: props?.skipOpencodeMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - workdir: projectDir, - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, +const projectDir = "/home/coder/project"; + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + workdir: projectDir, + install_opencode: "false", + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", }); if (!props?.skipOpencodeMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/opencode", - content: await loadTestFile(import.meta.dir, "opencode-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "opencode-mock.sh"), + ).text(), }); } - return { id }; + return { id, coderEnvVars, scripts }; +}; + +// coder_env values reach coder_script processes through the agent environment. +const envArgs = (env: Record) => + Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); + +const runScript = async ( + id: string, + name: string, + script: string, + env: Record = {}, +) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", target], envArgs(env)); +}; + +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env: Record = {}, +) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runScript(id, name, script, env); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } +}; + +const moduleLogDir = "/home/coder/.coder-modules/coder-labs/opencode/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const configPath = "/home/coder/.config/opencode/opencode.json"; +const authPath = "/home/coder/.local/share/opencode/auth.json"; +const managedPath = "/etc/opencode/opencode.json"; + +const seedFile = async (id: string, filePath: string, content: string) => { + const encoded = Buffer.from(content).toString("base64"); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p "$(dirname '${filePath}')" && echo '${encoded}' | base64 -d > '${filePath}'`, + ]); }; setDefaultTimeout(60 * 1000); @@ -71,292 +194,297 @@ describe("opencode", async () => { await runTerraformInit(import.meta.dir); }); - test("happy-path", async () => { - const { id } = await setup(); - await execModuleScript(id); - await expectAgentAPIStarted(id); + test("happy-path-validates-existing-binary", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping OpenCode installation"); + expect(log).toContain("Validated existing OpenCode"); + expect(log).toContain("0.0.0-mock"); + expect(log).toContain("OpenCode module setup completed."); + expect(log).not.toContain("agentapi"); + // Nothing is written when no config is provided. + const cfg = await execContainer(id, ["test", "-e", configPath]); + expect(cfg.exitCode).not.toBe(0); }); - test("install-opencode-version", async () => { - const version_to_install = "0.1.0"; - const { id } = await setup({ - skipOpencodeMock: true, - moduleVariables: { - install_opencode: "true", - opencode_version: version_to_install, - pre_install_script: dedent` - #!/usr/bin/env bash - set -euo pipefail - - # Mock the opencode install for testing - mkdir -p /home/coder/.opencode/bin - echo '#!/bin/bash\necho "opencode mock version ${version_to_install}"' > /home/coder/.opencode/bin/opencode - chmod +x /home/coder/.opencode/bin/opencode - `, - }, - }); - await execModuleScript(id); - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.opencode-module/install.log`, - ]); - expect(resp.stdout).toContain(version_to_install); + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipOpencodeMock: true }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("was not found or is not executable"); }); - test("check-latest-opencode-version-works", async () => { - const { id } = await setup({ + test("official-installer-is-used", async () => { + const { id, scripts } = await setup({ skipOpencodeMock: true, - skipAgentAPIMock: true, - moduleVariables: { - install_opencode: "true", - pre_install_script: dedent` - #!/usr/bin/env bash - set -euo pipefail - - # Mock the opencode install for testing - mkdir -p /home/coder/.opencode/bin - echo '#!/bin/bash\necho "opencode mock latest version"' > /home/coder/.opencode/bin/opencode - chmod +x /home/coder/.opencode/bin/opencode - `, - }, + moduleVariables: { install_opencode: "true", opencode_version: "v1.2.3" }, }); - await execModuleScript(id); - await expectAgentAPIStarted(id); - }); - - test("opencode-auth-json", async () => { - const authJson = JSON.stringify({ - token: "test-auth-token-123", - user: "test-user", + await writeExecutable({ + containerId: id, + filePath: "/tmp/opencode-installer-fixture.sh", + content: [ + "#!/usr/bin/env bash", + 'printf "VERSION=%s ARGS=%s\\n" "$VERSION" "$*" > /tmp/opencode-installer-env', + 'mkdir -p "$HOME/.opencode/bin"', + `cat > "$HOME/.opencode/bin/opencode" <<'EOF'`, + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "1.2.3"; fi', + "EOF", + 'chmod +x "$HOME/.opencode/bin/opencode"', + ].join("\n"), }); - const { id } = await setup({ - moduleVariables: { - auth_json: authJson, - }, + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/opencode-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/opencode-installer-fixture.sh "$out"', + ].join("\n"), }); - await execModuleScript(id); - - const authFile = await readFileContainer( + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Installed OpenCode"); + expect(log).toContain("OpenCode version: 1.2.3"); + const curlArgs = await readFileContainer(id, "/tmp/opencode-curl-args"); + expect(curlArgs).toContain("https://opencode.ai/install"); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 300"); + const installerEnv = await readFileContainer( id, - "/home/coder/.local/share/opencode/auth.json", + "/tmp/opencode-installer-env", ); - - expect(authFile).toContain("test-auth-token-123"); - expect(authFile).toContain("test-user"); + expect(installerEnv.trim()).toBe("VERSION=v1.2.3 ARGS=--no-modify-path"); + const bashrc = await readFileContainer(id, "/home/coder/.bashrc"); + expect(bashrc).toContain("/home/coder/.opencode/bin"); }); - test("opencode-config-json", async () => { - const configJson = JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - test: { - command: ["test-cmd"], - type: "local", - }, - }, - model: "anthropic/claude-sonnet-4-20250514", + test("latest-is-passed-to-installer-as-empty-version", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, }); - const { id } = await setup({ - moduleVariables: { - config_json: configJson, - }, + await writeExecutable({ + containerId: id, + filePath: "/tmp/opencode-installer-fixture.sh", + content: [ + "#!/usr/bin/env bash", + 'printf "VERSION=[%s]\\n" "$VERSION" > /tmp/opencode-installer-env', + 'mkdir -p "$HOME/.opencode/bin"', + 'printf "#!/bin/sh\\necho 9.9.9\\n" > "$HOME/.opencode/bin/opencode"', + 'chmod +x "$HOME/.opencode/bin/opencode"', + ].join("\n"), }); - await execModuleScript(id); - - const configFile = await readFileContainer( + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/opencode-installer-fixture.sh "$out"', + ].join("\n"), + }); + await runScripts(id, scripts); + const installerEnv = await readFileContainer( id, - "/home/coder/.config/opencode/opencode.json", + "/tmp/opencode-installer-env", ); - expect(configFile).toContain("test-cmd"); - expect(configFile).toContain("anthropic/claude-sonnet-4-20250514"); + expect(installerEnv.trim()).toBe("VERSION=[]"); }); - test("opencode-ai-prompt", async () => { - const prompt = "This is a task prompt for OpenCode."; - const { id } = await setup({ - moduleVariables: { - ai_prompt: prompt, - }, + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, }); - await execModuleScript(id); - - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.opencode-module/agentapi-start.log`, - ]); - expect(resp.stdout).toContain(prompt); - }); - - test("opencode-continue-flag", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - ai_prompt: "test prompt", - }, + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: "#!/bin/bash\nexit 22\n", }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("--continue"); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("could not be downloaded"); }); - test("opencode-continue-with-session-id", async () => { - const sessionId = "session-123"; - const { id } = await setup({ - moduleVariables: { - continue: "true", - session_id: sessionId, - ai_prompt: "test prompt", - }, - }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("--continue"); - expect(startLog.stdout).toContain(`--session ${sessionId}`); + test("workdir-is-created", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const dir = await execContainer(id, ["test", "-d", projectDir]); + expect(dir.exitCode).toBe(0); }); - test("opencode-session-id", async () => { - const sessionId = "session-123"; - const { id } = await setup({ + test("config-json-merges-and-preserves-existing-keys", async () => { + const { id, scripts } = await setup({ moduleVariables: { - session_id: sessionId, - ai_prompt: "test prompt", + config_json: JSON.stringify({ + model: "anthropic/claude-sonnet-4-5", + provider: { anthropic: { options: { timeout: 600000 } } }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--session ${sessionId}`); + await seedFile( + id, + configPath, + JSON.stringify({ + model: "openai/gpt-5", + share: "manual", + provider: { anthropic: { options: { setCacheKey: true } } }, + }), + ); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.model).toBe("anthropic/claude-sonnet-4-5"); + expect(cfg.share).toBe("manual"); + expect(cfg.provider.anthropic.options.timeout).toBe(600000); + expect(cfg.provider.anthropic.options.setCacheKey).toBe(true); + expect(cfg.$schema).toBe("https://opencode.ai/config.json"); }); - test("opencode-report-tasks-enabled", async () => { - const { id } = await setup({ + test("writes-mcp-servers-without-coder-server", async () => { + const { id, scripts } = await setup({ moduleVariables: { - report_tasks: "true", - ai_prompt: "test prompt", + mcp: JSON.stringify({ + playwright: { + type: "local", + command: ["npx", "-y", "@playwright/mcp"], + }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.mcp.playwright.command).toEqual([ + "npx", + "-y", + "@playwright/mcp", ]); - expect(startLog.stdout).toContain( - "report your progress using coder_report_task", - ); + expect(cfg.mcp.coder).toBeUndefined(); }); - test("opencode-report-tasks-disabled", async () => { - const { id } = await setup({ + test("merges-mcp-config-existing-servers-win", async () => { + const { id, scripts } = await setup({ moduleVariables: { - report_tasks: "false", - ai_prompt: "test prompt", + mcp: JSON.stringify({ + shared: { type: "local", command: ["module-command"] }, + extra: { type: "local", command: ["extra-command"] }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).not.toContain( - "report your progress using coder_report_task", + await seedFile( + id, + configPath, + JSON.stringify({ + theme: "keep-me", + mcp: { + shared: { type: "local", command: ["existing-command"] }, + seeded: { type: "remote", url: "https://seeded.example.com" }, + }, + }), ); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.theme).toBe("keep-me"); + expect(cfg.mcp.shared.command).toEqual(["existing-command"]); + expect(cfg.mcp.seeded.url).toBe("https://seeded.example.com"); + expect(cfg.mcp.extra.command).toEqual(["extra-command"]); }); - test("cli-app-creation", async () => { - const { id } = await setup({ + test("invalid-existing-config-is-backed-up", async () => { + const { id, scripts } = await setup({ moduleVariables: { - cli_app: "true", - cli_app_display_name: "OpenCode Terminal", + config_json: JSON.stringify({ share: "disabled" }), }, }); - await execModuleScript(id); - // CLI app creation is handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await seedFile(id, configPath, '{ // jsonc\n "share": "auto" }'); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.share).toBe("disabled"); + const backup = await readFileContainer(id, `${configPath}.bak`); + expect(backup).toContain("// jsonc"); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ + test("auth-json-from-env-merges-and-is-private", async () => { + const secret = "sk-ant-test-secret-123"; + const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'opencode-pre-install-script'", - post_install_script: "#!/bin/bash\necho 'opencode-post-install-script'", + auth_json: JSON.stringify({ + anthropic: { type: "api", key: secret }, + }), }, }); - await execModuleScript(id); - - const preInstallLog = await readFileContainer( - id, - "/home/coder/.opencode-module/pre_install.log", + expect(coderEnvVars["CODER_OPENCODE_AUTH_JSON"]).toContain(secret); + expect(scripts.install).not.toContain(secret); + expect(scripts.install).not.toContain( + Buffer.from(coderEnvVars["CODER_OPENCODE_AUTH_JSON"]).toString("base64"), ); - expect(preInstallLog).toContain("opencode-pre-install-script"); - - const postInstallLog = await readFileContainer( + await seedFile( id, - "/home/coder/.opencode-module/post_install.log", + authPath, + JSON.stringify({ + anthropic: { type: "api", key: "old-key" }, + openai: { type: "api", key: "keep-me" }, + }), ); - expect(postInstallLog).toContain("opencode-post-install-script"); + await runScripts(id, scripts, coderEnvVars); + const auth = JSON.parse(await readFileContainer(id, authPath)); + expect(auth.anthropic.key).toBe(secret); + expect(auth.openai.key).toBe("keep-me"); + const mode = await execContainer(id, ["stat", "-c", "%a", authPath]); + expect(mode.stdout.trim()).toBe("600"); + const log = await installLog(id); + expect(log).not.toContain(secret); }); - test("workdir-variable", async () => { - const workdir = "/home/coder/opencode-test-folder"; - const { id } = await setup({ - skipOpencodeMock: false, - moduleVariables: { - workdir, - }, - }); - await execModuleScript(id); - - const resp = await readFileContainer( - id, - "/home/coder/.opencode-module/agentapi-start.log", - ); - expect(resp).toContain(workdir); + test("no-auth-json-leaves-auth-file-absent", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, ["test", "-e", authPath]); + expect(resp.exitCode).not.toBe(0); }); - test("subdomain-enabled", async () => { - const { id } = await setup({ + test("managed-settings-written-as-root", async () => { + const { id, scripts } = await setup({ moduleVariables: { - subdomain: "true", + managed_settings: JSON.stringify({ share: "disabled" }), }, }); - await execModuleScript(id); - // Subdomain configuration is handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await runScripts(id, scripts); + const managed = JSON.parse(await readFileContainer(id, managedPath)); + expect(managed.share).toBe("disabled"); + const owner = await execContainer(id, ["stat", "-c", "%U %a", managedPath]); + expect(owner.stdout.trim()).toBe("root 644"); + const log = await installLog(id); + expect(log).toContain(`Wrote OpenCode managed settings to ${managedPath}`); + }); + + test("managed-settings-absent-when-unset", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, ["test", "-e", managedPath]); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).not.toContain("managed settings"); }); - test("custom-display-names", async () => { - const { id } = await setup({ + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ moduleVariables: { - web_app_display_name: "Custom OpenCode Web", - cli_app_display_name: "Custom OpenCode CLI", - cli_app: "true", + pre_install_script: "#!/bin/bash\necho 'opencode-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'opencode-post-install-script'", }, }); - await execModuleScript(id); - // Display names are handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("opencode-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("opencode-post-install-script"); }); }); diff --git a/registry/coder-labs/modules/opencode/main.tf b/registry/coder-labs/modules/opencode/main.tf index 880417669..3afff0ca6 100644 --- a/registry/coder-labs/modules/opencode/main.tf +++ b/registry/coder-labs/modules/opencode/main.tf @@ -14,55 +14,16 @@ variable "agent_id" { description = "The ID of a Coder agent." } -data "coder_workspace" "me" {} - -data "coder_workspace_owner" "me" {} - -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - variable "icon" { type = string - description = "The icon to use for the app." + description = "The icon to use for the install scripts." default = "/icon/opencode.svg" } variable "workdir" { type = string - description = "The folder to run OpenCode in." -} - -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI" - default = true -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for OpenCode" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "OpenCode" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "OpenCode CLI" + description = "Optional project directory. When set, the module pre-creates it if missing. OpenCode has no workspace trust prompt, so nothing else is written for it." + default = null } variable "pre_install_script" { @@ -77,127 +38,107 @@ variable "post_install_script" { default = null } -variable "install_agentapi" { +variable "install_opencode" { type = bool - description = "Whether to install AgentAPI." + description = "Whether to install OpenCode with the official installer (https://opencode.ai/install). When false, a working opencode must already be on PATH." default = true } -variable "agentapi_version" { +variable "opencode_version" { type = string - description = "The version of AgentAPI to install." - default = "v0.11.2" + description = "The version of OpenCode to install, for example '1.18.33'. Use 'latest' for the newest release. See https://github.com/anomalyco/opencode/releases" + default = "latest" + + validation { + condition = can(regex("^(latest|v?[0-9]+\\.[0-9]+\\.[0-9]+[0-9A-Za-z.+-]*)$", var.opencode_version)) + error_message = "opencode_version must be 'latest' or a release version such as '1.18.33'." + } } -variable "ai_prompt" { +variable "auth_json" { type = string - description = "Initial task prompt for OpenCode." + description = "OpenCode credentials in the format of $HOME/.local/share/opencode/auth.json. Merged into that file (module-provided providers win, other providers are preserved). Exported to the workspace as CODER_OPENCODE_AUTH_JSON so it is never rendered into the install script. See https://opencode.ai/docs/providers/#credentials" + sensitive = true default = "" -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} -variable "install_opencode" { - type = bool - description = "Whether to install OpenCode." - default = true + validation { + condition = var.auth_json == "" || can(keys(jsondecode(var.auth_json))) + error_message = "auth_json must be a JSON object keyed by provider ID." + } } -variable "opencode_version" { +variable "config_json" { type = string - description = "The version of OpenCode to install." - default = "latest" -} + description = "OpenCode config as JSON, deep-merged into the global $HOME/.config/opencode/opencode.json. Keys set here win; other keys on disk are preserved. Configure MCP servers with the mcp variable instead. See https://opencode.ai/docs/config/" + default = "" -variable "continue" { - type = bool - description = "continue the last session. Uses the --continue flag" - default = false + validation { + condition = var.config_json == "" || can(keys(jsondecode(var.config_json))) + error_message = "config_json must be a JSON object." + } + + validation { + condition = var.config_json == "" || !can(jsondecode(var.config_json).mcp) + error_message = "config_json must not contain an mcp key; use the mcp variable for MCP servers." + } } -variable "session_id" { +variable "mcp" { type = string - description = "Session id to continue. Passed via --session" + description = "MCP servers as a JSON object keyed by server name, in the format of the mcp key of opencode.json. Merged into $HOME/.config/opencode/opencode.json; servers already on disk win on duplicate names. See https://opencode.ai/docs/mcp-servers/" default = "" + + validation { + condition = var.mcp == "" || can(keys(jsondecode(var.mcp))) + error_message = "mcp must be a JSON object keyed by MCP server name." + } } -variable "auth_json" { - type = string - description = "Your auth.json from $HOME/.local/share/opencode/auth.json, Required for non-interactive authentication" - default = "" +variable "managed_settings" { + type = any + description = "OpenCode config written as root to /etc/opencode/opencode.json. Managed config has the highest precedence and cannot be overridden by user or project config. See https://opencode.ai/docs/config/#managed-settings" + default = null + + validation { + condition = var.managed_settings == null || can(keys(var.managed_settings)) + error_message = "managed_settings must be an object." + } } -variable "config_json" { - type = string - description = "OpenCode JSON config. https://opencode.ai/docs/config/" - default = "" +resource "coder_env" "opencode_auth_json" { + count = var.auth_json != "" ? 1 : 0 + agent_id = var.agent_id + name = "CODER_OPENCODE_AUTH_JSON" + value = var.auth_json } locals { - workdir = trimsuffix(var.workdir, "/") - app_slug = "opencode" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".opencode-module" -} - -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - agentapi_subdomain = var.subdomain - folder = local.workdir - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - - ARG_WORKDIR='${local.workdir}' \ - ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_SESSION_ID='${var.session_id}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_CONTINUE='${var.continue}' \ - /tmp/start.sh - EOT - - install_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - ARG_OPENCODE_VERSION='${var.opencode_version}' \ - ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ - ARG_INSTALL_OPENCODE='${var.install_opencode}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_WORKDIR='${local.workdir}' \ - ARG_AUTH_JSON='${var.auth_json != null ? base64encode(replace(var.auth_json, "'", "'\\''")) : ""}' \ - ARG_OPENCODE_CONFIG='${var.config_json != null ? base64encode(replace(var.config_json, "'", "'\\''")) : ""}' \ - /tmp/install.sh - EOT -} - -output "task_app_id" { - value = module.agentapi.task_app_id + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_opencode) + ARG_OPENCODE_VERSION = var.opencode_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_CONFIG_JSON = var.config_json != "" ? base64encode(var.config_json) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" + }) + module_dir_name = ".coder-modules/coder-labs/opencode" +} + +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 = "OpenCode" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +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 } diff --git a/registry/coder-labs/modules/opencode/main.tftest.hcl b/registry/coder-labs/modules/opencode/main.tftest.hcl index e8c7954b7..810f8879a 100644 --- a/registry/coder-labs/modules/opencode/main.tftest.hcl +++ b/registry/coder-labs/modules/opencode/main.tftest.hcl @@ -3,76 +3,40 @@ run "defaults_are_correct" { variables { agent_id = "test-agent" - workdir = "/home/coder/project" } assert { condition = var.install_opencode == true - error_message = "OpenCode installation should be enabled by default" - } - - assert { - condition = var.install_agentapi == true - error_message = "AgentAPI installation should be enabled by default" - } - - assert { - condition = var.agentapi_version == "v0.11.2" - error_message = "Default AgentAPI version should be 'v0.11.2'" + error_message = "install_opencode should default to true" } assert { condition = var.opencode_version == "latest" - error_message = "Default OpenCode version should be 'latest'" - } - - assert { - condition = var.report_tasks == true - error_message = "Task reporting should be enabled by default" + error_message = "opencode_version should default to 'latest'" } assert { - condition = var.cli_app == false - error_message = "CLI app should be disabled by default" + condition = local.workdir == "" + error_message = "workdir should be empty by default" } assert { - condition = var.subdomain == false - error_message = "Subdomain should be disabled by default" + condition = local.module_dir_name == ".coder-modules/coder-labs/opencode" + error_message = "module_dir_name should be '.coder-modules/coder-labs/opencode'" } assert { - condition = var.web_app_display_name == "OpenCode" - error_message = "Default web app display name should be 'OpenCode'" + condition = length(coder_env.opencode_auth_json) == 0 + error_message = "CODER_OPENCODE_AUTH_JSON should not be created when auth_json is empty" } assert { - condition = var.cli_app_display_name == "OpenCode CLI" - error_message = "Default CLI app display name should be 'OpenCode CLI'" - } - - assert { - condition = local.app_slug == "opencode" - error_message = "App slug should be 'opencode'" - } - - assert { - condition = local.module_dir_name == ".opencode-module" - error_message = "Module dir name should be '.opencode-module'" - } - - assert { - condition = local.workdir == "/home/coder/project" - error_message = "Workdir should be trimmed of trailing slash" - } - - assert { - condition = var.continue == false - error_message = "Continue flag should be disabled by default" + condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='latest'") + error_message = "install script should receive the opencode version" } } -run "workdir_trailing_slash_trimmed" { +run "workdir_trailing_slash_is_trimmed" { command = plan variables { @@ -82,293 +46,186 @@ run "workdir_trailing_slash_trimmed" { assert { condition = local.workdir == "/home/coder/project" - error_message = "Workdir should be trimmed of trailing slash" - } -} - -run "opencode_version_configuration" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - opencode_version = "v1.0.0" + error_message = "workdir should have its trailing slash trimmed" } assert { - condition = var.opencode_version == "v1.0.0" - error_message = "OpenCode version should be set correctly" + condition = strcontains(local.install_script, base64encode("/home/coder/project")) + error_message = "install script should receive the base64-encoded workdir" } } -run "agentapi_version_configuration" { +run "auth_json_creates_env_var" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - agentapi_version = "v0.9.0" - } - - assert { - condition = var.agentapi_version == "v0.9.0" - error_message = "AgentAPI version should be set correctly" - } -} - -run "cli_app_configuration" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - cli_app = true - cli_app_display_name = "Custom OpenCode CLI" + agent_id = "test-agent" + auth_json = "{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-ant-test-secret\"}}" } assert { - condition = var.cli_app == true - error_message = "CLI app should be enabled when specified" + condition = coder_env.opencode_auth_json[0].name == "CODER_OPENCODE_AUTH_JSON" && coder_env.opencode_auth_json[0].value == var.auth_json + error_message = "CODER_OPENCODE_AUTH_JSON env var should be created with the provided auth_json" } assert { - condition = var.cli_app_display_name == "Custom OpenCode CLI" - error_message = "Custom CLI app display name should be set" + condition = !strcontains(local.install_script, "sk-ant-test-secret") && !strcontains(local.install_script, base64encode(nonsensitive(var.auth_json))) + error_message = "auth_json should not be rendered into the install script" } } -run "web_app_configuration" { +run "config_and_mcp_are_encoded" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - web_app_display_name = "Custom OpenCode Web" - order = 5 - group = "AI Tools" - icon = "/custom/icon.svg" - } - - assert { - condition = var.web_app_display_name == "Custom OpenCode Web" - error_message = "Custom web app display name should be set" - } - - assert { - condition = var.order == 5 - error_message = "Custom order should be set" + agent_id = "test-agent" + config_json = "{\"model\": \"anthropic/claude-sonnet-4-5\"}" + mcp = "{\"playwright\": {\"type\": \"local\", \"command\": [\"npx\", \"-y\", \"@playwright/mcp\"]}}" } assert { - condition = var.group == "AI Tools" - error_message = "Custom group should be set" + condition = strcontains(local.install_script, base64encode(var.config_json)) + error_message = "install script should receive the base64-encoded config_json" } assert { - condition = var.icon == "/custom/icon.svg" - error_message = "Custom icon should be set" + condition = strcontains(local.install_script, base64encode(var.mcp)) + error_message = "install script should receive the base64-encoded mcp" } } -run "ai_configuration_variables" { +run "managed_settings_are_encoded" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - ai_prompt = "This is a test prompt" - session_id = "session-123" - continue = true - } - - assert { - condition = var.ai_prompt == "This is a test prompt" - error_message = "AI prompt should be set correctly" - } - - assert { - condition = var.session_id == "session-123" - error_message = "Session ID should be set correctly" + agent_id = "test-agent" + managed_settings = { + share = "disabled" + } } assert { - condition = var.continue == true - error_message = "Continue flag should be set correctly" + condition = strcontains(local.install_script, base64encode(jsonencode({ share = "disabled" }))) + error_message = "install script should receive the base64-encoded managed_settings" } } -run "auth_json_configuration" { +run "invalid_auth_json_fails" { command = plan variables { agent_id = "test-agent" - workdir = "/home/coder/project" - auth_json = "{\"token\": \"test-token\", \"user\": \"test-user\"}" - } - - assert { - condition = var.auth_json != "" - error_message = "Auth JSON should be set" + auth_json = "not-json" } - assert { - condition = can(jsondecode(var.auth_json)) - error_message = "Auth JSON should be valid JSON" - } + expect_failures = [ + var.auth_json, + ] } -run "config_json_configuration" { +run "invalid_opencode_version_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - config_json = "{\"$schema\": \"https://opencode.ai/config.json\", \"mcp\": {\"test\": {\"command\": [\"test-cmd\"], \"type\": \"local\"}}, \"model\": \"anthropic/claude-sonnet-4-20250514\"}" - } - - assert { - condition = var.config_json != "" - error_message = "OpenCode JSON configuration should be set" + agent_id = "test-agent" + opencode_version = "1.0'; rm -rf ~; '" } - assert { - condition = can(jsondecode(var.config_json)) - error_message = "OpenCode JSON configuration should be valid JSON" - } + expect_failures = [ + var.opencode_version, + ] } -run "task_reporting_configuration" { +run "pinned_opencode_version_is_accepted" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - report_tasks = false + agent_id = "test-agent" + opencode_version = "v1.18.33" } assert { - condition = var.report_tasks == false - error_message = "Task reporting should be disabled when specified" + condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='v1.18.33'") + error_message = "install script should receive the pinned opencode version" } } -run "subdomain_configuration" { +run "invalid_config_json_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - subdomain = true + agent_id = "test-agent" + config_json = "[1, 2]" } - assert { - condition = var.subdomain == true - error_message = "Subdomain should be enabled when specified" - } + expect_failures = [ + var.config_json, + ] } -run "install_flags_configuration" { +run "config_json_with_mcp_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - install_opencode = false - install_agentapi = false - } - - assert { - condition = var.install_opencode == false - error_message = "OpenCode installation should be disabled when specified" + agent_id = "test-agent" + config_json = "{\"mcp\": {\"x\": {\"type\": \"remote\", \"url\": \"https://example.com\"}}}" } - assert { - condition = var.install_agentapi == false - error_message = "AgentAPI installation should be disabled when specified" - } + expect_failures = [ + var.config_json, + ] } -run "custom_scripts_configuration" { +run "invalid_mcp_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - pre_install_script = "#!/bin/bash\necho 'pre-install'" - post_install_script = "#!/bin/bash\necho 'post-install'" - } - - assert { - condition = var.pre_install_script != null - error_message = "Pre-install script should be set" - } - - assert { - condition = var.post_install_script != null - error_message = "Post-install script should be set" - } - - assert { - condition = can(regex("pre-install", var.pre_install_script)) - error_message = "Pre-install script should contain expected content" + agent_id = "test-agent" + mcp = "[\"x\"]" } - assert { - condition = can(regex("post-install", var.post_install_script)) - error_message = "Post-install script should contain expected content" - } + expect_failures = [ + var.mcp, + ] } -run "empty_variables_handled_correctly" { +run "invalid_managed_settings_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - ai_prompt = "" - session_id = "" - auth_json = "" - config_json = "" - continue = false - } - - assert { - condition = var.ai_prompt == "" - error_message = "Empty AI prompt should be handled correctly" + agent_id = "test-agent" + managed_settings = "share=disabled" } - assert { - condition = var.session_id == "" - error_message = "Empty session ID should be handled correctly" - } + expect_failures = [ + var.managed_settings, + ] +} - assert { - condition = var.auth_json == "" - error_message = "Empty auth JSON should be handled correctly" - } +run "scripts_output_is_ordered" { + command = plan - assert { - condition = var.config_json == "" - error_message = "Empty config JSON should be handled correctly" + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" } assert { - condition = var.continue == false - error_message = "Continue flag default should be handled correctly" + condition = output.scripts == ["coder-labs-opencode-pre_install_script", "coder-labs-opencode-install_script", "coder-labs-opencode-post_install_script"] + error_message = "scripts output should be ordered pre_install, install, post_install" } } -run "continue_flag_configuration" { +run "scripts_output_install_only" { command = plan variables { agent_id = "test-agent" - workdir = "/home/coder/project" - continue = true } assert { - condition = var.continue == true - error_message = "Continue flag should be enabled when specified" + condition = output.scripts == ["coder-labs-opencode-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" } -} \ No newline at end of file +} diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh b/registry/coder-labs/modules/opencode/scripts/install.sh deleted file mode 100755 index 280d2a9aa..000000000 --- a/registry/coder-labs/modules/opencode/scripts/install.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} -ARG_OPENCODE_VERSION=${ARG_OPENCODE_VERSION:-latest} -ARG_INSTALL_OPENCODE=${ARG_INSTALL_OPENCODE:-true} -ARG_AUTH_JSON=$(echo -n "$ARG_AUTH_JSON" | base64 -d 2> /dev/null || echo "") -ARG_OPENCODE_CONFIG=$(echo -n "$ARG_OPENCODE_CONFIG" | base64 -d 2> /dev/null || echo "") - -# Print all received environment variables -printf "=== INSTALL CONFIG ===\n" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG" -printf "ARG_OPENCODE_VERSION: %s\n" "$ARG_OPENCODE_VERSION" -printf "ARG_INSTALL_OPENCODE: %s\n" "$ARG_INSTALL_OPENCODE" -if [ -n "$ARG_AUTH_JSON" ]; then - printf "ARG_AUTH_JSON: [AUTH DATA RECEIVED]\n" -else - printf "ARG_AUTH_JSON: [NOT PROVIDED]\n" -fi -if [ -n "$ARG_OPENCODE_CONFIG" ]; then - printf "ARG_OPENCODE_CONFIG: [RECEIVED]\n" -else - printf "ARG_OPENCODE_CONFIG: [NOT PROVIDED]\n" -fi -printf "==================================\n" - -install_opencode() { - if [ "$ARG_INSTALL_OPENCODE" = "true" ]; then - if ! command_exists opencode; then - echo "Installing OpenCode (version: ${ARG_OPENCODE_VERSION})..." - if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then - curl -fsSL https://opencode.ai/install | bash - else - curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash - fi - export PATH=/home/coder/.opencode/bin:$PATH - printf "Opencode location: %s\n" "$(which opencode)" - if ! command_exists opencode; then - echo "ERROR: Failed to install OpenCode" - exit 1 - fi - echo "OpenCode installed successfully" - else - echo "OpenCode already installed" - fi - else - echo "OpenCode installation skipped (ARG_INSTALL_OPENCODE=false)" - fi -} - -setup_opencode_config() { - local opencode_config_file="$HOME/.config/opencode/opencode.json" - local auth_json_file="$HOME/.local/share/opencode/auth.json" - - mkdir -p "$(dirname "$auth_json_file")" - mkdir -p "$(dirname "$opencode_config_file")" - - setup_opencode_auth "$auth_json_file" - - if [ -n "$ARG_OPENCODE_CONFIG" ]; then - echo "Writing to the config file" - echo "$ARG_OPENCODE_CONFIG" > "$opencode_config_file" - fi - - if [ "$ARG_REPORT_TASKS" = "true" ]; then - setup_coder_mcp_server "$opencode_config_file" - fi - - echo "MCP configuration completed: $opencode_config_file" -} - -setup_opencode_auth() { - local auth_json_file="$1" - - if [ -n "$ARG_AUTH_JSON" ]; then - echo "$ARG_AUTH_JSON" > "$auth_json_file" - printf "added auth json to %s" "$auth_json_file" - else - printf "auth json not provided" - fi -} - -setup_coder_mcp_server() { - local opencode_config_file="$1" - - # Set environment variables based on task reporting setting - echo "Configuring OpenCode task reporting" - export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" - export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - echo "Coder integration configured for task reporting" - - # Add coder MCP server configuration to the JSON file - echo "Adding Coder MCP server configuration" - - # Create the coder server configuration JSON - coder_config=$( - cat << EOF -{ - "type": "local", - "command": ["coder", "exp", "mcp", "server"], - "enabled": true, - "environment": { - "CODER_MCP_APP_STATUS_SLUG": "${CODER_MCP_APP_STATUS_SLUG:-}", - "CODER_MCP_AI_AGENTAPI_URL": "${CODER_MCP_AI_AGENTAPI_URL:-}", - "CODER_AGENT_URL": "${CODER_AGENT_URL:-}", - "CODER_AGENT_TOKEN": "${CODER_AGENT_TOKEN:-}", - "CODER_MCP_ALLOWED_TOOLS": "coder_report_task" - } -} -EOF - ) - - temp_file=$(mktemp) - jq --argjson coder_config "$coder_config" '.mcp.coder = $coder_config' "$opencode_config_file" > "$temp_file" - mv "$temp_file" "$opencode_config_file" - echo "Coder MCP server configuration added" - -} - -install_opencode -setup_opencode_config - -echo "OpenCode module setup completed." diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl new file mode 100644 index 000000000..f911cf88b --- /dev/null +++ b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl @@ -0,0 +1,273 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_OPENCODE_VERSION='${ARG_OPENCODE_VERSION}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_CONFIG_JSON=$(echo -n '${ARG_CONFIG_JSON}' | base64 -d) +ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) +ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) + +# Delivered through coder_env so credentials never appear in this script. +AUTH_JSON="$${CODER_OPENCODE_AUTH_JSON:-}" + +OPENCODE_BIN_DIR="$HOME/.opencode/bin" +export PATH="$${OPENCODE_BIN_DIR}:$PATH" + +echo "--------------------------------" +printf "install_opencode: %s\n" "$${ARG_INSTALL}" +printf "opencode_version: %s\n" "$${ARG_OPENCODE_VERSION}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +printf "auth_json: %s\n" "$([ -n "$${AUTH_JSON}" ] && echo provided || echo "not provided")" +echo "--------------------------------" + +require_jq() { + if ! command_exists jq; then + echo "ERROR: 'jq' is required to $1 but was not found." >&2 + exit 1 + fi +} + +# Prints the JSON object stored in $1, or {} when the file is missing. Invalid +# files (for example JSONC with comments) are backed up to $1.bak. +read_json_object() { + local file="$1" + if [ ! -f "$${file}" ]; then + echo '{}' + return + fi + if jq -e 'type == "object"' "$${file}" > /dev/null 2>&1; then + cat "$${file}" + return + fi + cp "$${file}" "$${file}.bak" + echo "Warning: $${file} is not a valid JSON object; backed it up to $${file}.bak" >&2 + echo '{}' +} + +function add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}" + echo "Added $${path_dir} to $${profile}" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then + echo "fish_add_path $${path_dir}" >> "$${fish_config}" + echo "Added $${path_dir} to $${fish_config}" + fi + fi +} + +function ensure_opencode_in_path() { + local OPENCODE_BIN="" + if command_exists opencode; then + OPENCODE_BIN=$(command -v opencode) + fi + + if [ -z "$${OPENCODE_BIN}" ] || [ ! -x "$${OPENCODE_BIN}" ]; then + echo "OpenCode binary (opencode) was not found or is not executable." >&2 + return 1 + fi + + local OPENCODE_VERSION + if ! OPENCODE_VERSION=$("$${OPENCODE_BIN}" --version); then + echo "OpenCode binary could not be executed." >&2 + return 1 + fi + printf "OpenCode version: %s\n" "$${OPENCODE_VERSION}" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/opencode" ]; then + ln -s "$${OPENCODE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/opencode" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/opencode -> $${OPENCODE_BIN}" + fi + + add_path_to_shell_profiles "$(dirname "$${OPENCODE_BIN}")" +} + +# Subshell scopes the temp-file cleanup trap to this function. +install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" https://opencode.ai/install; then + echo "OpenCode installer could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "OpenCode installer download was invalid." >&2 + return 1 + fi + + # The installer treats any non-empty VERSION as a release tag (a leading 'v' + # is stripped), so 'latest' must be passed as empty. It installs to + # ~/.opencode/bin; PATH is handled by this script. + local version="$${ARG_OPENCODE_VERSION}" + if [ "$${version}" = "latest" ]; then + version="" + fi + if ! VERSION="$${version}" bash "$${installer_file}" --no-modify-path; then + echo "OpenCode installation failed." >&2 + return 1 + fi +) + +install_opencode() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping OpenCode installation as per configuration." + if ! ensure_opencode_in_path; then + echo "ERROR: install_opencode is false but a working 'opencode' was not found on PATH." >&2 + exit 1 + fi + printf "%s Validated existing OpenCode\n" "$${BOLD}" + return + fi + + if command_exists opencode; then + local installed wanted + installed=$(opencode --version 2> /dev/null || true) + wanted="$${ARG_OPENCODE_VERSION#v}" + if [ "$${ARG_OPENCODE_VERSION}" = "latest" ] || [ "$${installed}" = "$${wanted}" ]; then + echo "OpenCode already installed ($${installed})" + ensure_opencode_in_path + return + fi + echo "OpenCode $${installed} is installed; installing $${wanted}" + fi + + printf "%s Installing OpenCode\n" "$${BOLD}" + install_with_official_installer + + if ! ensure_opencode_in_path; then + echo "ERROR: Failed to install OpenCode" >&2 + exit 1 + fi + printf "%s Installed OpenCode\n" "$${BOLD}" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +# Managed config is loaded last and cannot be overridden by user or project config. +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + + require_jq "write managed settings" + + if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then + echo "Warning: managed_settings is not a JSON object, skipping managed settings write" >&2 + return + fi + + local target="/etc/opencode/opencode.json" + if [ "$(id -u)" -eq 0 ]; then + mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + elif command_exists sudo && sudo -n true 2> /dev/null; then + sudo mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + echo "Warning: root access is required to write $${target}; skipping managed settings" >&2 + return + fi + echo "Wrote OpenCode managed settings to $${target}" +} + +# The module owns only the keys it sets; everything else in opencode.json is +# preserved. config_json keys win, while MCP servers already on disk win on +# duplicate names so in-workspace edits are not overwritten. +setup_config() { + local config_file="$HOME/.config/opencode/opencode.json" + + if [ -z "$${ARG_CONFIG_JSON}" ] && [ -z "$${ARG_MCP_CONFIG}" ]; then + echo "No config_json or mcp configured; leaving $${config_file} unchanged." + return + fi + + require_jq "configure OpenCode" + + mkdir -p "$(dirname "$${config_file}")" + + local existing config='{}' mcp='{}' + existing=$(read_json_object "$${config_file}") + if [ -n "$${ARG_CONFIG_JSON}" ]; then + config="$${ARG_CONFIG_JSON}" + fi + if [ -n "$${ARG_MCP_CONFIG}" ]; then + mcp="$${ARG_MCP_CONFIG}" + fi + + echo "$${existing}" | jq --argjson config "$${config}" --argjson mcp "$${mcp}" ' + (. * $config) + | if ($mcp | length) > 0 then .mcp = ($mcp + (.mcp // {})) else . end + | .["$schema"] //= "https://opencode.ai/config.json" + ' > "$${config_file}.tmp" + mv "$${config_file}.tmp" "$${config_file}" + echo "OpenCode configuration written: $${config_file}" +} + +# Provider entries from auth_json replace the same provider IDs on disk; other +# credentials (for example from `opencode providers login`) are preserved. +setup_auth() { + local auth_file="$HOME/.local/share/opencode/auth.json" + + if [ -z "$${AUTH_JSON}" ]; then + echo "No auth_json provided; leaving $${auth_file} unchanged." + return + fi + + require_jq "write OpenCode credentials" + + if ! echo "$${AUTH_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then + echo "ERROR: CODER_OPENCODE_AUTH_JSON is not a JSON object." >&2 + exit 1 + fi + + mkdir -p "$(dirname "$${auth_file}")" + + local existing + existing=$(read_json_object "$${auth_file}") + ( + umask 077 + # Read from the environment so credentials never appear in process arguments. + echo "$${existing}" | jq '. + (env.CODER_OPENCODE_AUTH_JSON | fromjson)' > "$${auth_file}.tmp" + ) + mv "$${auth_file}.tmp" "$${auth_file}" + chmod 0600 "$${auth_file}" + echo "OpenCode credentials written: $${auth_file}" +} + +install_opencode +setup_workdir +write_managed_settings +setup_config +setup_auth + +echo "OpenCode module setup completed." diff --git a/registry/coder-labs/modules/opencode/scripts/start.sh b/registry/coder-labs/modules/opencode/scripts/start.sh deleted file mode 100755 index 6691a61f4..000000000 --- a/registry/coder-labs/modules/opencode/scripts/start.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PATH=/home/coder/.opencode/bin:$PATH - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_SESSION_ID=${ARG_SESSION_ID:-} -ARG_CONTINUE=${ARG_CONTINUE:-false} - -# Print all received environment variables -printf "=== START CONFIG ===\n" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" -printf "ARG_SESSION_ID: %s\n" "$ARG_SESSION_ID" -if [ -n "$ARG_AI_PROMPT" ]; then - printf "ARG_AI_PROMPT: [AI PROMPT RECEIVED]\n" -else - printf "ARG_AI_PROMPT: [NOT PROVIDED]\n" -fi -printf "==================================\n" - -OPENCODE_ARGS=() -AGENTAPI_ARGS=() - -validate_opencode_installation() { - if ! command_exists opencode; then - printf "ERROR: OpenCode not installed. Set install_opencode to true\n" - exit 1 - fi -} - -build_opencode_args() { - - if [ -n "$ARG_SESSION_ID" ]; then - OPENCODE_ARGS+=(--session "$ARG_SESSION_ID") - fi - - if [ "$ARG_CONTINUE" = "true" ]; then - OPENCODE_ARGS+=(--continue) - fi - - if [ -n "$ARG_AI_PROMPT" ]; then - if [ "$ARG_REPORT_TASKS" = "true" ]; then - PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT" - else - PROMPT="$ARG_AI_PROMPT" - fi - AGENTAPI_ARGS+=(-I "$PROMPT") - fi -} - -start_agentapi() { - printf "Starting in directory: %s\n" "$ARG_WORKDIR" - cd "$ARG_WORKDIR" - - build_opencode_args - - printf "Running OpenCode with args: %s\n" "${OPENCODE_ARGS[*]}" - echo agentapi server "${AGENTAPI_ARGS[@]}" --type opencode --term-width 67 --term-height 1190 -- opencode "${OPENCODE_ARGS[@]}" - agentapi server "${AGENTAPI_ARGS[@]}" --type opencode --term-width 67 --term-height 1190 -- opencode "${OPENCODE_ARGS[@]}" -} - -validate_opencode_installation -start_agentapi diff --git a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh index dcde8756c..431c05ac0 100644 --- a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh +++ b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh @@ -1,25 +1,9 @@ -#!/bin/bash +#!/usr/bin/env bash -# Mock OpenCode CLI for testing purposes -# This script simulates the OpenCode command-line interface - -echo "OpenCode Mock CLI - Test Version" -echo "Args received: $*" - -# Simulate opencode behavior based on arguments -case "$1" in - --version | -v) - echo "opencode mock version 0.1.0-test" - ;; - --help | -h) - echo "OpenCode Mock Help" - echo "Usage: opencode [options] [command]" - echo "This is a mock version for testing" - ;; - *) - echo "Running OpenCode mock with arguments: $*" - echo "Mock execution completed successfully" - ;; -esac +if [[ "$1" == "--version" || "$1" == "-v" ]]; then + echo "0.0.0-mock" + exit 0 +fi +echo "opencode invoked with: $*" exit 0 From 83e4518bed1d74b2f8e2eefab2195b546f915486 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 12 Oct 2026 09:05:06 +0000 Subject: [PATCH 2/2] feat(registry/coder-labs/modules/opencode): target OpenCode 2 - Install with the V2 installer (opencode.ai/v2/install), retry transient failures, and upgrade or reject OpenCode 1. - auth_json now takes `opencode auth export` output and is applied with `opencode auth import`; V2 ignores auth.json on fresh installs. - Write mcp under mcp.servers and reject the V1 enabled field. - Drop managed_settings; V2 does not read /etc/opencode. Document experimental.policies instead. - Respect OPENCODE_CONFIG_DIR/XDG_CONFIG_HOME and reload a running shared service after config changes. - Point docs links at the V2 docs. --- .../coder-labs/modules/opencode/README.md | 75 +++-- .../coder-labs/modules/opencode/main.test.ts | 271 +++++++++++------- registry/coder-labs/modules/opencode/main.tf | 39 ++- .../modules/opencode/main.tftest.hcl | 64 +++-- .../modules/opencode/scripts/install.sh.tftpl | 146 ++++++---- .../opencode/testdata/opencode-mock.sh | 49 +++- 6 files changed, 407 insertions(+), 237 deletions(-) diff --git a/registry/coder-labs/modules/opencode/README.md b/registry/coder-labs/modules/opencode/README.md index ab8385597..014eb3e9e 100644 --- a/registry/coder-labs/modules/opencode/README.md +++ b/registry/coder-labs/modules/opencode/README.md @@ -8,7 +8,7 @@ tags: [agent, opencode, ai] # OpenCode -Install and configure [OpenCode](https://opencode.ai) in your workspace. +Install and configure [OpenCode 2](https://opencode.ai/v2/docs/) in your workspace. ```tf module "opencode" { @@ -19,7 +19,7 @@ module "opencode" { ``` > [!WARNING] -> If upgrading from v0.x.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures OpenCode; launch it with your own `coder_app`. `workdir` is now optional. `ai_prompt`, `continue`, and `session_id` are removed in favor of the `--prompt`, `--continue`, and `--session` CLI flags in your launcher. `config_json` is now merged into `~/.config/opencode/opencode.json` instead of replacing it, and MCP servers move from `config_json` to the new `mcp` variable. `auth_json` is merged into `auth.json` instead of replacing it. Keep using v0.x.x if you depend on Coder Tasks. +> If upgrading from v0.x.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures OpenCode; launch it with your own `coder_app`. `workdir` is now optional. `ai_prompt`, `continue`, and `session_id` are removed in favor of the `--prompt`, `--continue`, and `--session` CLI flags in your launcher. The module now installs OpenCode 2 and requires it. `config_json` is merged into the global `opencode.json` instead of replacing it, MCP servers move from `config_json` to the new `mcp` variable (written under `mcp.servers`), and `auth_json` now takes the output of `opencode auth export` instead of `auth.json` contents. Keep using v0.x.x if you depend on Coder Tasks. ## Examples @@ -57,10 +57,13 @@ resource "coder_app" "opencode" { } ``` -When `workdir` is set, the module creates it if missing. Pass `--model`, `--continue`, `--session`, `--agent`, or any other [CLI flag](https://opencode.ai/docs/cli/) in the launcher command. +When `workdir` is set, the module creates it if missing. Pass `--continue`, `--session`, `--prompt`, `--auto`, or any other flag from `opencode --help` in the launcher command, or launch the minimal interface with `opencode mini`. See the [OpenCode CLI](https://opencode.ai/v2/docs/cli/). + +> [!CAUTION] +> `--auto` approves every permission request that is not explicitly denied, including shell commands. Use it only in trusted environments. > [!NOTE] -> The `coder_app` command re-executes on every pane reconnect. This works for the interactive `opencode` TUI, but one-shot commands like `opencode run` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (for example, with tmux or `opencode attach`). +> The `coder_app` command re-executes on every pane reconnect. This works for the interactive `opencode` TUI, but one-shot commands like `opencode run` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (for example, with tmux). ### MCP servers @@ -74,22 +77,24 @@ module "opencode" { playwright = { type = "local" command = ["npx", "-y", "@playwright/mcp@latest", "--headless", "--isolated"] - enabled = true } context7 = { - type = "remote" - url = "https://mcp.context7.com/mcp" + type = "remote" + url = "https://mcp.context7.com/mcp" + disabled = true } }) } ``` -`mcp` uses the format of the [`mcp` key in `opencode.json`](https://opencode.ai/docs/mcp-servers/) and is merged into `~/.config/opencode/opencode.json`. Servers already in that file win on duplicate names, so changes made inside the workspace (for example with `opencode mcp add`) are not overwritten. +`mcp` maps server names to server objects in the format of [`mcp.servers`](https://opencode.ai/v2/docs/mcp-servers/) and is merged into the global `opencode.json` under `mcp.servers`. Servers connect automatically; set `disabled = true` to keep one configured without connecting it. The V1 `enabled` field is rejected at plan time because OpenCode 2 ignores it inside `mcp.servers`. Servers already in the file win on duplicate names, including V1-style entries listed directly under `mcp`, so changes made inside the workspace (for example with `opencode mcp add --global`) are not overwritten. > [!NOTE] > The official installer ships a self-contained binary and does not install Node.js. MCP servers whose `command` is `npx` or `uvx` need that runtime available in the workspace image, or installed with `pre_install_script`. -### Managed settings +### Policies + +OpenCode 2 has no system-wide managed settings file. Use [policies](https://opencode.ai/v2/docs/policies/) in `config_json` to deny providers or hard-deny permission checks. Policies in the global config override project config, so a repository cannot re-enable a provider you deny here. ```tf module "opencode" { @@ -97,17 +102,20 @@ module "opencode" { version = "1.0.0" agent_id = coder_agent.main.id - managed_settings = { - share = "disabled" - autoupdate = false - permission = { - bash = "ask" + config_json = jsonencode({ + experimental = { + policies = [ + { action = "provider.use", resource = "*", effect = "deny" }, + { action = "provider.use", resource = "anthropic", effect = "allow" }, + { action = "permission", resource = "shell:git push *", effect = "deny" }, + ] } - } + }) } ``` -`managed_settings` is written as root to `/etc/opencode/opencode.json`. OpenCode loads [managed settings](https://opencode.ai/docs/config/#managed-settings) last, so user and project config cannot override them. Writing the file requires root or passwordless `sudo`; otherwise the module logs a warning and skips it. +> [!NOTE] +> The global config is owned by the workspace user, so these policies guard against project config, not against the user. For organization-enforced policy, use a connected [OpenCode Console](https://opencode.ai/v2/docs/policies/#console) workspace. ### Serialize a downstream `coder_script` after the install pipeline @@ -138,11 +146,28 @@ resource "coder_script" "post_opencode" { ## Configuration -`config_json` is deep-merged into the global `~/.config/opencode/opencode.json`: keys you set win, and everything else in the file is preserved. If the existing file is not valid JSON (for example, JSONC with comments), it is backed up to `opencode.json.bak` and replaced. See the [OpenCode config reference](https://opencode.ai/docs/config/) for available keys. +`config_json` is deep-merged into the global `opencode.json` in `$OPENCODE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`. Keys you set win, and everything else in the file is preserved. If the existing file is not valid JSON (for example, JSONC with comments), it is backed up to `opencode.json.bak` and replaced. When OpenCode's shared background service is already running, the module runs `opencode reload` so the change takes effect. See the [OpenCode config reference](https://opencode.ai/v2/docs/config/) for available keys. + +OpenCode 2 stores credentials in its SQLite database, not in `auth.json`. `auth_json` takes the JSON array printed by `opencode auth export` and is applied with `opencode auth import`. Credentials with the same `id` are replaced on every start so rotated secrets apply, and other saved accounts are preserved. The value is exported to the workspace as `CODER_OPENCODE_AUTH_JSON` so it is never rendered into the install script. + +```tf +module "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + auth_json = jsonencode([{ + id = "coder-anthropic" + integrationID = "anthropic" + label = "coder" + active = true + value = { type = "key", key = var.anthropic_api_key } + }]) +} +``` -`auth_json` uses the format of `~/.local/share/opencode/auth.json` (the file `opencode providers login` writes). Its provider entries are merged into that file with mode `0600`, and other credentials on disk are preserved. The value is exported to the workspace as `CODER_OPENCODE_AUTH_JSON` so it is never rendered into the install script. Alternatively, skip `auth_json` and set the provider's API key environment variable (for example `ANTHROPIC_API_KEY`) with a `coder_env`. See [OpenCode providers](https://opencode.ai/docs/providers/). +Alternatively, skip `auth_json` and set the provider's API key environment variable (for example `ANTHROPIC_API_KEY`) with a `coder_env`. OpenCode lists it as an environment connection. See [OpenCode providers](https://opencode.ai/v2/docs/cli/providers/). -The module installs OpenCode with the [official installer](https://opencode.ai/install) into `~/.opencode/bin`. Set `opencode_version` to pin a release; when a different version is already installed, the installer runs again. With `latest`, the install is skipped when `opencode` is already on `PATH`. OpenCode updates itself on startup by default; set `autoupdate = false` in `config_json` or `managed_settings` to keep a pinned version. If `install_opencode = false`, a working `opencode` must already be available on `PATH`, or workspace startup fails. +The module installs OpenCode 2 with the [official installer](https://opencode.ai/v2/install) into `~/.opencode/bin`, retrying when the installer cannot resolve the latest release. Set `opencode_version` to pin a release; when a different version or OpenCode 1 is already installed, the installer runs again. With `latest`, the install is skipped when OpenCode 2 is already on `PATH`. OpenCode checks for updates by default; set `update = "disable"` in `config_json` to keep a pinned version. If `install_opencode = false`, a working OpenCode 2 `opencode` must already be available on `PATH`, or workspace startup fails. ## Troubleshooting @@ -154,11 +179,13 @@ cat ~/.coder-modules/coder-labs/opencode/logs/pre_install.log cat ~/.coder-modules/coder-labs/opencode/logs/post_install.log ``` -Run `opencode debug config` in the workspace to see the resolved configuration. +Run `opencode debug config` in the workspace to see the resolved configuration, and `opencode service status` to check the background service. OpenCode's own logs are in `~/.local/share/opencode/log/opencode.log`. See [OpenCode troubleshooting](https://opencode.ai/v2/docs/troubleshooting/). ## References -- [OpenCode documentation](https://opencode.ai/docs) -- [OpenCode config](https://opencode.ai/docs/config/) -- [OpenCode MCP servers](https://opencode.ai/docs/mcp-servers/) -- [OpenCode CLI](https://opencode.ai/docs/cli/) +- [OpenCode documentation](https://opencode.ai/v2/docs/) +- [OpenCode config](https://opencode.ai/v2/docs/config/) +- [OpenCode MCP servers](https://opencode.ai/v2/docs/mcp-servers/) +- [OpenCode policies](https://opencode.ai/v2/docs/policies/) +- [OpenCode CLI](https://opencode.ai/v2/docs/cli/) +- [Migrate from V1](https://opencode.ai/v2/docs/migrate-v1/) diff --git a/registry/coder-labs/modules/opencode/main.test.ts b/registry/coder-labs/modules/opencode/main.test.ts index f2bd4f47c..88f95ada7 100644 --- a/registry/coder-labs/modules/opencode/main.test.ts +++ b/registry/coder-labs/modules/opencode/main.test.ts @@ -175,8 +175,8 @@ const moduleLogDir = "/home/coder/.coder-modules/coder-labs/opencode/logs"; const installLog = (id: string) => readFileContainer(id, `${moduleLogDir}/install.log`); const configPath = "/home/coder/.config/opencode/opencode.json"; -const authPath = "/home/coder/.local/share/opencode/auth.json"; -const managedPath = "/etc/opencode/opencode.json"; +const credentialStore = "/home/coder/.opencode-mock-credentials.json"; +const mockCalls = "/tmp/opencode-mock-calls"; const seedFile = async (id: string, filePath: string, content: string) => { const encoded = Buffer.from(content).toString("base64"); @@ -187,6 +187,39 @@ const seedFile = async (id: string, filePath: string, content: string) => { ]); }; +const credential = (id: string, integrationID: string, key: string) => ({ + id, + integrationID, + label: id, + active: true, + value: { type: "key", key }, +}); + +// Replaces curl with a stub that serves `installer` as the downloaded script. +const mockInstallerDownload = async (id: string, installer: string[]) => { + await writeExecutable({ + containerId: id, + filePath: "/tmp/opencode-installer-fixture.sh", + content: ["#!/usr/bin/env bash", ...installer].join("\n"), + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/opencode-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/opencode-installer-fixture.sh "$out"', + ].join("\n"), + }); +}; + +const installFixtureBinary = (version: string) => [ + 'mkdir -p "$HOME/.opencode/bin"', + `printf '#!/bin/sh\\necho "opencode ${version}"\\n' > "$HOME/.opencode/bin/opencode"`, + 'chmod +x "$HOME/.opencode/bin/opencode"', +]; + setDefaultTimeout(60 * 1000); describe("opencode", async () => { @@ -200,7 +233,7 @@ describe("opencode", async () => { const log = await installLog(id); expect(log).toContain("Skipping OpenCode installation"); expect(log).toContain("Validated existing OpenCode"); - expect(log).toContain("0.0.0-mock"); + expect(log).toContain("opencode v2.0.0-mock"); expect(log).toContain("OpenCode module setup completed."); expect(log).not.toContain("agentapi"); // Nothing is written when no config is provided. @@ -216,41 +249,31 @@ describe("opencode", async () => { expect(log).toContain("was not found or is not executable"); }); - test("official-installer-is-used", async () => { + test("opencode-v1-is-rejected-when-install-disabled", async () => { + const { id, scripts } = await setup(); + const resp = await runScript(id, "install", scripts.install, { + OPENCODE_MOCK_VERSION: "1.18.33", + }); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("this module requires OpenCode 2"); + }); + + test("official-v2-installer-is-used", async () => { const { id, scripts } = await setup({ skipOpencodeMock: true, - moduleVariables: { install_opencode: "true", opencode_version: "v1.2.3" }, - }); - await writeExecutable({ - containerId: id, - filePath: "/tmp/opencode-installer-fixture.sh", - content: [ - "#!/usr/bin/env bash", - 'printf "VERSION=%s ARGS=%s\\n" "$VERSION" "$*" > /tmp/opencode-installer-env', - 'mkdir -p "$HOME/.opencode/bin"', - `cat > "$HOME/.opencode/bin/opencode" <<'EOF'`, - "#!/bin/sh", - 'if [ "$1" = "--version" ]; then echo "1.2.3"; fi', - "EOF", - 'chmod +x "$HOME/.opencode/bin/opencode"', - ].join("\n"), - }); - await writeExecutable({ - containerId: id, - filePath: "/usr/local/bin/curl", - content: [ - "#!/bin/bash", - "printf '%s\\n' \"$*\" > /tmp/opencode-curl-args", - 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', - 'cp /tmp/opencode-installer-fixture.sh "$out"', - ].join("\n"), + moduleVariables: { install_opencode: "true", opencode_version: "v2.1.3" }, }); + await mockInstallerDownload(id, [ + 'printf "VERSION=%s ARGS=%s\\n" "$VERSION" "$*" > /tmp/opencode-installer-env', + ...installFixtureBinary("v2.1.3"), + ]); await runScripts(id, scripts); const log = await installLog(id); expect(log).toContain("Installed OpenCode"); - expect(log).toContain("OpenCode version: 1.2.3"); + expect(log).toContain("OpenCode version: opencode v2.1.3"); const curlArgs = await readFileContainer(id, "/tmp/opencode-curl-args"); - expect(curlArgs).toContain("https://opencode.ai/install"); + expect(curlArgs).toContain("https://opencode.ai/v2/install"); expect(curlArgs).toContain("--retry 2"); expect(curlArgs).toContain("--connect-timeout 10"); expect(curlArgs).toContain("--max-time 300"); @@ -258,7 +281,7 @@ describe("opencode", async () => { id, "/tmp/opencode-installer-env", ); - expect(installerEnv.trim()).toBe("VERSION=v1.2.3 ARGS=--no-modify-path"); + expect(installerEnv.trim()).toBe("VERSION=v2.1.3 ARGS=--no-modify-path"); const bashrc = await readFileContainer(id, "/home/coder/.bashrc"); expect(bashrc).toContain("/home/coder/.opencode/bin"); }); @@ -268,26 +291,10 @@ describe("opencode", async () => { skipOpencodeMock: true, moduleVariables: { install_opencode: "true" }, }); - await writeExecutable({ - containerId: id, - filePath: "/tmp/opencode-installer-fixture.sh", - content: [ - "#!/usr/bin/env bash", - 'printf "VERSION=[%s]\\n" "$VERSION" > /tmp/opencode-installer-env', - 'mkdir -p "$HOME/.opencode/bin"', - 'printf "#!/bin/sh\\necho 9.9.9\\n" > "$HOME/.opencode/bin/opencode"', - 'chmod +x "$HOME/.opencode/bin/opencode"', - ].join("\n"), - }); - await writeExecutable({ - containerId: id, - filePath: "/usr/local/bin/curl", - content: [ - "#!/bin/bash", - 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', - 'cp /tmp/opencode-installer-fixture.sh "$out"', - ].join("\n"), - }); + await mockInstallerDownload(id, [ + 'printf "VERSION=[%s]\\n" "$VERSION" > /tmp/opencode-installer-env', + ...installFixtureBinary("v9.9.9"), + ]); await runScripts(id, scripts); const installerEnv = await readFileContainer( id, @@ -296,6 +303,43 @@ describe("opencode", async () => { expect(installerEnv.trim()).toBe("VERSION=[]"); }); + test("installer-is-retried-on-transient-failure", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, + }); + await mockInstallerDownload(id, [ + "n=$(cat /tmp/opencode-installer-runs 2>/dev/null || echo 0)", + "echo $((n + 1)) > /tmp/opencode-installer-runs", + 'if [ "$n" -lt 2 ]; then echo "Failed to fetch version information"; exit 1; fi', + ...installFixtureBinary("v2.0.1"), + ]); + await runScripts(id, scripts); + expect( + (await readFileContainer(id, "/tmp/opencode-installer-runs")).trim(), + ).toBe("3"); + expect(await installLog(id)).toContain("Installed OpenCode"); + }); + + test("installed-opencode-v1-is-upgraded-to-v2", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/opencode", + content: '#!/bin/sh\necho "1.18.33"\n', + }); + await mockInstallerDownload(id, installFixtureBinary("v2.0.27")); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain( + "OpenCode 1.18.33 is installed; installing OpenCode 2", + ); + expect(log).toContain("OpenCode version: opencode v2.0.27"); + }); + test("installer-download-failure-is-terminal", async () => { const { id, scripts } = await setup({ skipOpencodeMock: true, @@ -324,7 +368,7 @@ describe("opencode", async () => { moduleVariables: { config_json: JSON.stringify({ model: "anthropic/claude-sonnet-4-5", - provider: { anthropic: { options: { timeout: 600000 } } }, + providers: { anthropic: { settings: { timeout: 600000 } } }, }), }, }); @@ -334,19 +378,33 @@ describe("opencode", async () => { JSON.stringify({ model: "openai/gpt-5", share: "manual", - provider: { anthropic: { options: { setCacheKey: true } } }, + providers: { anthropic: { settings: { setCacheKey: true } } }, }), ); await runScripts(id, scripts); const cfg = JSON.parse(await readFileContainer(id, configPath)); expect(cfg.model).toBe("anthropic/claude-sonnet-4-5"); expect(cfg.share).toBe("manual"); - expect(cfg.provider.anthropic.options.timeout).toBe(600000); - expect(cfg.provider.anthropic.options.setCacheKey).toBe(true); + expect(cfg.providers.anthropic.settings.timeout).toBe(600000); + expect(cfg.providers.anthropic.settings.setCacheKey).toBe(true); expect(cfg.$schema).toBe("https://opencode.ai/config.json"); }); - test("writes-mcp-servers-without-coder-server", async () => { + test("config-dir-env-is-respected", async () => { + const configDir = "/home/coder/custom-opencode"; + const { id, scripts } = await setup({ + moduleVariables: { config_json: JSON.stringify({ share: "disabled" }) }, + }); + await runScripts(id, scripts, { OPENCODE_CONFIG_DIR: configDir }); + const cfg = JSON.parse( + await readFileContainer(id, `${configDir}/opencode.json`), + ); + expect(cfg.share).toBe("disabled"); + const fallback = await execContainer(id, ["test", "-e", configPath]); + expect(fallback.exitCode).not.toBe(0); + }); + + test("writes-mcp-servers-under-servers-key", async () => { const { id, scripts } = await setup({ moduleVariables: { mcp: JSON.stringify({ @@ -359,12 +417,13 @@ describe("opencode", async () => { }); await runScripts(id, scripts); const cfg = JSON.parse(await readFileContainer(id, configPath)); - expect(cfg.mcp.playwright.command).toEqual([ + expect(cfg.mcp.servers.playwright.command).toEqual([ "npx", "-y", "@playwright/mcp", ]); - expect(cfg.mcp.coder).toBeUndefined(); + expect(cfg.mcp.playwright).toBeUndefined(); + expect(cfg.mcp.servers.coder).toBeUndefined(); }); test("merges-mcp-config-existing-servers-win", async () => { @@ -372,6 +431,7 @@ describe("opencode", async () => { moduleVariables: { mcp: JSON.stringify({ shared: { type: "local", command: ["module-command"] }, + legacy: { type: "local", command: ["module-command"] }, extra: { type: "local", command: ["extra-command"] }, }), }, @@ -382,17 +442,25 @@ describe("opencode", async () => { JSON.stringify({ theme: "keep-me", mcp: { - shared: { type: "local", command: ["existing-command"] }, - seeded: { type: "remote", url: "https://seeded.example.com" }, + timeout: { startup: 45000 }, + legacy: { type: "local", command: ["v1-command"], enabled: true }, + servers: { + shared: { type: "local", command: ["existing-command"] }, + seeded: { type: "remote", url: "https://seeded.example.com" }, + }, }, }), ); await runScripts(id, scripts); const cfg = JSON.parse(await readFileContainer(id, configPath)); expect(cfg.theme).toBe("keep-me"); - expect(cfg.mcp.shared.command).toEqual(["existing-command"]); - expect(cfg.mcp.seeded.url).toBe("https://seeded.example.com"); - expect(cfg.mcp.extra.command).toEqual(["extra-command"]); + expect(cfg.mcp.timeout.startup).toBe(45000); + expect(cfg.mcp.servers.shared.command).toEqual(["existing-command"]); + expect(cfg.mcp.servers.seeded.url).toBe("https://seeded.example.com"); + expect(cfg.mcp.servers.extra.command).toEqual(["extra-command"]); + // A V1-shaped server already on disk also wins and is left in place. + expect(cfg.mcp.legacy.command).toEqual(["v1-command"]); + expect(cfg.mcp.servers.legacy).toBeUndefined(); }); test("invalid-existing-config-is-backed-up", async () => { @@ -409,13 +477,13 @@ describe("opencode", async () => { expect(backup).toContain("// jsonc"); }); - test("auth-json-from-env-merges-and-is-private", async () => { + test("auth-json-is-imported-and-rotated-without-leaking", async () => { const secret = "sk-ant-test-secret-123"; const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - auth_json: JSON.stringify({ - anthropic: { type: "api", key: secret }, - }), + auth_json: JSON.stringify([ + credential("coder-anthropic", "anthropic", secret), + ]), }, }); expect(coderEnvVars["CODER_OPENCODE_AUTH_JSON"]).toContain(secret); @@ -425,51 +493,58 @@ describe("opencode", async () => { ); await seedFile( id, - authPath, - JSON.stringify({ - anthropic: { type: "api", key: "old-key" }, - openai: { type: "api", key: "keep-me" }, - }), + credentialStore, + JSON.stringify([ + credential("coder-anthropic", "anthropic", "old-key"), + credential("user-openai", "openai", "keep-me"), + ]), ); await runScripts(id, scripts, coderEnvVars); - const auth = JSON.parse(await readFileContainer(id, authPath)); - expect(auth.anthropic.key).toBe(secret); - expect(auth.openai.key).toBe("keep-me"); - const mode = await execContainer(id, ["stat", "-c", "%a", authPath]); - expect(mode.stdout.trim()).toBe("600"); + const store = JSON.parse(await readFileContainer(id, credentialStore)); + const byId = Object.fromEntries( + store.map((c: { id: string; value: { key: string } }) => [ + c.id, + c.value.key, + ]), + ); + expect(byId["coder-anthropic"]).toBe(secret); + expect(byId["user-openai"]).toBe("keep-me"); + const calls = await readFileContainer(id, mockCalls); + expect(calls).toContain("auth logout anthropic coder-anthropic"); + expect(calls).not.toContain("user-openai"); const log = await installLog(id); + expect(log).toContain("OpenCode credentials imported."); expect(log).not.toContain(secret); }); - test("no-auth-json-leaves-auth-file-absent", async () => { + test("no-auth-json-skips-import", async () => { const { id, scripts } = await setup(); await runScripts(id, scripts); - const resp = await execContainer(id, ["test", "-e", authPath]); - expect(resp.exitCode).not.toBe(0); + const calls = await execContainer(id, ["test", "-e", mockCalls]); + expect(calls.exitCode).not.toBe(0); + expect(await installLog(id)).toContain("No auth_json provided"); }); - test("managed-settings-written-as-root", async () => { + test("running-service-is-reloaded", async () => { const { id, scripts } = await setup({ - moduleVariables: { - managed_settings: JSON.stringify({ share: "disabled" }), - }, + moduleVariables: { config_json: JSON.stringify({ share: "disabled" }) }, }); - await runScripts(id, scripts); - const managed = JSON.parse(await readFileContainer(id, managedPath)); - expect(managed.share).toBe("disabled"); - const owner = await execContainer(id, ["stat", "-c", "%U %a", managedPath]); - expect(owner.stdout.trim()).toBe("root 644"); - const log = await installLog(id); - expect(log).toContain(`Wrote OpenCode managed settings to ${managedPath}`); + await runScripts(id, scripts, { + OPENCODE_MOCK_SERVICE_URL: "http://127.0.0.1:49374", + }); + expect(await readFileContainer(id, mockCalls)).toContain("reload"); + expect(await installLog(id)).toContain( + "Reloaded the running OpenCode service.", + ); }); - test("managed-settings-absent-when-unset", async () => { - const { id, scripts } = await setup(); + test("stopped-service-is-not-started", async () => { + const { id, scripts } = await setup({ + moduleVariables: { config_json: JSON.stringify({ share: "disabled" }) }, + }); await runScripts(id, scripts); - const resp = await execContainer(id, ["test", "-e", managedPath]); - expect(resp.exitCode).not.toBe(0); - const log = await installLog(id); - expect(log).not.toContain("managed settings"); + const calls = await execContainer(id, ["test", "-e", mockCalls]); + expect(calls.exitCode).not.toBe(0); }); test("pre-post-install-scripts", async () => { diff --git a/registry/coder-labs/modules/opencode/main.tf b/registry/coder-labs/modules/opencode/main.tf index 3afff0ca6..2819a56ab 100644 --- a/registry/coder-labs/modules/opencode/main.tf +++ b/registry/coder-labs/modules/opencode/main.tf @@ -40,36 +40,38 @@ variable "post_install_script" { variable "install_opencode" { type = bool - description = "Whether to install OpenCode with the official installer (https://opencode.ai/install). When false, a working opencode must already be on PATH." + description = "Whether to install OpenCode with the official V2 installer (https://opencode.ai/v2/install). When false, a working OpenCode 2 opencode must already be on PATH." default = true } variable "opencode_version" { type = string - description = "The version of OpenCode to install, for example '1.18.33'. Use 'latest' for the newest release. See https://github.com/anomalyco/opencode/releases" + description = "The version of OpenCode to install, for example '2.0.27'. Use 'latest' for the newest release. The module requires OpenCode 2. See https://www.npmjs.com/package/@opencode/cli?activeTab=versions" default = "latest" validation { condition = can(regex("^(latest|v?[0-9]+\\.[0-9]+\\.[0-9]+[0-9A-Za-z.+-]*)$", var.opencode_version)) - error_message = "opencode_version must be 'latest' or a release version such as '1.18.33'." + error_message = "opencode_version must be 'latest' or a release version such as '2.0.27'." } } variable "auth_json" { type = string - description = "OpenCode credentials in the format of $HOME/.local/share/opencode/auth.json. Merged into that file (module-provided providers win, other providers are preserved). Exported to the workspace as CODER_OPENCODE_AUTH_JSON so it is never rendered into the install script. See https://opencode.ai/docs/providers/#credentials" + description = "OpenCode credentials in the JSON array format printed by `opencode auth export`. Imported with `opencode auth import`; credentials with the same id are replaced, other saved accounts are preserved. Exported to the workspace as CODER_OPENCODE_AUTH_JSON so it is never rendered into the install script. See https://opencode.ai/v2/docs/cli/providers" sensitive = true default = "" validation { - condition = var.auth_json == "" || can(keys(jsondecode(var.auth_json))) - error_message = "auth_json must be a JSON object keyed by provider ID." + condition = var.auth_json == "" || try(alltrue([ + for c in jsondecode(var.auth_json) : can(c.id) && can(c.integrationID) && can(c.label) && can(c.active) && can(c.value.type) + ]) && length(jsondecode(var.auth_json)) > 0, false) + error_message = "auth_json must be a non-empty JSON array of credentials as printed by `opencode auth export` (each with id, integrationID, label, active, and value)." } } variable "config_json" { type = string - description = "OpenCode config as JSON, deep-merged into the global $HOME/.config/opencode/opencode.json. Keys set here win; other keys on disk are preserved. Configure MCP servers with the mcp variable instead. See https://opencode.ai/docs/config/" + description = "OpenCode config as JSON, deep-merged into the global opencode.json ($OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else ~/.config/opencode). Keys set here win; other keys on disk are preserved. Configure MCP servers with the mcp variable instead. See https://opencode.ai/v2/docs/config/" default = "" validation { @@ -85,23 +87,17 @@ variable "config_json" { variable "mcp" { type = string - description = "MCP servers as a JSON object keyed by server name, in the format of the mcp key of opencode.json. Merged into $HOME/.config/opencode/opencode.json; servers already on disk win on duplicate names. See https://opencode.ai/docs/mcp-servers/" + description = "MCP servers as a JSON object keyed by server name, in the format of mcp.servers in opencode.json. Merged into the global opencode.json under mcp.servers; servers already on disk win on duplicate names. Use disabled (not enabled) to keep a server configured without connecting it. See https://opencode.ai/v2/docs/mcp-servers/" default = "" validation { condition = var.mcp == "" || can(keys(jsondecode(var.mcp))) error_message = "mcp must be a JSON object keyed by MCP server name." } -} - -variable "managed_settings" { - type = any - description = "OpenCode config written as root to /etc/opencode/opencode.json. Managed config has the highest precedence and cannot be overridden by user or project config. See https://opencode.ai/docs/config/#managed-settings" - default = null validation { - condition = var.managed_settings == null || can(keys(var.managed_settings)) - error_message = "managed_settings must be an object." + condition = var.mcp == "" || try(alltrue([for name, server in jsondecode(var.mcp) : !contains(["servers", "timeout"], name) && !can(server.enabled)]), false) + error_message = "mcp must map server names to server objects (not the whole mcp block), and servers must use disabled instead of the V1 enabled field." } } @@ -115,12 +111,11 @@ resource "coder_env" "opencode_auth_json" { locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { - ARG_INSTALL = tostring(var.install_opencode) - ARG_OPENCODE_VERSION = var.opencode_version - ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" - ARG_CONFIG_JSON = var.config_json != "" ? base64encode(var.config_json) : "" - ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" - ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" + ARG_INSTALL = tostring(var.install_opencode) + ARG_OPENCODE_VERSION = var.opencode_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_CONFIG_JSON = var.config_json != "" ? base64encode(var.config_json) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" }) module_dir_name = ".coder-modules/coder-labs/opencode" } diff --git a/registry/coder-labs/modules/opencode/main.tftest.hcl b/registry/coder-labs/modules/opencode/main.tftest.hcl index 810f8879a..58513257a 100644 --- a/registry/coder-labs/modules/opencode/main.tftest.hcl +++ b/registry/coder-labs/modules/opencode/main.tftest.hcl @@ -60,7 +60,7 @@ run "auth_json_creates_env_var" { variables { agent_id = "test-agent" - auth_json = "{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-ant-test-secret\"}}" + auth_json = "[{\"id\": \"coder-anthropic\", \"integrationID\": \"anthropic\", \"label\": \"coder\", \"active\": true, \"value\": {\"type\": \"key\", \"key\": \"sk-ant-test-secret\"}}]" } assert { @@ -94,28 +94,25 @@ run "config_and_mcp_are_encoded" { } } -run "managed_settings_are_encoded" { +run "invalid_auth_json_fails" { command = plan variables { - agent_id = "test-agent" - managed_settings = { - share = "disabled" - } + agent_id = "test-agent" + auth_json = "not-json" } - assert { - condition = strcontains(local.install_script, base64encode(jsonencode({ share = "disabled" }))) - error_message = "install script should receive the base64-encoded managed_settings" - } + expect_failures = [ + var.auth_json, + ] } -run "invalid_auth_json_fails" { +run "v1_auth_json_object_fails" { command = plan variables { agent_id = "test-agent" - auth_json = "not-json" + auth_json = "{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-ant-test-secret\"}}" } expect_failures = [ @@ -123,6 +120,32 @@ run "invalid_auth_json_fails" { ] } +run "mcp_with_v1_enabled_field_fails" { + command = plan + + variables { + agent_id = "test-agent" + mcp = "{\"playwright\": {\"type\": \"local\", \"command\": [\"npx\"], \"enabled\": true}}" + } + + expect_failures = [ + var.mcp, + ] +} + +run "mcp_with_whole_block_fails" { + command = plan + + variables { + agent_id = "test-agent" + mcp = "{\"servers\": {\"playwright\": {\"type\": \"local\", \"command\": [\"npx\"]}}}" + } + + expect_failures = [ + var.mcp, + ] +} + run "invalid_opencode_version_fails" { command = plan @@ -141,11 +164,11 @@ run "pinned_opencode_version_is_accepted" { variables { agent_id = "test-agent" - opencode_version = "v1.18.33" + opencode_version = "v2.0.27" } assert { - condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='v1.18.33'") + condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='v2.0.27'") error_message = "install script should receive the pinned opencode version" } } @@ -189,19 +212,6 @@ run "invalid_mcp_fails" { ] } -run "invalid_managed_settings_fails" { - command = plan - - variables { - agent_id = "test-agent" - managed_settings = "share=disabled" - } - - expect_failures = [ - var.managed_settings, - ] -} - run "scripts_output_is_ordered" { command = plan diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl index f911cf88b..eb41a3733 100644 --- a/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl @@ -13,7 +13,6 @@ ARG_OPENCODE_VERSION='${ARG_OPENCODE_VERSION}' ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) ARG_CONFIG_JSON=$(echo -n '${ARG_CONFIG_JSON}' | base64 -d) ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) -ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) # Delivered through coder_env so credentials never appear in this script. AUTH_JSON="$${CODER_OPENCODE_AUTH_JSON:-}" @@ -21,6 +20,9 @@ AUTH_JSON="$${CODER_OPENCODE_AUTH_JSON:-}" OPENCODE_BIN_DIR="$HOME/.opencode/bin" export PATH="$${OPENCODE_BIN_DIR}:$PATH" +# Same resolution order as `opencode debug paths config`. +OPENCODE_CONFIG_PATH="$${OPENCODE_CONFIG_DIR:-$${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" + echo "--------------------------------" printf "install_opencode: %s\n" "$${ARG_INSTALL}" printf "opencode_version: %s\n" "$${ARG_OPENCODE_VERSION}" @@ -73,6 +75,19 @@ function add_path_to_shell_profiles() { fi } +# Prints the bare version reported by `opencode --version` ("opencode v2.0.27" -> "2.0.27"). +installed_opencode_version() { + local out + out=$(opencode --version 2> /dev/null) || return 1 + out="$${out##* }" + echo "$${out#v}" +} + +# V2 credentials and config shapes are not understood by OpenCode 1. +is_opencode_v1() { + [[ "$1" =~ ^1\. ]] +} + function ensure_opencode_in_path() { local OPENCODE_BIN="" if command_exists opencode; then @@ -91,6 +106,11 @@ function ensure_opencode_in_path() { fi printf "OpenCode version: %s\n" "$${OPENCODE_VERSION}" + if is_opencode_v1 "$(installed_opencode_version)"; then + echo "OpenCode 1 ($${OPENCODE_VERSION}) is not supported; this module requires OpenCode 2." >&2 + return 1 + fi + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/opencode" ]; then ln -s "$${OPENCODE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/opencode" echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/opencode -> $${OPENCODE_BIN}" @@ -108,7 +128,7 @@ install_with_official_installer() ( if ! curl --fail --silent --show-error --location \ --retry 2 --retry-delay 1 --retry-all-errors \ --connect-timeout 10 --max-time 300 \ - --output "$${installer_file}" https://opencode.ai/install; then + --output "$${installer_file}" https://opencode.ai/v2/install; then echo "OpenCode installer could not be downloaded after up to 3 attempts." >&2 return 1 fi @@ -125,10 +145,18 @@ install_with_official_installer() ( if [ "$${version}" = "latest" ]; then version="" fi - if ! VERSION="$${version}" bash "$${installer_file}" --no-modify-path; then - echo "OpenCode installation failed." >&2 - return 1 - fi + # The installer resolves 'latest' through an update API that intermittently + # fails, so retry the whole installer before giving up. + local attempt + for attempt in 1 2 3; do + if VERSION="$${version}" bash "$${installer_file}" --no-modify-path; then + return 0 + fi + echo "OpenCode installer attempt $${attempt} failed." >&2 + sleep $((attempt * 2)) + done + echo "OpenCode installation failed." >&2 + return 1 ) install_opencode() { @@ -144,9 +172,11 @@ install_opencode() { if command_exists opencode; then local installed wanted - installed=$(opencode --version 2> /dev/null || true) + installed=$(installed_opencode_version || true) wanted="$${ARG_OPENCODE_VERSION#v}" - if [ "$${ARG_OPENCODE_VERSION}" = "latest" ] || [ "$${installed}" = "$${wanted}" ]; then + if is_opencode_v1 "$${installed}"; then + echo "OpenCode $${installed} is installed; installing OpenCode 2" + elif [ "$${ARG_OPENCODE_VERSION}" = "latest" ] || [ "$${installed}" = "$${wanted}" ]; then echo "OpenCode already installed ($${installed})" ensure_opencode_in_path return @@ -171,40 +201,12 @@ setup_workdir() { fi } -# Managed config is loaded last and cannot be overridden by user or project config. -write_managed_settings() { - if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then - return - fi - - require_jq "write managed settings" - - if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then - echo "Warning: managed_settings is not a JSON object, skipping managed settings write" >&2 - return - fi - - local target="/etc/opencode/opencode.json" - if [ "$(id -u)" -eq 0 ]; then - mkdir -p "$(dirname "$${target}")" - echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" - chmod 0644 "$${target}" - elif command_exists sudo && sudo -n true 2> /dev/null; then - sudo mkdir -p "$(dirname "$${target}")" - echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null - sudo chmod 0644 "$${target}" - else - echo "Warning: root access is required to write $${target}; skipping managed settings" >&2 - return - fi - echo "Wrote OpenCode managed settings to $${target}" -} - # The module owns only the keys it sets; everything else in opencode.json is # preserved. config_json keys win, while MCP servers already on disk win on -# duplicate names so in-workspace edits are not overwritten. +# duplicate names so in-workspace edits are not overwritten. V1 configs may +# still list servers directly under mcp, so those names count as existing too. setup_config() { - local config_file="$HOME/.config/opencode/opencode.json" + local config_file="$${OPENCODE_CONFIG_PATH}/opencode.json" if [ -z "$${ARG_CONFIG_JSON}" ] && [ -z "$${ARG_MCP_CONFIG}" ]; then echo "No config_json or mcp configured; leaving $${config_file} unchanged." @@ -226,48 +228,70 @@ setup_config() { echo "$${existing}" | jq --argjson config "$${config}" --argjson mcp "$${mcp}" ' (. * $config) - | if ($mcp | length) > 0 then .mcp = ($mcp + (.mcp // {})) else . end + | if ($mcp | length) > 0 then + ((.mcp // {}) | if type == "object" then . else {} end) as $current + | ($current.servers // {}) as $servers + | ($mcp | with_entries(select( + .key as $name | (($servers | has($name)) or ($current | has($name))) | not + ))) as $new + | .mcp = ($current + {servers: ($servers + $new)}) + else . end | .["$schema"] //= "https://opencode.ai/config.json" ' > "$${config_file}.tmp" mv "$${config_file}.tmp" "$${config_file}" echo "OpenCode configuration written: $${config_file}" } -# Provider entries from auth_json replace the same provider IDs on disk; other -# credentials (for example from `opencode providers login`) are preserved. +# OpenCode 2 stores credentials in its SQLite database and ignores auth.json on +# fresh installs, so credentials go through `opencode auth import`. Import skips +# ids that already exist, so the module's own ids are logged out first to apply +# rotated secrets. --standalone avoids starting the shared background service. setup_auth() { - local auth_file="$HOME/.local/share/opencode/auth.json" - if [ -z "$${AUTH_JSON}" ]; then - echo "No auth_json provided; leaving $${auth_file} unchanged." + echo "No auth_json provided; leaving OpenCode credentials unchanged." return fi - require_jq "write OpenCode credentials" + require_jq "import OpenCode credentials" - if ! echo "$${AUTH_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then - echo "ERROR: CODER_OPENCODE_AUTH_JSON is not a JSON object." >&2 + if ! jq -e 'env.CODER_OPENCODE_AUTH_JSON | fromjson | type == "array"' -n > /dev/null 2>&1; then + echo "ERROR: CODER_OPENCODE_AUTH_JSON is not a JSON array of credentials." >&2 exit 1 fi - mkdir -p "$(dirname "$${auth_file}")" - - local existing - existing=$(read_json_object "$${auth_file}") - ( - umask 077 - # Read from the environment so credentials never appear in process arguments. - echo "$${existing}" | jq '. + (env.CODER_OPENCODE_AUTH_JSON | fromjson)' > "$${auth_file}.tmp" - ) - mv "$${auth_file}.tmp" "$${auth_file}" - chmod 0600 "$${auth_file}" - echo "OpenCode credentials written: $${auth_file}" + local existing_ids integration credential_id + existing_ids=$(opencode auth export --standalone 2> /dev/null | jq -r '.[].id' 2> /dev/null || true) + while IFS=$'\t' read -r integration credential_id; do + if [ -n "$${credential_id}" ] && grep -qxF "$${credential_id}" <<< "$${existing_ids}"; then + opencode auth logout --standalone "$${integration}" "$${credential_id}" < /dev/null > /dev/null 2>&1 || true + fi + done < <(jq -rn 'env.CODER_OPENCODE_AUTH_JSON | fromjson | .[] | [.integrationID, .id] | @tsv') + + # Read from the environment so credentials never appear in process arguments. + if ! jq -n 'env.CODER_OPENCODE_AUTH_JSON | fromjson' | opencode auth import --standalone; then + echo "ERROR: OpenCode credential import failed." >&2 + exit 1 + fi + echo "OpenCode credentials imported." +} + +# A running shared service caches configuration until it is reloaded. +reload_running_service() { + local status + status=$(opencode service status 2> /dev/null || true) + if [[ "$${status}" == http* ]]; then + if opencode reload > /dev/null 2>&1; then + echo "Reloaded the running OpenCode service." + else + echo "Warning: could not reload the running OpenCode service; run 'opencode reload'." >&2 + fi + fi } install_opencode setup_workdir -write_managed_settings setup_config setup_auth +reload_running_service echo "OpenCode module setup completed." diff --git a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh index 431c05ac0..fca8cd2c5 100644 --- a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh +++ b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh @@ -1,9 +1,48 @@ #!/usr/bin/env bash -if [[ "$1" == "--version" || "$1" == "-v" ]]; then - echo "0.0.0-mock" - exit 0 -fi +# Minimal OpenCode 2 stand-in. Credentials live in a JSON array file so tests +# can assert on `auth import`/`auth logout` semantics. +store="${HOME}/.opencode-mock-credentials.json" +[[ -f "${store}" ]] || echo '[]' > "${store}" -echo "opencode invoked with: $*" +args=() +for arg in "$@"; do + [[ "${arg}" = "--standalone" ]] || args+=("${arg}") +done +set -- "${args[@]}" + +case "$1 ${2:-}" in + "--version "* | "-v "*) + echo "opencode ${OPENCODE_MOCK_VERSION:-v2.0.0-mock}" + ;; + "auth export") + cat "${store}" + ;; + "auth import") + incoming=$(cat) + echo "auth import invoked" >> /tmp/opencode-mock-calls + jq --argjson new "${incoming}" '. as $old | $old + [$new[] | select(.id as $id | ($old | map(.id) | index($id)) == null)]' "${store}" > "${store}.tmp" + mv "${store}.tmp" "${store}" + echo "Imported credentials" + ;; + "auth logout") + echo "auth logout $3 $4" >> /tmp/opencode-mock-calls + jq --arg id "$4" 'map(select(.id != $id))' "${store}" > "${store}.tmp" + mv "${store}.tmp" "${store}" + ;; + "service status") + if [[ -n "${OPENCODE_MOCK_SERVICE_URL:-}" ]]; then + echo "${OPENCODE_MOCK_SERVICE_URL}" + else + echo "stopped" + fi + ;; + "reload "*) + echo "reload" >> /tmp/opencode-mock-calls + echo "Configuration reloaded" + ;; + *) + echo "opencode invoked with: $*" + ;; +esac exit 0