Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
47 changes: 38 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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

Expand Down Expand Up @@ -117,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}" \
Expand Down Expand Up @@ -144,19 +170,22 @@ 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 \
&& 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 ' "')" \
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand Down Expand Up @@ -123,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.


---
Expand Down
14 changes: 14 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions mise-config.toml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions mise-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Tool installation

Prefer `mise` over `apt` for missing development tools and runtimes.

- `mise use <tool>`: latest, project-local (`mise.toml`).
- `mise use -g <tool>`: latest, global; use only when shared across projects.
- `mise install <tool>`: install a configured tool.
- `mise exec <tool> -- <command>`: one-off use.

Use `@<version>` only when pinning is required. Use `apt` only for system
packages mise cannot manage. Image tools install lazily; do not bulk-install.
106 changes: 106 additions & 0 deletions test-home-tools-e2e.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/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

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
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})"
Loading