# OpenCode dev sandbox: rootless-Podman container with JDK 25, Node 24, Go, # Rust, pnpm, git, curl, and a hash-pinned build of OpenCode # (github.com/anomalyco/opencode). # # Built as multi-stage: JDK, Node, Go, and Rust toolchains are pulled from # their official upstream images and copied (not apt-installed) into a slim # Debian final stage, to keep the image reasonably small while staying on a # mainstream glibc distro. # # LSP support for OpenCode's built-in LSP client (off by default -- needs # `"lsp": true` in opencode.json to actually turn on): # - Go -> gopls, built from source against the pinned Go # toolchain below. # - Rust -> rust-analyzer, installed as a rustup # component (same trust chain as the rest of the # copied Rust toolchain). OpenCode has no # auto-download fallback for this one, unlike # gopls, so it must already be on PATH. # - TypeScript/JavaScript -> typescript + typescript-language-server, # installed as global npm packages; NODE_PATH # below makes the global `typescript` package # resolvable from any mounted project dir, even # ones with no local node_modules. # - Java -> jdtls, pre-baked (OpenCode has NO auto-download # fallback for Java at all -- it must already be # on PATH). No bin/jdtls ships upstream, so # scripts/jdtls is a hand-written launcher # wrapper, the same convention every other jdtls # consumer (nvim-jdtls, mason.nvim, coc-java) uses. # - Kotlin -> kotlin-lsp (JetBrains' official server), # pre-baked. Ships its own bundled JetBrains # Runtime, so it needs none of this image's JDK. # ---- Stage: JDK source ------------------------------------------------------ # JDK 24 is EOL (Adoptium stopped building it Sept 2025; no 24-jdk* images # exist). JDK 25 is the current LTS. Built on Ubuntu Noble (glibc 2.39), which # is older than Debian Trixie's glibc (2.40) below -- copying a binary linked # against an older glibc into a newer-glibc environment is safe. FROM eclipse-temurin:25-jdk-noble AS jdk-source # ---- Stage: Node source ------------------------------------------------------ # Node 24 is the current Active LTS. Matched to the same Debian release as the # final base so glibc versions are identical -- no compatibility risk at all. FROM node:24-trixie-slim AS node-source # ---- Stage: Go source -------------------------------------------------------- # Full toolchain, not just a standalone LSP binary: gopls shells out to `go` # itself and needs the standard library's source/type info to be useful -- # the same reason the JDK above is copied in whole rather than just a jar. # Matched to Debian Trixie for the same glibc-alignment reason as node-source. FROM golang:1.27.1-trixie AS go-source # ---- Stage: Rust source ------------------------------------------------------- # Full toolchain, for the same reason as Go above -- rust-analyzer needs a # real sysroot (std source + metadata) to do anything beyond bare syntax. FROM rust:1.98.1-trixie AS rust-source # ---- Final stage -------------------------------------------------------------- FROM debian:trixie-slim AS final # Re-declared here: build ARGs don't cross stage boundaries implicitly. # Auto-populated by podman/buildah from the build host's native platform on a # plain `podman build` (no --platform needed) for same-arch builds, which # covers building on either Apple Silicon or Intel Macs. Cross-arch builds # would need an explicit --platform flag (out of scope for this project). ARG TARGETARCH # OpenCode version + per-arch SHA256 pins. Re-verified by hand against the # actual release assets (curl + sha256sum), not just copied from a summary: # curl -fsSL -o oc.tar.gz https://github.com/anomalyco/opencode/releases/download/v/opencode-linux-.tar.gz # sha256sum oc.tar.gz # Re-run this whenever OPENCODE_VERSION is bumped, and update the two hashes. ARG OPENCODE_VERSION=1.18.32 ARG OPENCODE_SHA256_AMD64=3046e0404fdc60fb80307e7a47824ba07477364178a4d09baa8548496dd6d43b ARG OPENCODE_SHA256_ARM64=568461b7d4d8c19865c97e9a1102e613049c6039d01fe772154de873c1865840 # pnpm version, installed via pnpm's own standalone script (not Corepack -- # the pnpm team doesn't recommend Corepack, and Node 25+ dropped bundling it). ARG PNPM_VERSION=12.6.0 # gopls version, built from source at image-build time against the pinned Go # toolchain (FROM golang:... above) via `go install`. Go's module system # verifies this against the public checksum database (sum.golang.org) # automatically, so no separate hash-pinning step is needed here. ARG GOPLS_VERSION=0.23.0 # typescript + typescript-language-server versions, installed as global npm # packages. Pinned to typescript's LAST JavaScript-based release (6.0.x) on # purpose, NOT "latest" (7.x): TypeScript 7 is a native Go rewrite that no # longer ships tsserver.js, which both typescript-language-server and # OpenCode's own LSP client (which resolves "typescript/lib/tsserver.js" # directly) require. Re-evaluate this pin if typescript-language-server ever # ships a tsgo-native mode. ARG TYPESCRIPT_VERSION=6.0.3 ARG TS_LS_VERSION=6.0.1 # jdtls: NOT distributed via GitHub Releases -- real distribution is # Eclipse's own download server. Architecture-independent (pure JVM). Hash # hand-verified against Eclipse's own published .sha256 file at # https://download.eclipse.org/jdtls/milestones//.sha256 -- # re-run that check whenever JDTLS_VERSION/JDTLS_BUILD are bumped. ARG JDTLS_VERSION=1.61.0 ARG JDTLS_BUILD=202609031315 ARG JDTLS_SHA256=338e7e73d61836651ba2453919a0d34fa763eb4e7c03342092309bffb8934c64 # kotlin-lsp: JetBrains' official server (github.com/Kotlin/kotlin-lsp), not # the stale, long-unmaintained fwcd/kotlin-language-server. Per-arch native # binary; JetBrains publishes no checksum, so these are hand-computed from # the downloaded assets themselves, same as OPENCODE_SHA256_* above. ARG KOTLIN_LSP_VERSION=263.4702.0 ARG KOTLIN_LSP_SHA256_AMD64=1e11d2e5fefbf9ea215ad8dd6be95f2222897cd086e8cb7a661a52084a590405 ARG KOTLIN_LSP_SHA256_ARM64=ec7cb254a6662a07fff9f10e4365226afab6c40008f8a974c10ac5e785d6510f # ca-certificates : TLS roots for curl/git/npm/pnpm/JVM. # fontconfig, # libfreetype6 : Temurin's font subsystem needs these even headless. # libstdc++6 : native runtime needed by libjvm.so and native Node addons. # git, curl : required tools. # tar : needed to extract the OpenCode release archive; not # guaranteed present on a trimmed slim base. # openssh-client : without it, `git clone git@...` silently fails. RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates \ fontconfig \ libfreetype6 \ libstdc++6 \ git \ curl \ tar \ openssh-client && \ rm -rf /var/lib/apt/lists/* # --- JDK / Node / Go / Rust, copied from the source stages --------------------- COPY --from=jdk-source /opt/java/openjdk /opt/java/openjdk COPY --from=node-source /usr/local/bin /usr/local/bin COPY --from=node-source /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=go-source /usr/local/go /usr/local/go COPY --from=rust-source /usr/local/cargo /usr/local/cargo COPY --from=rust-source /usr/local/rustup /usr/local/rustup # /usr/local/bin is already on Debian's default PATH, so node/npm/npx need no # extra PATH entry. Everything else here (JDK, pnpm, Go, Rust) lives under a # non-standard prefix and needs one. ENV JAVA_HOME=/opt/java/openjdk ENV PNPM_HOME=/opt/pnpm # GOPATH: writable install/cache dir for `go install` -- both the build-time # gopls install below and anything `go`/gopls itself does at runtime (e.g. # module downloads while analyzing a mounted project). Made world-writable # further down, for the same --userns=keep-id reason /home/dev is. ENV GOPATH=/go ENV GOROOT=/usr/local/go # CARGO_HOME/RUSTUP_HOME: matches the layout already baked into the # rust-source image, so the copied toolchain and rustup metadata (including # the recorded default toolchain) keep working unmodified. ENV CARGO_HOME=/usr/local/cargo ENV RUSTUP_HOME=/usr/local/rustup ENV PATH="${JAVA_HOME}/bin:${PNPM_HOME}/bin:${GOROOT}/bin:${GOPATH}/bin:${CARGO_HOME}/bin:${PATH}" # Makes the global `typescript` package (installed below) resolvable via # Node's module resolution from *any* project directory OpenCode mounts at # /workspace, even ones with no local node_modules of their own -- needed # because OpenCode's TypeScript LSP support resolves # "typescript/lib/tsserver.js" relative to the project directory, and # NODE_PATH is the fallback once that climb reaches the filesystem root. ENV NODE_PATH=/usr/local/lib/node_modules # Fixed HOME: the container is run with --userns=keep-id, so the runtime UID # is whatever host user invoked `podman run`, which has no /etc/passwd entry # inside the image. A plain ENV HOME is unaffected by that UID mapping, so we # pin it here and make it world-writable (sticky bit, like /tmp) so any mapped # UID can use it. ENV HOME=/home/dev # Belt-and-suspenders on top of OpenCode's default (~/.config/opencode): # OPENCODE_CONFIG_DIR is OpenCode's one confirmed config-location override. ENV OPENCODE_CONFIG_DIR=/home/dev/.config/opencode RUN mkdir -p /home/dev && chmod 1777 /home/dev # GOPATH needs to be writable by any --userns=keep-id-mapped UID too, for the # same reason /home/dev is (see above). RUN mkdir -p /go && chmod 1777 /go # pnpm: standalone installer, pinned version. SHELL must be set explicitly -- # the installer can't infer a shell type inside a minimal `RUN` environment # with no interactive shell, and errors out (ERR_PNPM_UNKNOWN_SHELL) otherwise. RUN curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION="${PNPM_VERSION}" SHELL=/bin/bash sh - && \ chmod -R a+rX "${PNPM_HOME}" # rust-analyzer: installed as a rustup component, same trust chain (signed, # checksum-verified by rustup itself) as the rest of the copied Rust # toolchain. Unlike gopls below, OpenCode has no auto-download fallback for # rust-analyzer -- it must already be resolvable on PATH. RUN rustup component add rust-analyzer # gopls: built from source via `go install`, pinned to GOPLS_VERSION (see the # ARG comment above for why this needs no separate hash-pinning step). RUN go install golang.org/x/tools/gopls@v${GOPLS_VERSION} && \ chmod -R a+rwX "${GOPATH}" # typescript + typescript-language-server: global npm install, pinned (see # the ARG comment above for why TYPESCRIPT_VERSION deliberately isn't "latest"). RUN npm install -g \ "typescript@${TYPESCRIPT_VERSION}" \ "typescript-language-server@${TS_LS_VERSION}" # jdtls: architecture-independent (pure JVM) tarball, fetched directly from # Eclipse's own download server (not GitHub Releases). No bin/jdtls launcher # ships in it -- scripts/jdtls (copied in below) is the hand-written wrapper. RUN set -eu; \ curl -fsSL -o /tmp/jdtls.tar.gz \ "https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz"; \ printf '%s /tmp/jdtls.tar.gz\n' "$JDTLS_SHA256" | sha256sum -c -; \ mkdir -p /opt/jdtls; \ tar -xzf /tmp/jdtls.tar.gz -C /opt/jdtls; \ rm -f /tmp/jdtls.tar.gz COPY scripts/jdtls /usr/local/bin/jdtls RUN chmod +x /usr/local/bin/jdtls # kotlin-lsp: arch-aware fetch (same pattern as the OpenCode section below), # SHA256 verified by hand (see the ARG comment above). The tarball extracts # to a versioned top-level dir, flattened here via --strip-components=1. # bin/intellij-server is the real binary -- kotlin-lsp.sh (not copied) is # just a deprecated wrapper that execs it with no extra flags, confirmed by # reading that script directly. Symlinked under both plausible command names # since it's unconfirmed which one OpenCode's Kotlin LSP integration expects. RUN set -eu; \ case "$TARGETARCH" in \ amd64) KLS_ASSET="kotlin-server-${KOTLIN_LSP_VERSION}.tar.gz"; KLS_SHA256="$KOTLIN_LSP_SHA256_AMD64" ;; \ arm64) KLS_ASSET="kotlin-server-${KOTLIN_LSP_VERSION}-aarch64.tar.gz"; KLS_SHA256="$KOTLIN_LSP_SHA256_ARM64" ;; \ *) echo "ERROR: unsupported TARGETARCH='$TARGETARCH'" >&2; exit 1 ;; \ esac; \ curl -fsSL -o /tmp/kotlin-lsp.tar.gz \ "https://download.jetbrains.com/language-server/kotlin-server/${KOTLIN_LSP_VERSION}/${KLS_ASSET}"; \ printf '%s /tmp/kotlin-lsp.tar.gz\n' "$KLS_SHA256" | sha256sum -c -; \ mkdir -p /opt/kotlin-lsp; \ tar -xzf /tmp/kotlin-lsp.tar.gz -C /opt/kotlin-lsp --strip-components=1; \ rm -f /tmp/kotlin-lsp.tar.gz; \ ln -s /opt/kotlin-lsp/bin/intellij-server /usr/local/bin/kotlin-lsp; \ ln -s /opt/kotlin-lsp/bin/intellij-server /usr/local/bin/kotlin-language-server # OpenCode: arch-aware fetch, SHA256 verification, install. # Asset naming uses "x64" for the amd64 arch, not "amd64". # Confirmed layout: both tarballs contain the binary flat at top level as # "opencode" (no subdirectory nesting). RUN set -eu; \ case "$TARGETARCH" in \ amd64) OC_ARCH=x64; OC_SHA256="$OPENCODE_SHA256_AMD64" ;; \ arm64) OC_ARCH=arm64; OC_SHA256="$OPENCODE_SHA256_ARM64" ;; \ *) echo "ERROR: unsupported TARGETARCH='$TARGETARCH'" >&2; exit 1 ;; \ esac; \ curl -fsSL -o /tmp/opencode.tar.gz \ "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-${OC_ARCH}.tar.gz"; \ printf '%s /tmp/opencode.tar.gz\n' "$OC_SHA256" | sha256sum -c -; \ mkdir -p /tmp/opencode-extract; \ tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode-extract; \ install -m 0755 /tmp/opencode-extract/opencode /usr/local/bin/opencode; \ rm -rf /tmp/opencode.tar.gz /tmp/opencode-extract # Build-time smoke tests: fail here, not at first `make opencode`. RUN { ldd /usr/local/bin/opencode || true; } | tee /tmp/opencode-ldd.txt && \ if grep -qi "not found" /tmp/opencode-ldd.txt; then \ echo "ERROR: opencode binary has unresolved shared library dependencies" >&2; \ exit 1; \ fi && \ rm -f /tmp/opencode-ldd.txt # jdtls/kotlin-lsp aren't invoked here (both block waiting on stdio LSP # input with no --version-style flag) -- just checked for presence instead. RUN java -version && node --version && pnpm --version && git --version && curl --version && \ go version && rustc --version && rust-analyzer --version && gopls version && \ tsc --version && typescript-language-server --version && \ test -x /usr/local/bin/jdtls && \ ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar >/dev/null && \ test -x /usr/local/bin/kotlin-lsp && test -x /opt/kotlin-lsp/bin/intellij-server WORKDIR /workspace CMD ["opencode"]