From 1164f8c8bf3336589259760686bfc5cae745006f Mon Sep 17 00:00:00 2001 From: Serge Gatezh <2880401+gatezh@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:52:45 -0600 Subject: [PATCH] fix(devcontainers): restore XDG dirs so mise works under nested volume mounts 24afdcf replaced mkdir -p /home/node/.local/share/fish with /home/node/.local, dropping the .local/share level that mkdir -p had created as a side effect. Consuming projects still mount a *-fish-data volume at ~/.local/share/fish, and Docker invents a missing mount parent as root:root -- so .local/share became root-owned and mise could no longer create ~/.local/share/mise. Every consuming devcontainer fails 'mise install' on its next rebuild. Creates .local/share, .local/state, .config and .cache node-owned (the four dirs mise writes to) in both affected images. Reproduced against the published image and verified with a volume mounted at the nested path -- a plain docker run does not surface it. CI now asserts .local/share is node-owned, since the failure is silent until a consumer rebuilds. Also tightens comments across the Dockerfiles and renovate.json5: claude-code 45%->38% comment lines, renovate.json5 42%->30%. Package-manifest lists are left alone -- one short line per package is the useful form. --- .devcontainer/Dockerfile | 41 ++++----- .github/renovate.json5 | 48 ++++------- .github/workflows/build-claude-code.yml | 8 +- .github/workflows/ci.yml | 4 +- claude-bun/.devcontainer/Dockerfile | 31 ++----- claude-code/.devcontainer/Dockerfile | 105 +++++++++--------------- 6 files changed, 85 insertions(+), 152 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 69ab28f..eed4c89 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -19,11 +19,8 @@ # ── rtk (token-optimized CLI proxy) ────────────────────────────────────── # ── oh-my-zsh + powerlevel10k ───────────────────────────────── -# Pinned to commit SHAs and fetched as tarballs. Neither project tags usefully -- -# oh-my-zsh has no tags at all, and powerlevel10k's last release predates its -# current master by years -- so a SHA is the only real pin. Bump deliberately: -# auto-adopting upstream shell-framework changes is what repeatedly broke this -# image, and a prompt theme does not need a 3-day release cadence. +# Pinned by commit SHA: neither project tags usefully, and auto-adopting +# shell-framework changes has broken this image before. Bump deliberately. FROM alpine:3.21 AS ohmyzsh-download RUN apk add --no-cache curl tar ARG OH_MY_ZSH_REF=0ee67f042872d1dfab74270c31867771ca35aef4 @@ -116,16 +113,18 @@ RUN mkdir -p /usr/local/share/npm-global/lib \ ENV DEVCONTAINER=true -# Create workspace and config directories with proper ownership -RUN mkdir -p /workspace /home/node/.claude /home/node/.local /commandhistory \ - && chown -R node:node /workspace /home/node/.claude /home/node/.local /commandhistory +# Workspace and XDG dirs, node-owned: a volume mounted at a nested path under one +# of these makes Docker invent the missing parent as root, locking node out. +RUN mkdir -p /workspace /home/node/.claude \ + /home/node/.local/share /home/node/.local/state /home/node/.config /home/node/.cache \ + /commandhistory \ + && chown -R node:node /workspace /home/node/.claude /home/node/.local \ + /home/node/.config /home/node/.cache /commandhistory WORKDIR /workspace -# gh (GitHub CLI) — installed from the upstream .deb, not apt: Debian trixie -# freezes gh at 2.46.0 (Apr 2024). Version kept current by Renovate. -# Must stay AFTER the apt block: the .deb depends on git, and dpkg -i does not -# resolve dependencies — it fails loudly ("dependency problems") if git is absent. +# gh from the upstream .deb: trixie freezes it at 2.46.0 (Apr 2024). Must follow +# the apt block — the .deb depends on git and dpkg -i won't resolve it. RUN ARCH=$(dpkg --print-architecture) \ && curl -fsSL -o "gh_${GH_VERSION}_linux_${ARCH}.deb" \ "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${ARCH}.deb" \ @@ -138,11 +137,8 @@ USER node ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin ENV SHELL=/usr/bin/zsh -# Only LANG, and only C.UTF-8 — what the official images do (ruby sets exactly -# this; node/debian/python set nothing). LC_ALL would override every LC_* category -# and silently defeat a consuming project's containerEnv. TERM is deliberately -# unset: no official image sets it, an image ENV beats the tty value `docker exec -t` -# supplies, and tput/clear failing without a terminal is correct behaviour. +# LANG only, as the official images do. LC_ALL would outrank a consumer's +# containerEnv; TERM stays unset so the terminal's own value wins. ENV LANG=C.UTF-8 ENV EDITOR="code --wait" ENV VISUAL="code --wait" @@ -150,16 +146,11 @@ ENV VISUAL="code --wait" # ── zsh + oh-my-zsh + powerlevel10k ────────────────────────────────────────── # Written as node: $HOME during RUN follows USER, so this must not run as root. USER node -# oh-my-zsh tree is COPYed from the parallel download stage above, so the final -# image needs no git or curl for it and nothing is fetched at build time here. COPY --from=ohmyzsh-download --chown=node:node /omz /home/node/.oh-my-zsh -# .zshrc is written here rather than by an installer, so nothing upstream can -# inject env overrides (locale/TERM) that then have to be patched back out. -# The wizard flag matters: without a POWERLEVEL9K_* config powerlevel10k starts -# its interactive configurator on first shell, which blocks a container. -# $HOME and $ZSH are single-quoted on purpose: they must reach .zshrc -# unexpanded so zsh resolves them at runtime, not at build time. +# .zshrc written here, not by an installer, so nothing upstream injects env +# overrides. The wizard flag stops powerlevel10k prompting on first shell. +# Single-quoted so $HOME/$ZSH reach .zshrc unexpanded. # hadolint ignore=SC2016 RUN printf '%s\n' \ 'export ZSH="$HOME/.oh-my-zsh"' \ diff --git a/.github/renovate.json5 b/.github/renovate.json5 index e25b934..6d686ee 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,10 +6,8 @@ // github-actions managers open PRs for base images or action pins (out of scope). enabledManagers: ['custom.regex'], - // Pin + auto-update the four dev tools these images used to pull from - // "latest" at build time. Replaces the old daily rebuild cron: a Renovate - // bump PR (auto-merged on green CI) triggers the existing push-based image - // build. No upstream release -> no PR -> no rebuild. + // Pins tools these images used to pull at build time. Replaces the daily cron: + // a bump PR triggers the push-based build. No release -> no PR -> no rebuild. customManagers: [ { customType: 'regex', @@ -22,25 +20,17 @@ packageRules: [ { - // Most GitHub tags carry a leading "v" ("v0.48.0", "v29.8.1"); strip it so the - // datasource version matches the bare ARG value ("0.48.0"). A tag with no "v" - // passes through unchanged, so this is safe to apply datasource-wide. + // Strip the leading "v" so tags match the bare ARG value. Tags without one + // pass through unchanged, so this is safe datasource-wide. matchDatasources: ['github-releases', 'github-tags'], extractVersion: '^v?(?.+)$', }, { - // Group the tracked tools into one PR and auto-merge once CI passes. - // - // Relies on Renovate's default platformAutomerge:true — GitHub's native - // auto-merge merges on green with no second Renovate run. The previous - // explicit platformAutomerge:false is what broke this: only a Renovate - // run could merge, and every run found a newer claude-code, force-pushed - // the branch and ended before CI finished (#121 sat green for 3.5 weeks). - // - // Requires: repo setting "Allow auto-merge", and a ruleset on master - // requiring the "CI complete" check (.github/workflows/ci.yml). Without - // that required check nothing blocks the PR, GitHub never offers native - // auto-merge, and Renovate silently falls back to the broken path. + // One grouped PR, auto-merged on green via Renovate's default + // platformAutomerge. Setting it false broke this before: only a Renovate run + // could merge, and each run force-pushed a newer claude-code before CI + // finished (#121 sat green 3.5 weeks). Requires "Allow auto-merge" plus a + // master ruleset requiring the "CI complete" check, or it silently regresses. matchPackageNames: [ 'rtk-ai/rtk', 'umputun/ralphex', @@ -57,10 +47,8 @@ groupName: 'devcontainer tools', automerge: true, - // These bumps merge unreviewed and publish straight to ghcr.io, so let a - // release soak before adopting it. internalChecksFilter defaults to - // 'strict', so a too-young version is simply not offered yet — the group - // PR carries whichever tools are currently eligible. + // These merge unreviewed and publish straight to ghcr.io, so let releases + // soak. Too-young versions are simply not offered; the PR carries the rest. minimumReleaseAge: '3 days', }, { @@ -70,21 +58,17 @@ minimumReleaseAge: null, }, { - // ralphex-fe bakes its toolchain into the image — it has no mise, and - // its published tag is literally bun-hugo. So these are - // image-level versions, not per-project ones, and there is no other - // place to set them. Bun uses the npm datasource because oven-sh/bun - // tags releases as "bun-v1.4.2", which the github-releases extractVersion - // above does not strip. + // ralphex-fe has no mise and its tag is literally bun-hugo, so + // these are image-level versions. Bun uses npm: its tags are "bun-v1.4.2", + // which the extractVersion above does not strip. matchPackageNames: ['bun', 'gohugoio/hugo'], groupName: 'ralphex-fe toolchain', automerge: true, minimumReleaseAge: '3 days', }, { - // hugo-bun-node installs Node from the unofficial musl builds — its base - // image (oven/bun:*-alpine) ships no node at all. The image is Node 24 LTS - // by design, so major bumps stay a deliberate call, not a Renovate PR. + // hugo-bun-node's alpine base ships no node, so it installs the musl build. + // Node 24 LTS by design — majors stay a deliberate call. matchDatasources: ['node-version'], allowedVersions: '^24', }, diff --git a/.github/workflows/build-claude-code.yml b/.github/workflows/build-claude-code.yml index fc78ddf..1d4c6b1 100644 --- a/.github/workflows/build-claude-code.yml +++ b/.github/workflows/build-claude-code.yml @@ -75,19 +75,19 @@ jobs: matrix: include: - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" + verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" runner: ubuntu-24.04 arch: amd64 - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" + verify-command: "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" runner: ubuntu-24.04-arm arch: arm64 - image-suffix: claude-code-sandbox - verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" + verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" runner: ubuntu-24.04 arch: amd64 - image-suffix: claude-code-sandbox - verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" + verify-command: "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" runner: ubuntu-24.04-arm arch: arm64 runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d7ffc0..61a8355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,12 +117,12 @@ jobs: add_image "claude-code" \ "claude-code/.devcontainer" \ "claude-code/.devcontainer/Dockerfile" \ - "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" \ + "bun --version || true && claude --version && mise --version && zsh --version && gh --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && printenv AGENT_BROWSER_EXECUTABLE_PATH | grep -qx /usr/bin/chromium && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" \ "default" add_image "claude-code-sandbox" \ "claude-code/.devcontainer" \ "claude-code/.devcontainer/Dockerfile" \ - "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k" \ + "claude --version && mise --version && zsh --version && gh --version && which iptables && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test -r /etc/claude-code/managed-settings.json && jq -r '.hooks.SessionStart[0].hooks[0].command' /etc/claude-code/managed-settings.json | grep -qx /usr/local/bin/patch-playwright-mcp && zsh -ic 'typeset -p ZSH_THEME' | grep -q powerlevel10k/powerlevel10k && stat -c %U /home/node/.local/share | grep -qx node" \ "sandbox" fi diff --git a/claude-bun/.devcontainer/Dockerfile b/claude-bun/.devcontainer/Dockerfile index 0e660da..c17de72 100644 --- a/claude-bun/.devcontainer/Dockerfile +++ b/claude-bun/.devcontainer/Dockerfile @@ -1,11 +1,8 @@ ARG BUN_VERSION=1.3.5 # ── oh-my-zsh + powerlevel10k ───────────────────────────────── -# Pinned to commit SHAs and fetched as tarballs. Neither project tags usefully -- -# oh-my-zsh has no tags at all, and powerlevel10k's last release predates its -# current master by years -- so a SHA is the only real pin. Bump deliberately: -# auto-adopting upstream shell-framework changes is what repeatedly broke this -# image, and a prompt theme does not need a 3-day release cadence. +# Pinned by commit SHA: neither project tags usefully, and auto-adopting +# shell-framework changes has broken this image before. Bump deliberately. FROM alpine:3.21 AS ohmyzsh-download RUN apk add --no-cache curl tar ARG OH_MY_ZSH_REF=0ee67f042872d1dfab74270c31867771ca35aef4 @@ -66,10 +63,8 @@ RUN mkdir -p /workspace /home/bun/.claude /home/bun/.bun && \ WORKDIR /workspace -# gh (GitHub CLI) — installed from the upstream .deb, not apt: Debian trixie -# freezes gh at 2.46.0 (Apr 2024). Version kept current by Renovate. -# Must stay AFTER the apt block: the .deb depends on git, and dpkg -i does not -# resolve dependencies — it fails loudly ("dependency problems") if git is absent. +# gh from the upstream .deb: trixie freezes it at 2.46.0 (Apr 2024). Must follow +# the apt block — the .deb depends on git and dpkg -i won't resolve it. RUN ARCH=$(dpkg --print-architecture) && \ curl -fsSL -o "gh_${GH_VERSION}_linux_${ARCH}.deb" \ "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${ARCH}.deb" && \ @@ -87,27 +82,19 @@ ENV PATH="$BUN_INSTALL/bin:$PATH" # Set the default shell to zsh rather than sh (/usr/bin/zsh, matching the other # images; /bin/zsh is the same binary via usrmerge) ENV SHELL=/usr/bin/zsh -# Only LANG, and only C.UTF-8 — what the official images do (ruby sets exactly -# this; node/debian/python set nothing). LC_ALL would override every LC_* category -# and silently defeat a consuming project's containerEnv. TERM is deliberately -# unset: no official image sets it, an image ENV beats the tty value `docker exec -t` -# supplies, and tput/clear failing without a terminal is correct behaviour. +# LANG only, as the official images do. LC_ALL would outrank a consumer's +# containerEnv; TERM stays unset so the terminal's own value wins. ENV LANG=C.UTF-8 # zsh + oh-my-zsh + powerlevel10k. # # Written as bun: $HOME during RUN follows USER, so this must not run as root. USER bun -# oh-my-zsh tree is COPYed from the parallel download stage above, so the final -# image needs no git or curl for it and nothing is fetched at build time here. COPY --from=ohmyzsh-download --chown=bun:bun /omz /home/bun/.oh-my-zsh -# .zshrc is written here rather than by an installer, so nothing upstream can -# inject env overrides (locale/TERM) that then have to be patched back out. -# The wizard flag matters: without a POWERLEVEL9K_* config powerlevel10k starts -# its interactive configurator on first shell, which blocks a container. -# $HOME and $ZSH are single-quoted on purpose: they must reach .zshrc -# unexpanded so zsh resolves them at runtime, not at build time. +# .zshrc written here, not by an installer, so nothing upstream injects env +# overrides. The wizard flag stops powerlevel10k prompting on first shell. +# Single-quoted so $HOME/$ZSH reach .zshrc unexpanded. # hadolint ignore=SC2016 RUN printf '%s\n' \ 'export ZSH="$HOME/.oh-my-zsh"' \ diff --git a/claude-code/.devcontainer/Dockerfile b/claude-code/.devcontainer/Dockerfile index 9c65524..e1ed9db 100644 --- a/claude-code/.devcontainer/Dockerfile +++ b/claude-code/.devcontainer/Dockerfile @@ -18,11 +18,8 @@ # ── rtk (token-optimized CLI proxy) ────────────────────────────────────── # Version pinned and kept up to date by Renovate (see .github/renovate.json5). # ── oh-my-zsh + powerlevel10k ───────────────────────────────── -# Pinned to commit SHAs and fetched as tarballs. Neither project tags usefully -- -# oh-my-zsh has no tags at all, and powerlevel10k's last release predates its -# current master by years -- so a SHA is the only real pin. Bump deliberately: -# auto-adopting upstream shell-framework changes is what repeatedly broke this -# image, and a prompt theme does not need a 3-day release cadence. +# Pinned by commit SHA: neither project tags usefully, and auto-adopting +# shell-framework changes has broken this image before. Bump deliberately. FROM alpine:3.21 AS ohmyzsh-download RUN apk add --no-cache curl tar ARG OH_MY_ZSH_REF=0ee67f042872d1dfab74270c31867771ca35aef4 @@ -73,13 +70,9 @@ ARG GH_VERSION=2.100.0 # - git: version control # - jq: JSON processing (firewall script, onboarding patch) # - less: pager for git and other CLI output -# - openssh-client: provides ssh-keygen, needed for SSH-format commit signing -# (gpg.format=ssh + commit.gpgsign=true, copied in via VS Code's -# dev.containers.copyGitConfig); also restores ssh/ssh-add. git only -# *Recommends* it, so --no-install-recommends drops it unless listed here. -# Hard-depends on libfido2, so FIDO2 hardware keys (YubiKey sk-ssh-ed25519) -# sign too. Must be in base: the sandbox firewall blocks deb.debian.org, so -# it can't be added at runtime (same reasoning as chromium). See issue #110. +# - openssh-client: ssh-keygen for SSH-format commit signing (and FIDO2 keys via +# libfido2). git only Recommends it, so --no-install-recommends drops it. Must be +# in base — the sandbox firewall blocks deb.debian.org at runtime. See #110. # - sudo: privilege escalation for firewall setup # - zsh: interactive shell; oh-my-zsh + powerlevel10k configured below RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ @@ -102,11 +95,10 @@ RUN mkdir -p /usr/local/share/npm-global/lib \ ENV DEVCONTAINER=true -# Create workspace, Claude config, and node_modules volume mount points. -# Pre-creating these dirs with node ownership ensures Docker's volume -# population seeds fresh named volumes with correct permissions. -# Standard monorepo layout shared across projects. -# See: https://docs.docker.com/engine/storage/volumes/#populate-a-volume-using-a-container +# Volume mount points, pre-created node-owned so Docker seeds fresh named volumes +# with correct ownership. The XDG dirs matter for the same reason: a volume mounted +# at a nested path (consumers still declare *-fish-data at ~/.local/share/fish) +# makes Docker invent the missing parent as root, locking mise out of its data dir. RUN mkdir -p /workspace/node_modules \ /workspace/services/api/node_modules \ /workspace/services/app/node_modules \ @@ -114,16 +106,18 @@ RUN mkdir -p /workspace/node_modules \ /workspace/packages/shared/node_modules \ /workspace/packages/database/node_modules \ /home/node/.claude \ - /home/node/.local \ + /home/node/.local/share \ + /home/node/.local/state \ + /home/node/.config \ + /home/node/.cache \ /commandhistory \ - && chown -R node:node /workspace /home/node/.claude /home/node/.local /commandhistory + && chown -R node:node /workspace /home/node/.claude /home/node/.local \ + /home/node/.config /home/node/.cache /commandhistory WORKDIR /workspace -# gh (GitHub CLI) — installed from the upstream .deb, not apt: Debian trixie -# freezes gh at 2.46.0 (Apr 2024). Version kept current by Renovate. -# Must stay AFTER the apt block: the .deb depends on git, and dpkg -i does not -# resolve dependencies — it fails loudly ("dependency problems") if git is absent. +# gh from the upstream .deb: trixie freezes it at 2.46.0 (Apr 2024). Must follow +# the apt block — the .deb depends on git and dpkg -i won't resolve it. RUN ARCH=$(dpkg --print-architecture) \ && curl -fsSL -o "gh_${GH_VERSION}_linux_${ARCH}.deb" \ "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${ARCH}.deb" \ @@ -136,20 +130,15 @@ USER node ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin ENV SHELL=/usr/bin/zsh -# Only LANG, and only C.UTF-8 — what the official images do (ruby sets exactly -# this; node/debian/python set nothing). LC_ALL would override every LC_* category -# and silently defeat a consuming project's containerEnv. TERM is deliberately -# unset: no official image sets it, an image ENV beats the tty value `docker exec -t` -# supplies, and tput/clear failing without a terminal is correct behaviour. +# LANG only, as the official images do. LC_ALL would outrank a consumer's +# containerEnv; TERM stays unset so the terminal's own value wins. ENV LANG=C.UTF-8 ENV EDITOR="code --wait" ENV VISUAL="code --wait" # ── Mise (install as root) ──────────────────────────────────────────────────── -# Mise is installed as a tool manager — projects run `mise install` at container -# creation to install their specific tool versions from .mise.toml. -# Node is NOT installed via mise — it's provided by the base image; mise shims -# would shadow the base image's npx, breaking global npm-installed CLIs. +# Tool manager only — projects run `mise install` from their own .mise.toml. Node +# is deliberately not via mise: its shims would shadow the base image's npx. USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN curl https://mise.run | sh \ @@ -160,16 +149,11 @@ SHELL ["/bin/sh", "-c"] # ── zsh + oh-my-zsh + powerlevel10k ────────────────────────────────────────── # Written as node: $HOME during RUN follows USER, so this must not run as root. USER node -# oh-my-zsh tree is COPYed from the parallel download stage above, so the final -# image needs no git or curl for it and nothing is fetched at build time here. COPY --from=ohmyzsh-download --chown=node:node /omz /home/node/.oh-my-zsh -# .zshrc is written here rather than by an installer, so nothing upstream can -# inject env overrides (locale/TERM) that then have to be patched back out. -# The wizard flag matters: without a POWERLEVEL9K_* config powerlevel10k starts -# its interactive configurator on first shell, which blocks a container. -# $HOME and $ZSH are single-quoted on purpose: they must reach .zshrc -# unexpanded so zsh resolves them at runtime, not at build time. +# .zshrc written here, not by an installer, so nothing upstream injects env +# overrides. The wizard flag stops powerlevel10k prompting on first shell. +# Single-quoted so $HOME/$ZSH reach .zshrc unexpanded. # hadolint ignore=SC2016 RUN printf '%s\n' \ 'export ZSH="$HOME/.oh-my-zsh"' \ @@ -189,48 +173,35 @@ ENV PATH="/home/node/.local/share/mise/shims:$PATH" ENV MISE_TRUSTED_CONFIG_PATHS="/workspace" # ── Dev tools (copied from parallel download stages) ───────────────────────── -# rtk (token-optimized CLI proxy) and ralphex (autonomous plan execution). -# Like Claude Code itself, these are dev infrastructure — not project dependencies. -# Downloaded from GitHub Releases at Renovate-pinned versions (see .github/renovate.json5). +# rtk (CLI proxy) and ralphex (plan execution) — dev infrastructure, not project +# dependencies. Renovate-pinned; see .github/renovate.json5. COPY --from=rtk-download /usr/local/bin/rtk /usr/local/bin/rtk COPY --from=ralphex-download /usr/local/bin/ralphex /usr/local/bin/ralphex # ── Playwright MCP patch script ────────────────────────────────────────────── -# Universal logic with no project-specific config — bake into the image so -# consumer projects don't carry a copy. Invoked from postCreateCommand -# (init-plugins.sh), postStartCommand (devcontainer.json), and the Claude Code -# SessionStart hook (managed-settings.json below) — the hook closes the gap -# where the plugin auto-updates mid-container-run. See issues #87, #98. +# Baked in so consumer projects don't carry a copy. Invoked from init-plugins.sh, +# postStartCommand and a SessionStart hook — the hook covers plugin auto-updates +# mid-run. See #87, #98. COPY --chmod=0755 patch-playwright-mcp.sh /usr/local/bin/patch-playwright-mcp # ── Claude Code managed settings ───────────────────────────────────────────── -# Image-policy settings at the Linux managed location (highest precedence, -# outside any volume mount). Wires patch-playwright-mcp as a SessionStart hook -# so cache dirs created by mid-session plugin auto-updates get patched before -# the next session reads them. See issues #98, #101. +# Image-policy settings at the Linux managed location (highest precedence, outside +# any volume mount). Wires patch-playwright-mcp as a SessionStart hook. See #98, #101. # -# Pre-create /etc/claude-code as root: BuildKit applies COPY --chmod to any -# parent directories it auto-creates, which would leave the dir at mode 0644 -# (no execute bit, not traversable). Creating it explicitly avoids that and -# makes the file readable for the node user at runtime. +# /etc/claude-code is created explicitly: BuildKit applies COPY --chmod to parent +# dirs it auto-creates, leaving 0644 — not traversable. USER root RUN mkdir -p /etc/claude-code COPY --chown=root:root --chmod=0644 managed-settings.json /etc/claude-code/managed-settings.json USER node # ── Claude Code CLI ─────────────────────────────────────────────────────────── -# Using npm instead of the native installer (curl claude.ai/install.sh | bash). -# The native installer is recommended for interactive use, but rate-limits (429) -# when multiple parallel Docker builds hit it simultaneously. -# npm is deprecated for interactive users but still supported "for compatibility -# reasons" — Docker/CI parallel builds are exactly that reason. -# See: https://code.claude.com/docs/en/getting-started#install-with-npm -# Version pinned and kept up to date by Renovate (see .github/renovate.json5); -# a bump PR triggers a rebuild. +# npm, not the native installer: the installer rate-limits (429) under parallel +# Docker builds. npm stays supported "for compatibility reasons" — this is that +# reason. https://code.claude.com/docs/en/getting-started#install-with-npm # -# Install as the node user so all files land with node ownership from the start. -# A later `chown -R` in another layer would duplicate every file (overlayfs -# treats an ownership change as a rewrite), adding hundreds of MB. +# Installed as node so files land node-owned; a later chown -R would duplicate +# every file on overlayfs, adding hundreds of MB. # renovate: datasource=npm depName=@anthropic-ai/claude-code ARG CLAUDE_CODE_VERSION=2.1.274 RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}