From 20002c386732715b3fa77d22438cd97a2e6771fb Mon Sep 17 00:00:00 2001 From: Gabe M Date: Mon, 10 Aug 2026 21:06:35 -0600 Subject: [PATCH 1/5] fix: update mise to use core install paths for glab, vim, and python. should be a bit quicker than going through homebrew --- README.md | 6 +++--- mise-config.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8f7f8bc..ed2841a 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,15 @@ These tools install on first use (via mise → github/zerobrew): |---|---|---| | GitHub CLI | `gh` | github | | jq | `jq` | github | -| GitLab CLI | `glab` | zerobrew | +| GitLab CLI | `glab` | core | | Ruby | `ruby` | zerobrew | | ripgrep | `rg` | github | | fd | `fd` | github | | Wget | `wget` | zerobrew | -| Vim | `vim` | zerobrew | +| Vim | `vim` | core | | Micro | `micro` | github | | Nano | `nano` | zerobrew | -| Python 3 | `python3` | zerobrew | +| Python 3 | `python3` | core | | Node.js | `node` | core | | Sapling | `sl` | github | diff --git a/mise-config.toml b/mise-config.toml index 1772f5c..0fb7588 100644 --- a/mise-config.toml +++ b/mise-config.toml @@ -1,15 +1,15 @@ [tools] "github:cli/cli" = "latest" # shim:gh "github:jqlang/jq" = "latest" -"zerobrew:glab" = "latest" +"glab" = "latest" "zerobrew:ruby" = "latest" "github:BurntSushi/ripgrep" = "latest" # shim:rg "github:sharkdp/fd" = "latest" "zerobrew:wget" = "latest" -"zerobrew:vim" = "latest" +"vim" = "latest" "github:zyedidia/micro" = "latest" "zerobrew:nano" = "latest" -"zerobrew:python" = "latest" +"python" = "latest" "node" = "latest" "npm" = "latest" # shim:npm,npx "github:facebook/sapling" = "latest" # shim:sl From 28a0b5fe4fc35d7f71fc7945d89d755f8ad2c12c Mon Sep 17 00:00:00 2001 From: Gabe M Date: Mon, 10 Aug 2026 21:16:11 -0600 Subject: [PATCH 2/5] fix: pin mise, homebrew, and zerobrew installs More reproducible and up to date installs --- Dockerfile | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 596404e..9496c10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,15 +60,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb # 1. Homebrew — partial clone with --filter=blob:none avoids downloading -# all past file versions, saving ~70 MB while keeping brew update working. +# all past file versions, saving ~70 MB while retaining the git metadata +# Homebrew expects. The release tag is pinned for reproducible builds. +ARG HOMEBREW_VERSION=6.0.16 RUN mkdir -p /home/linuxbrew \ && chown opencode:opencode /home/linuxbrew \ && sudo -u opencode git clone --filter=blob:none \ + --branch "${HOMEBREW_VERSION}" \ https://github.com/Homebrew/brew /home/linuxbrew/.linuxbrew/Homebrew \ + && sudo -u opencode git -C /home/linuxbrew/.linuxbrew/Homebrew \ + checkout --quiet -B stable "${HOMEBREW_VERSION}" \ && sudo -u opencode mkdir -p /home/linuxbrew/.linuxbrew/bin \ && sudo -u opencode ln -sf \ /home/linuxbrew/.linuxbrew/Homebrew/bin/brew /home/linuxbrew/.linuxbrew/bin/brew \ - && sudo -u opencode /home/linuxbrew/.linuxbrew/bin/brew update --force \ && sudo -u opencode /home/linuxbrew/.linuxbrew/bin/brew cleanup --prune=all \ && sudo -u opencode rm -rf "$(sudo -u opencode /home/linuxbrew/.linuxbrew/bin/brew --cache)" \ && rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/test \ @@ -79,17 +83,38 @@ RUN mkdir -p /home/linuxbrew \ && rm -rf /home/linuxbrew/.linuxbrew/share/zsh \ && rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/homebrew/homebrew-core +ARG MISE_VERSION=2026.8.3 +ARG MISE_ZEROBREW_PLUGIN_COMMIT=998174989a12910f82c1f6e791a432731ed2647d + # 1.5. mise — dev tool manager; pre-approved tools defined in the global config -# auto-install via zerobrew backend on first use at runtime. -RUN curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ +# auto-install via zerobrew backend on first use at runtime. The release is +# pinned so Docker caching cannot silently retain an old moving target. +RUN curl -fsSL https://mise.run | MISE_VERSION="${MISE_VERSION}" MISE_INSTALL_PATH=/usr/local/bin/mise sh \ && mkdir -p /opt/mise \ - && MISE_DATA_DIR=/opt/mise mise plugins install zerobrew https://github.com/kennyg/mise-zerobrew \ + && MISE_DATA_DIR=/opt/mise mise plugins install zerobrew "https://github.com/kennyg/mise-zerobrew#${MISE_ZEROBREW_PLUGIN_COMMIT}" \ && sed -i '/quoted_path .. " install /i\ cmd.exec(quoted_zb .. " --root " .. quoted_path .. " init")' /opt/mise/plugins/zerobrew/hooks/backend_install.lua # 1.6. zerobrew — fast Homebrew alternative; used as mise backend -RUN sudo -u opencode HOME=/home/opencode NONINTERACTIVE=1 /bin/bash -c " \ - curl -fsSL https://zerobrew.rs/install | bash -s -- --no-modify-path \ -" +ARG ZEROBREW_VERSION=0.3.2 +RUN set -eux; \ + case "$(uname -m)" in \ + x86_64) ZEROBREW_ARCH=x64 ;; \ + aarch64) ZEROBREW_ARCH=arm64 ;; \ + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac; \ + base_url="https://github.com/lucasgelfond/zerobrew/releases/download/v${ZEROBREW_VERSION}"; \ + download_dir="$(mktemp -d)"; \ + trap 'rm -rf "$download_dir"' 0; \ + curl -fsSL "${base_url}/SHA256SUMS" -o "${download_dir}/SHA256SUMS"; \ + curl -fsSL "${base_url}/zb-linux-${ZEROBREW_ARCH}" -o "${download_dir}/zb-linux-${ZEROBREW_ARCH}"; \ + curl -fsSL "${base_url}/zbx-linux-${ZEROBREW_ARCH}" -o "${download_dir}/zbx-linux-${ZEROBREW_ARCH}"; \ + (cd "$download_dir" && sha256sum -c --ignore-missing SHA256SUMS); \ + install -Dm755 "${download_dir}/zb-linux-${ZEROBREW_ARCH}" /home/opencode/.local/bin/zb; \ + install -Dm755 "${download_dir}/zbx-linux-${ZEROBREW_ARCH}" /home/opencode/.local/bin/zbx; \ + mkdir -p /home/opencode/.local/share/zerobrew; \ + chown -R opencode:opencode /home/opencode/.local; \ + sudo -u opencode HOME=/home/opencode XDG_DATA_HOME=/home/opencode/.local/share \ + /home/opencode/.local/bin/zb init --no-modify-path ARG OPENCODE_VERSION @@ -156,7 +181,7 @@ RUN opencode --version \ && printf '\neval "$(mise activate zsh)"\n' >> /home/opencode/.zshrc \ && mkdir -p /home/opencode/.config/fish \ && printf '\nmise activate fish | source\n' >> /home/opencode/.config/fish/config.fish \ - && printf '\neval "$(mise activate sh)"\n' >> /home/opencode/.profile \ + && printf '\nif [ -n "${BASH_VERSION:-}" ]; then eval "$(mise activate bash)"; fi\n' >> /home/opencode/.profile \ && mkdir -p /opt/auto-install-shims \ && grep -E '^\s*"' /etc/mise/config.toml | while IFS='=' read -r key value; do \ key="$(echo "$key" | tr -d ' "')" \ From 6c8e9e7c7aa03e7ad1a793503d0caf73b3120737 Mon Sep 17 00:00:00 2001 From: Gabe M Date: Mon, 10 Aug 2026 21:41:46 -0600 Subject: [PATCH 3/5] feat: auto install home tools during boot, at ~/.config/mise/config.toml, with mise if opted in --- .github/workflows/pr-test.yml | 3 ++ Dockerfile | 1 + README.md | 1 + entrypoint.sh | 14 +++++ test-home-tools-e2e.sh | 97 +++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 test-home-tools-e2e.sh diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 7669719..695ef27 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -59,6 +59,9 @@ jobs: IN="docker run --rm -e GITHUB_TOKEN --entrypoint bash opencode-test:latest" + test "home-tool installer works in the real image" \ + "bash test-home-tools-e2e.sh opencode-test:latest" + echo "=== Pre-installed tools (interactive) ===" test "node works in interactive shell" \ diff --git a/Dockerfile b/Dockerfile index 9496c10..7c5c8c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -142,6 +142,7 @@ ENV HOMEBREW_NO_AUTO_UPDATE=1 ENV HOMEBREW_INSTALL_FROM_API=1 ENV MISE_DATA_DIR=/opt/mise ENV MISE_ALWAYS_INSTALL=1 +ENV OPENCODE_INSTALL_HOME_TOOLS=false LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/sprisa/opencode-server/refs/heads/main/README.md" \ org.opencontainers.image.created="${IMAGE_CREATED}" \ diff --git a/README.md b/README.md index ed2841a..c64ceb0 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ The server starts on port 4096. Mount your project at `/home/opencode` to persis | `OPENCODE_CORS_ORIGIN` | *(none)* | Optional CORS origin; omit to disable CORS | | `OPENCODE_PRINT_LOGS` | `false` | Set to `true` to print OpenCode logs to container stderr | | `OPENCODE_LOG_LEVEL` | *(OpenCode default)* | Optional `DEBUG`, `INFO`, `WARN`, or `ERROR` log level | +| `OPENCODE_INSTALL_HOME_TOOLS` | `false` | After a 3-second delay, install tools from `~/.config/mise/config.toml` in the background | ### Examples diff --git a/entrypoint.sh b/entrypoint.sh index 1e7651a..4cfa06c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,6 +4,20 @@ set -euo pipefail mkdir -p "${HOME}/.config/opencode" "${HOME}/workspace" cd "${HOME}/workspace" +# This is opt-in because customer-configured tools can be large and compete with +# the server for resources after startup. Keep system and project tools lazy: the +# home ceiling plus a null system config scopes this install to the global user +# config at ~/.config/mise/config.toml. +if [ "${OPENCODE_INSTALL_HOME_TOOLS:-false}" = "true" ] && [ -f "${HOME}/.config/mise/config.toml" ]; then + ( + sleep 3 + if ! MISE_SYSTEM_CONFIG_FILE=/dev/null MISE_CEILING_PATHS="${HOME}" \ + mise -C "${HOME}" install --yes; then + printf '%s\n' 'opencode: home tool installation failed' >&2 + fi + ) & +fi + args=(serve --hostname 0.0.0.0 --port "${OPENCODE_PORT:-4096}") if [ "${OPENCODE_PRINT_LOGS:-false}" = "true" ]; then args+=(--print-logs) diff --git a/test-home-tools-e2e.sh b/test-home-tools-e2e.sh new file mode 100644 index 0000000..ceb3822 --- /dev/null +++ b/test-home-tools-e2e.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# End-to-end test for the delayed home-tool installer. +# Usage: test-home-tools-e2e.sh [image] +set -euo pipefail + +IMAGE="${1:-opencode-test:latest}" +NAME="opencode-home-tools-e2e-$$" +FIXTURE="$(mktemp -d)" + +cleanup() { + docker rm -f "${NAME}" >/dev/null 2>&1 || true + rm -rf "${FIXTURE}" +} +trap cleanup EXIT + +mkdir -p "${FIXTURE}/mise" "${FIXTURE}/project" +printf '%s\n' '[tools]' 'claude = "latest"' > "${FIXTURE}/mise/config.toml" +printf '%s\n' '[tools]' 'node = "latest"' > "${FIXTURE}/project/mise.toml" + +# The default must preserve lazy behavior even when a home config exists. +docker run -d --name "${NAME}" \ + --mount "type=bind,src=${FIXTURE}/mise,dst=/home/opencode/.config/mise" \ + --mount "type=bind,src=${FIXTURE}/project/mise.toml,dst=/home/opencode/workspace/mise.toml,readonly" \ + "${IMAGE}" >/dev/null + +if [ "$(docker inspect -f '{{.State.Running}}' "${NAME}")" != "true" ]; then + docker logs "${NAME}" >&2 || true + exit 1 +fi + +sleep 4 +if docker exec "${NAME}" bash -c 'for path in /opt/mise/installs/claude/*/claude; do [ -x "$path" ] && exit 0; done; exit 1'; then + echo "home tool installed while the feature was disabled" >&2 + exit 1 +fi +docker rm -f "${NAME}" >/dev/null + +# The opt-in path uses the same real image and home/project config. +docker run -d --name "${NAME}" \ + -e OPENCODE_INSTALL_HOME_TOOLS=true \ + -e OPENCODE_PRINT_LOGS=true \ + --mount "type=bind,src=${FIXTURE}/mise,dst=/home/opencode/.config/mise" \ + --mount "type=bind,src=${FIXTURE}/project/mise.toml,dst=/home/opencode/workspace/mise.toml,readonly" \ + "${IMAGE}" >/dev/null + +if [ "$(docker inspect -f '{{.State.Running}}' "${NAME}")" != "true" ]; then + docker logs "${NAME}" >&2 || true + exit 1 +fi + +# The server process must be up before the three-second installer delay expires. +sleep 1 +if ! docker exec "${NAME}" bash -c 'for path in /opt/mise/installs/claude/*/claude; do [ -x "$path" ] && exit 0; done; exit 1'; then + : # Not installed yet is the expected state. +else + echo "home tool installed before the startup delay" >&2 + exit 1 +fi + +health="000" +for _ in $(seq 1 30); do + health="$(docker exec "${NAME}" curl -s -o /dev/null -m 1 -w '%{http_code}' http://127.0.0.1:4096/global/health || true)" + if [ "${health}" != "000" ]; then + break + fi + sleep 1 +done +case "${health}" in + 200|401) ;; + *) + echo "opencode health check returned ${health}" >&2 + docker logs "${NAME}" >&2 || true + exit 1 + ;; +esac + +installed=0 +for _ in $(seq 1 90); do + if docker exec "${NAME}" bash -c 'for path in /opt/mise/installs/claude/*/claude; do [ -x "$path" ] && exit 0; done; exit 1'; then + installed=1 + break + fi + sleep 1 +done +if [ "${installed}" -ne 1 ]; then + echo "Claude was not installed by the background home-tool job" >&2 + docker logs "${NAME}" >&2 || true + exit 1 +fi + +# The system config and project config must remain lazy. +if docker exec "${NAME}" bash -c 'for path in /opt/mise/installs/github-jqlang-jq/*/jq /opt/mise/installs/node/*/bin/node; do [ -x "$path" ] && exit 0; done; exit 1'; then + echo "system/project tool was eagerly installed" >&2 + exit 1 +fi + +echo "PASS: Docker home-tool installation e2e (${IMAGE})" From 588ece6f51ca4a02fc159e41b31b0a46041de6b3 Mon Sep 17 00:00:00 2001 From: Gabe M Date: Mon, 10 Aug 2026 22:04:49 -0600 Subject: [PATCH 4/5] feat: mise instructions for agents prefer mise for quick installs vs apt --- Dockerfile | 3 +++ README.md | 1 + mise-instructions.md | 11 +++++++++++ test-home-tools-e2e.sh | 9 +++++++++ 4 files changed, 24 insertions(+) create mode 100644 mise-instructions.md diff --git a/Dockerfile b/Dockerfile index 7c5c8c2..34af4bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -170,12 +170,15 @@ COPY --from=builder --chown=opencode:opencode /home/opencode/.local/share/zerobr COPY --from=builder /usr/local/bin/mise /usr/local/bin/mise COPY --from=builder --chown=opencode:opencode /opt/mise /opt/mise COPY mise-config.toml /etc/mise/config.toml +COPY mise-instructions.md /etc/opencode/mise-instructions.md # Opencode COPY --from=builder /opt/opencode /usr/local/bin/opencode # Verify runtime and set up login-shell PATH and auto-install handler RUN opencode --version \ + && mkdir -p /etc/opencode \ + && printf '%s\n' '{"$schema":"https://opencode.ai/config.json","instructions":["/etc/opencode/mise-instructions.md"]}' > /etc/opencode/opencode.json \ && printf 'for d in "$HOME/.local/bin" "/home/linuxbrew/.linuxbrew/bin" "/home/linuxbrew/.linuxbrew/sbin" "$HOME/.local/share/zerobrew/prefix/bin"; do case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH";; esac; done\nexport PATH\n' > /etc/profile.d/brew-path.sh \ && chmod 0644 /etc/profile.d/brew-path.sh \ && printf '\neval "$(mise activate bash)"\n' >> /home/opencode/.bashrc \ diff --git a/README.md b/README.md index c64ceb0..8233f70 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Fetches the latest release from [anomalyco/opencode](https://github.com/anomalyc - Homebrew is installed under `/home/linuxbrew/.linuxbrew` (outside the persistent volume). It uses its bundled portable Ruby — no system Ruby needed. - Zerobrew (`zb`) is installed at `/usr/local/bin/zb` with its prefix at `~/.local/share/zerobrew/prefix` on PATH for accessing formula binaries. - **Lazy-installed tools** (see table above): run any listed tool and mise auto-installs it via zerobrew or github backend on first use. The image ships defaults in `/etc/mise/config.toml`; create `~/.config/mise/config.toml` to add your own — mise merges both. +- OpenCode includes a managed instruction at `/etc/opencode/mise-instructions.md` reminding agents to prefer mise over apt for development tools. --- diff --git a/mise-instructions.md b/mise-instructions.md new file mode 100644 index 0000000..1912375 --- /dev/null +++ b/mise-instructions.md @@ -0,0 +1,11 @@ +## Tool installation + +Prefer `mise` over `apt` for missing development tools and runtimes. + +- `mise use `: latest, project-local (`mise.toml`). +- `mise use -g `: latest, global; use only when shared across projects. +- `mise install `: install a configured tool. +- `mise exec -- `: one-off use. + +Use `@` only when pinning is required. Use `apt` only for system +packages mise cannot manage. Image tools install lazily; do not bulk-install. diff --git a/test-home-tools-e2e.sh b/test-home-tools-e2e.sh index ceb3822..389114c 100644 --- a/test-home-tools-e2e.sh +++ b/test-home-tools-e2e.sh @@ -28,6 +28,15 @@ if [ "$(docker inspect -f '{{.State.Running}}' "${NAME}")" != "true" ]; then exit 1 fi +config="$(docker exec "${NAME}" opencode debug config --pure)" +case "${config}" in + *"/etc/opencode/mise-instructions.md"*) ;; + *) + echo "mise instruction was not loaded into OpenCode config" >&2 + exit 1 + ;; +esac + sleep 4 if docker exec "${NAME}" bash -c 'for path in /opt/mise/installs/claude/*/claude; do [ -x "$path" ] && exit 0; done; exit 1'; then echo "home tool installed while the feature was disabled" >&2 From cd81b682738dea7f0e90c54c85b41da8d865d1cf Mon Sep 17 00:00:00 2001 From: Gabe M Date: Mon, 10 Aug 2026 22:13:36 -0600 Subject: [PATCH 5/5] build: fix changed CI detection --- .github/workflows/pr-build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index efa89ef..f747304 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -52,7 +52,6 @@ jobs: CHANGED=$(git -C head diff "origin/${{ github.base_ref }}" HEAD --name-only -- Dockerfile version.txt entrypoint.sh) if [ -n "$CHANGED" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" - echo "files=$CHANGED" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi