diff --git a/Containerfile b/Containerfile index c6e4b36..5463149 100644 --- a/Containerfile +++ b/Containerfile @@ -1,9 +1,35 @@ -# OpenCode dev sandbox: rootless-Podman container with JDK 25, Node 24, pnpm, -# git, curl, and a hash-pinned build of OpenCode (github.com/anomalyco/opencode). +# 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 three stages: JDK and Node are pulled from their official upstream -# images and copied (not apt-installed) into a slim Debian final stage, to -# keep the image small while staying on a mainstream glibc distro. +# 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 @@ -17,6 +43,18 @@ FROM eclipse-temurin:25-jdk-noble AS jdk-source # 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 @@ -40,6 +78,39 @@ ARG OPENCODE_SHA256_ARM64=568461b7d4d8c19865c97e9a1102e613049c6039d01fe772154de8 # 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. @@ -60,16 +131,38 @@ RUN apt-get update && \ openssh-client && \ rm -rf /var/lib/apt/lists/* -# --- JDK / Node, copied from the source stages --------------------------------- +# --- 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. JAVA_HOME/bin and PNPM_HOME are non-standard and need one. +# 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 -ENV PATH="${JAVA_HOME}/bin:${PNPM_HOME}/bin:${PATH}" +# 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 @@ -81,6 +174,9 @@ ENV HOME=/home/dev # 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 @@ -88,6 +184,58 @@ RUN mkdir -p /home/dev && chmod 1777 /home/dev 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 @@ -113,7 +261,14 @@ RUN { ldd /usr/local/bin/opencode || true; } | tee /tmp/opencode-ldd.txt && \ exit 1; \ fi && \ rm -f /tmp/opencode-ldd.txt -RUN java -version && node --version && pnpm --version && git --version && curl --version +# 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"] diff --git a/README.md b/README.md index 5679d7c..8eb25ab 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A rootless Podman container for running [OpenCode](https://github.com/anomalyco/ ## What this gives you - A hash-pinned, integrity-verified build of OpenCode (not "latest" — an exact version, checked against a SHA256 captured at build time). -- JDK 25 (LTS), Node 24 (LTS), pnpm, git, curl, openssh-client. +- JDK 25 (LTS), Node 24 (LTS), Go, Rust, pnpm, git, curl, openssh-client. +- Language servers for Go, Rust, TypeScript/JavaScript, Java, and Kotlin, ready for OpenCode's built-in LSP client (see [LSP support](#lsp-support) below). - The current directory mounted read-write as `/workspace`. - OpenCode's config, data, and session state shared across every workdir/container, so logging in once carries over everywhere. - Every container joined to a shared Podman network so it can reach other dev containers by name. @@ -50,6 +51,20 @@ The edit is wrapped in `# >>> opencode-sandbox >>>` markers so re-running `make install` never duplicates it, and is skipped entirely if `~/.local/bin` is already on `PATH`. +## LSP support + +OpenCode's built-in LSP client is off by default — turn it on per-project with `"lsp": true` in that project's `opencode.json` (or configure individual servers there; see OpenCode's own docs for the object form). Once enabled, this image is ready for it: + +| Language | Server | How it gets there | +|---|---|---| +| Go | `gopls` | Built from source at image-build time (`go install`), pinned via `GOPLS_VERSION` in the Containerfile. | +| Rust | `rust-analyzer` | Installed as a `rustup` component at image-build time — OpenCode has no auto-download fallback for this one, so it has to already be on `PATH`. | +| TypeScript / JavaScript | `typescript` + `typescript-language-server` | Installed as global npm packages, pinned via `TYPESCRIPT_VERSION`/`TS_LS_VERSION`. Pinned to TypeScript's last JavaScript-based release (6.0.x) — TypeScript 7 is a native Go rewrite that dropped `tsserver.js`, which `typescript-language-server` needs to attach to. `NODE_PATH` is set so the global `typescript` package resolves even from a project with no local `node_modules`. | +| Java | `jdtls` | Pre-baked — OpenCode has no auto-download fallback for Java at all, so this has to be on `PATH`. Fetched straight from Eclipse's own download server (not GitHub Releases; architecture-independent, pure JVM). 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 — which also manages a persistent, per-project `-data` workspace dir under OpenCode's host-persisted data dir. | +| Kotlin | `kotlin-lsp` | Pre-baked — JetBrains' official server (`github.com/Kotlin/kotlin-lsp`), fetched per-arch and hash-verified by hand (JetBrains publishes no checksum). Ships its own bundled JetBrains Runtime, so it needs none of this image's JDK. Symlinked as both `kotlin-lsp` and `kotlin-language-server`, since it's unconfirmed which name OpenCode's Kotlin integration expects. | + +Bumping a pinned LSP version means editing the corresponding `ARG`/`FROM` tag in the Containerfile and rebuilding (`make build`). + ## How it's put together **Containerfile** — multi-stage build on `debian:trixie-slim`: diff --git a/scripts/jdtls b/scripts/jdtls new file mode 100755 index 0000000..c96dfd1 --- /dev/null +++ b/scripts/jdtls @@ -0,0 +1,39 @@ +#!/bin/sh +# jdtls launcher: the eclipse.jdt.ls project ships no bin/jdtls of its own -- +# every consumer (nvim-jdtls, mason.nvim, coc-java, etc.) hand-writes one, so +# this mirrors that same well-established convention. Installed at +# /usr/local/bin/jdtls by the Containerfile so OpenCode's Java LSP +# integration (which has no auto-download fallback for jdtls, unlike gopls) +# can find a bare `jdtls` command on PATH. +set -eu + +JDTLS_HOME=/opt/jdtls + +# jdtls requires a persistent, project-specific workspace dir (`-data`): the +# same one must be reused across restarts for a given project, and different +# projects must NOT share one (jdtls indexes it as if it were a single +# workspace). Derived from a hash of the real project path and stored under +# OpenCode's own host-persisted data dir (see the Makefile's DATA_DIR bind +# mount) so it survives container restarts instead of vanishing with `--rm`. +workspace_hash=$(printf '%s' "$(pwd -P)" | cksum | cut -d' ' -f1) +data_dir="$HOME/.local/share/opencode/jdtls-workspaces/$workspace_hash" +mkdir -p "$data_dir" + +launcher=$(ls "$JDTLS_HOME"/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -n1) +if [ -z "$launcher" ]; then + echo "jdtls: equinox launcher jar not found under $JDTLS_HOME/plugins" >&2 + exit 1 +fi + +exec java \ + --add-modules=ALL-SYSTEM \ + --add-opens java.base/java.util=ALL-UNNAMED \ + --add-opens java.base/java.lang=ALL-UNNAMED \ + -Declipse.application=org.eclipse.jdt.ls.core.id1 \ + -Dosgi.bundles.defaultStartLevel=4 \ + -Declipse.product=org.eclipse.jdt.ls.core.product \ + -Dlog.protocol=true -Dlog.level=ALL -Xmx1G \ + -jar "$launcher" \ + -configuration "$JDTLS_HOME/config_linux" \ + -data "$data_dir" \ + "$@"