diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000000..6e5aa3e78ab --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,50 @@ +name: Publish Docker Image + +on: + release: + types: [published] + workflow_dispatch: {} + +jobs: + docker: + name: Build and push to Docker Hub + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compute tags + id: meta + run: | + tag="${GITHUB_REF_NAME#v}" + sha=$(echo "$GITHUB_SHA" | cut -c1-7) + if [ -z "$tag" ]; then tag="$sha"; fi + echo "version=$tag" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }} + platforms: linux/amd64,linux/arm64 + tags: | + opencodeai/opencode:server + opencodeai/opencode:server-${{ steps.meta.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..8473b236bee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM oven/bun:latest AS base + +# Core tools required by server features (downloads, unzip, etc.) and gopls support +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl unzip tar git golang-go nodejs npm jq \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -g 1001 opencode && \ + useradd -r -u 1001 -g opencode -m opencode + +# Set working directory for the app layer +WORKDIR /app + +# Copy only the opencode package files for a minimal build +COPY packages/opencode/package.json ./package.json +# Provide workspace catalog mapping for catalog: versions +COPY package.json /tmp/root.package.json +RUN sed -i 's/"@opencode-ai\/sdk": "workspace:\*"/"@opencode-ai\/sdk": "latest"/g' package.json && \ + sed -i 's/"@opencode-ai\/plugin": "workspace:\*"/"@opencode-ai\/plugin": "latest"/g' package.json && \ + node -e 'const fs=require("fs"); const root=JSON.parse(fs.readFileSync("/tmp/root.package.json","utf8")); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); const cat=(root.workspaces&&root.workspaces.catalog)||{}; if(pkg.dependencies){for(const k of Object.keys(pkg.dependencies)) if(pkg.dependencies[k]==="catalog:") pkg.dependencies[k]=cat[k]||pkg.dependencies[k];} if(pkg.devDependencies){for(const k of Object.keys(pkg.devDependencies)) if(pkg.devDependencies[k]==="catalog:") pkg.devDependencies[k]=cat[k]||pkg.devDependencies[k];} fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));' + +# Install dependencies (production preferred, fall back to full) +RUN bun install --production || bun install + +# Copy source code +COPY packages/opencode/src ./src +COPY packages/opencode/tsconfig.json ./ + +# Expose port +EXPOSE 8080 + +# Switch to non-root user +USER opencode + +# Start the server +CMD ["bun", "run", "/app/src/index.ts", "serve", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index b844c497ead..a923bad8f0a 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,58 @@ $ bun install $ bun dev ``` +#### Docker Server Mode + +You can optionally run the opencode server in a Docker container with the current directory mounted for isolation. When started with `--docker`, opencode securely syncs only its own provider credentials (from `auth.json`) into the container; no other local credentials or home directories are mounted. + +```bash +# TUI with server in Docker (mounts $PWD to /workspace) +# Uses Docker Hub image by default: opencodeai/opencode:server +opencode --docker + +# Headless server in Docker +opencode serve --docker --port 8080 --docker-image opencode:latest +``` + +This maps a host port to the container’s server and mounts your current directory at `/workspace`. + +Build from a local Dockerfile (handy for dev): + +```bash +# Build with the repo Dockerfile, then run +opencode --docker --docker-build --dockerfile ./Dockerfile + +# Or headless +opencode serve --docker --docker-build --dockerfile ./Dockerfile --port 8080 +``` + +The default Docker image is `opencodeai/opencode:server`. The provided Dockerfile uses the `oven/bun` base image, adds essential tools (`curl`, `unzip`, `tar`, `git`, `nodejs`, `npm`) and Go (for optional `gopls`), installs the opencode server, and exposes port `8080`. + +If you prefer to build the image manually: + +```bash +docker build -t opencode:latest . +``` + +Or use the helper: + +```bash +# Tags both opencodeai/opencode:server and opencode:local +./script/docker-build [Dockerfile] [context] +``` + +Auto-enable Docker mode via config: + +```jsonc +// ~/.config/opencode/config.json +{ + "server": { + "docker": true, + "image": "opencodeai/opencode:server" + } +} +``` + #### Development Notes **API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients. diff --git a/package.json b/package.json index a38d1b613cd..e8db7f13e9d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "packageManager": "bun@1.2.19", "scripts": { "dev": "bun run --conditions=development packages/opencode/src/index.ts", + "docker:build": "./script/docker-build", "typecheck": "bun run --filter='*' typecheck", "generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)", "postinstall": "./script/hooks" diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 850dbc83d42..13ea3d8052a 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,10 +1,39 @@ +import { Provider } from "../../provider/provider" import { Server } from "../../server/server" +import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" +import { Auth } from "../../auth" +import path from "path" +import { ModelsDev } from "../../provider/models" export const ServeCommand = cmd({ command: "serve", builder: (yargs) => yargs + .option("docker", { + type: "boolean", + describe: "run server in docker with current dir mounted", + }) + .option("docker-image", { + type: "string", + describe: "docker image for server", + default: "opencodeai/opencode:server", + alias: ["dockerImage"], + }) + .option("dockerfile", { + type: "string", + describe: "path to a local Dockerfile to build before running", + }) + .option("docker-context", { + type: "string", + describe: "docker build context directory (defaults to Dockerfile's dir)", + alias: ["dockerContext"], + }) + .option("docker-build", { + type: "boolean", + describe: "force build the docker image before running", + alias: ["dockerBuild"], + }) .option("port", { alias: ["p"], type: "number", @@ -19,14 +48,116 @@ export const ServeCommand = cmd({ }), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, + const cwd = process.cwd() + await bootstrap(cwd, async () => { + const providers = await Provider.list() + if (Object.keys(providers).length === 0) { + return "needs_provider" + } + + const srv = await (async () => { + if (!args.docker) return Server.listen({ port: args.port, hostname: args.hostname }) + const docker = Bun.which("docker") + if (!docker) return Server.listen({ port: args.port, hostname: args.hostname }) + const df = (args as { dockerfile?: string }).dockerfile + const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true + const img = await (async () => { + const defaultImg = "opencodeai/opencode:server" + if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? defaultImg + const f = df ?? "Dockerfile" + const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f)) + const base = (args as { dockerImage?: string }).dockerImage ?? defaultImg + const tag = base === defaultImg ? "opencode:local" : base + const b = Bun.spawn({ cmd: [docker, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) + const code = await b.exited + if (code !== 0) return base + return tag + })() + const alloc = () => { + const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }) + const p = s.port + s.stop() + return p + } + const port = args.port && args.port > 0 ? args.port : alloc() + const host = args.hostname ?? "127.0.0.1" + const cport = 8080 + const vol = process.cwd() + ":/workspace" + const db = await ModelsDev.get() + const envlist: string[] = [] + for (const p of Object.values(db)) { + for (const k of p.env) { + const v = process.env[k] + if (v) envlist.push(`${k}=${v}`) + } + } + const cmd = [ + docker, + "run", + "--rm", + "-d", + "-p", + `${port}:${cport}`, + "-v", + vol, + "-w", + "/workspace", + ...envlist.flatMap((e) => ["-e", e]), + img, + "bun", + "run", + "/app/src/index.ts", + "serve", + "--hostname", + "0.0.0.0", + "--port", + String(cport), + ] + const p = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }) + const code = await p.exited + const id = await new Response(p.stdout).text().then((x) => x.trim()) + if (code !== 0 || !id) return Server.listen({ port: args.port, hostname: args.hostname }) + const url = new URL("http://" + host + ":" + String(port)) + const until = Date.now() + 30_000 + let ready = false + while (Date.now() < until) { + const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) + if (ok) { + ready = true + break + } + await Bun.sleep(250) + } + if (!ready) return Server.listen({ port: args.port, hostname: args.hostname }) + return { + hostname: host, + port, + url, + stop: async () => { + const stop = Bun.spawn({ cmd: [docker, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + }, + } + })() + + if (args.docker) { + const auth = await Auth.all() + await Promise.all( + Object.entries(auth).map(([id, info]) => + fetch(new URL("/auth/" + encodeURIComponent(id), srv.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(info), + }).catch(() => {}), + ), + ) + } + + console.log(`opencode server listening on http://${srv.hostname}:${srv.port}`) + + await new Promise(() => {}) + + srv.stop() }) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - server.stop() }, }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 2011c26cbd5..4f6b01be6e7 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -12,10 +12,12 @@ import { Bus } from "../../bus" import { Log } from "../../util/log" import { FileWatcher } from "../../file/watch" import { Ide } from "../../ide" +import { Auth } from "../../auth" import { Flag } from "../../flag/flag" import { Session } from "../../session" import { Instance } from "../../project/instance" +import { ModelsDev } from "../../provider/models" declare global { const OPENCODE_TUI_PATH: string @@ -36,6 +38,30 @@ export const TuiCommand = cmd({ type: "string", describe: "path to start opencode in", }) + .option("docker", { + type: "boolean", + describe: "run server in docker with current dir mounted", + }) + .option("docker-image", { + type: "string", + describe: "docker image for server", + default: "opencodeai/opencode:server", + alias: ["dockerImage"], + }) + .option("dockerfile", { + type: "string", + describe: "path to a local Dockerfile to build before running", + }) + .option("docker-context", { + type: "string", + describe: "docker build context directory (defaults to Dockerfile's dir)", + alias: ["dockerContext"], + }) + .option("docker-build", { + type: "boolean", + describe: "force build the docker image before running", + alias: ["dockerBuild"], + }) .option("model", { type: "string", alias: ["m"], @@ -105,11 +131,118 @@ export const TuiCommand = cmd({ if (Object.keys(providers).length === 0) { return "needs_provider" } + const cfg = await Config.get() + const useDocker = (args.docker ?? (cfg.server?.docker === true)) === true - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const server = await (async () => { + if (!useDocker) { + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + const dockerBin = Bun.which("docker") + if (!dockerBin) { + UI.error("docker not found, starting server locally") + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + const df = (args as { dockerfile?: string }).dockerfile + const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true + const img = await (async () => { + const defaultImg = "opencodeai/opencode:server" + const configured = cfg.server?.image + if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? configured ?? defaultImg + const f = df ?? "Dockerfile" + const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f)) + const base = (args as { dockerImage?: string }).dockerImage ?? configured ?? defaultImg + const tag = base === defaultImg ? "opencode:local" : base + const b = Bun.spawn({ cmd: [dockerBin, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) + const code = await b.exited + if (code !== 0) { + UI.error("docker build failed, starting server locally") + return base + } + return tag + })() + + const alloc = () => { + const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }) + const p = s.port + s.stop() + return p + } + + const port = args.port && args.port > 0 ? args.port : alloc() + const host = "127.0.0.1" + const cport = 8080 + const vol = process.cwd() + ":/workspace" + const db = await ModelsDev.get() + const envlist: string[] = [] + for (const p of Object.values(db)) { + for (const k of p.env) { + const v = process.env[k] + if (v) envlist.push(`${k}=${v}`) + } + } + + const cmd = [ + dockerBin, + "run", + "--rm", + "-d", + "-p", + `${port}:${cport}`, + "-v", + vol, + "-w", + "/workspace", + ...envlist.flatMap((e) => ["-e", e]), + img, + "bun", + "run", + "/app/src/index.ts", + "serve", + "--hostname", + "0.0.0.0", + "--port", + String(cport), + ] + + const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }) + const code = await proc.exited + const id = await new Response(proc.stdout).text().then((x) => x.trim()) + if (code !== 0 || !id) { + UI.error("failed to start docker server, starting locally") + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + const url = new URL("http://" + host + ":" + String(port)) + const until = Date.now() + 30_000 + let ready = false + while (Date.now() < until) { + const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) + if (ok) { + ready = true + break + } + await Bun.sleep(250) + } + if (!ready) { + UI.error("docker server failed to become ready, starting locally") + const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + return { + hostname: host, + port, + url, + stop: async () => { + const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + }, + } + })() let cmd = ["go", "run", "./main.go"] let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url)) @@ -131,6 +264,19 @@ export const TuiCommand = cmd({ Log.Default.info("tui", { cmd, }) + if (useDocker) { + const auth = await Auth.all() + await Promise.all( + Object.entries(auth).map(([id, info]) => + fetch(new URL("/auth/" + encodeURIComponent(id), server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(info), + }).catch(() => {}), + ), + ) + } + const proc = Bun.spawn({ cmd: [ ...cmd, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 135c0e80c38..8142209e036 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -485,6 +485,13 @@ export namespace Config { .optional(), }) .optional(), + server: z + .object({ + docker: z.boolean().optional().describe("Run the server in Docker by default for the TUI"), + image: z.string().optional().describe("Default Docker image to use for the server"), + }) + .optional() + .describe("Server runtime preferences"), }) .strict() .openapi({ diff --git a/script/docker-build b/script/docker-build new file mode 100755 index 00000000000..3ed2279211f --- /dev/null +++ b/script/docker-build @@ -0,0 +1,26 @@ +#!/bin/sh +set -euo pipefail + +# Usage: script/docker-build [Dockerfile] [context] +# - Builds the opencode server image and tags it twice: +# - opencodeai/opencode:server (default hub tag) +# - opencode:local (local convenience tag) + +DF=${1:-Dockerfile} +CTX=${2:-$(dirname "${DF}")} +IMG1=${IMG1:-opencodeai/opencode:server} +IMG2=${IMG2:-opencode:local} + +DOCKER=${DOCKER:-} +if [ -z "${DOCKER}" ]; then + if command -v docker >/dev/null 2>&1; then + DOCKER=$(command -v docker) + else + echo "docker not found in PATH" >&2 + exit 1 + fi +fi + +echo "Building ${IMG1} and ${IMG2} from ${DF} (context ${CTX})..." +exec "${DOCKER}" build -t "${IMG1}" -t "${IMG2}" -f "${DF}" "${CTX}" +