diff --git a/.github/workflows/build-claude-code.yml b/.github/workflows/build-claude-code.yml new file mode 100644 index 0000000..7fd1ad4 --- /dev/null +++ b/.github/workflows/build-claude-code.yml @@ -0,0 +1,95 @@ +name: Build claude-code + +on: + push: + branches: [master] + paths: + - "claude-code/.devcontainer/Dockerfile" + workflow_dispatch: + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + REGISTRY: ghcr.io + +concurrency: + group: build-claude-code-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build & Push (${{ matrix.target }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - target: default + image-suffix: claude-code + verify-command: "bun --version || true && claude --version && mise --version && fish --version" + - target: sandbox + image-suffix: claude-code-sandbox + verify-command: "claude --version && mise --version && fish --version && iptables --version" + steps: + - uses: actions/checkout@v6 + + - name: Lowercase image base + id: repo + run: echo "image_base=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }} + tags: | + type=raw,value=latest + type=sha,prefix= + type=raw,value={{date 'YYYYMMDD'}} + + - name: Build and push + id: push + uses: docker/build-push-action@v7 + with: + context: claude-code/.devcontainer + file: claude-code/.devcontainer/Dockerfile + target: ${{ matrix.target }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} + + - name: Generate artifact attestation + uses: actions/attest@v4 + with: + subject-name: ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Verify image (amd64) + run: | + docker pull --platform linux/amd64 ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }}:latest + docker run --rm --platform linux/amd64 ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }}:latest bash -c "${{ matrix.verify-command }}" + + - name: Verify image (arm64) + run: | + docker pull --platform linux/arm64 ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }}:latest + docker run --rm --platform linux/arm64 ${{ steps.repo.outputs.image_base }}/${{ matrix.image-suffix }}:latest bash -c "${{ matrix.verify-command }}" diff --git a/.github/workflows/reusable-docker-build.yml b/.github/workflows/reusable-docker-build.yml index 4037138..24eeecf 100644 --- a/.github/workflows/reusable-docker-build.yml +++ b/.github/workflows/reusable-docker-build.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Generate image tags id: tags @@ -60,13 +60,13 @@ jobs: echo "$TAGS" | tr ',' '\n' - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -74,12 +74,12 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ steps.tags.outputs.namespace }}/${{ inputs.image-name }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} diff --git a/claude-code/.devcontainer/Dockerfile b/claude-code/.devcontainer/Dockerfile new file mode 100644 index 0000000..aa10114 --- /dev/null +++ b/claude-code/.devcontainer/Dockerfile @@ -0,0 +1,180 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# Shared devcontainer image — two build targets: +# default — full dev environment with agent-browser and passwordless sudo +# sandbox — network-restricted environment with firewall packages +# +# Projects consume these pre-built images and run `mise install` at container +# creation to install their specific tool versions (Bun, Hugo, etc.). +# +# Build: +# docker build --target default -t claude-code:default . +# docker build --target sandbox -t claude-code:sandbox . +# ═══════════════════════════════════════════════════════════════════════════════ + +# ─── BASE ───────────────────────────────────────────────────────────────────── +FROM node:22-trixie-slim AS base + +ARG GIT_DELTA_VERSION=0.18.2 +ARG PLAYWRIGHT_VERSION=1.50.1 +ARG AGENT_BROWSER_VERSION=0.14.0 + +# System packages shared by all targets +# - ca-certificates: SSL/TLS for HTTPS connections +# - curl/wget: downloading tools and installers +# - fish: interactive shell (built-in syntax highlighting, autosuggestions, completions) +# - fzf: fuzzy finder (fish integration via fzf.fish or built-in) +# - gh: GitHub CLI +# - git: version control +# - gnupg2: package signing verification +# - jq: JSON processing (firewall script, onboarding patch) +# - less: pager for git delta output +# - man-db: manual pages +# - nano/vim: editors +# - procps: ps, top (debugging) +# - sudo: privilege escalation for firewall setup +# - unzip: extracting archives +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + fish \ + fzf \ + gh \ + git \ + gnupg2 \ + jq \ + less \ + man-db \ + nano \ + procps \ + sudo \ + unzip \ + vim \ + wget \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# npm global directory with proper permissions for node user +# Pre-create /lib to prevent "ENOENT" errors during npx commands +RUN mkdir -p /usr/local/share/npm-global/lib \ + && chown -R node:node /usr/local/share/npm-global + +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 +RUN mkdir -p /workspace/node_modules \ + /workspace/services/api/node_modules \ + /workspace/services/app/node_modules \ + /workspace/services/www/node_modules \ + /workspace/packages/shared/node_modules \ + /workspace/packages/database/node_modules \ + /home/node/.claude \ + /home/node/.local/share/fish \ + && chown -R node:node /workspace /home/node/.claude /home/node/.local + +WORKDIR /workspace + +# Install git-delta (better git diffs) +RUN ARCH=$(dpkg --print-architecture) \ + && wget -q "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" \ + && dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" \ + && rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" + +# ── Non-root user setup ────────────────────────────────────────────────────── +USER node + +ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global +ENV PATH=$PATH:/usr/local/share/npm-global/bin +ENV SHELL=/usr/bin/fish +ENV EDITOR=nano +ENV VISUAL=nano + +# ── Starship + Mise (install as root, configure as node) ─────────────────────── +USER root +RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes +RUN curl https://mise.run | sh \ + && cp /root/.local/bin/mise /usr/local/bin/mise + +# Configure starship and fish shell. +# 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 Playwright installation. +USER node +RUN mkdir -p /home/node/.config/fish \ + && starship preset no-runtime-versions -o /home/node/.config/starship.toml \ + && printf '%s\n' 'set -g fish_greeting' 'starship init fish | source' > /home/node/.config/fish/config.fish +ENV PATH="/home/node/.local/share/mise/shims:$PATH" +ENV MISE_TRUSTED_CONFIG_PATHS="/workspace" + +# ── Playwright (headless shell for browser testing) ─────────────────────────── +# Browser binary baked at a pinned version (PLAYWRIGHT_VERSION build arg). +# If a project's @playwright/test version differs from PLAYWRIGHT_VERSION, +# Playwright auto-downloads the correct browser on first test run (~10s). +# This is a best-effort optimization, not a hard contract. +# Using --only-shell for smaller image (agent-browser installs full chromium separately). +USER root +RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium +USER node +RUN npx -y playwright@${PLAYWRIGHT_VERSION} install --only-shell + +# ── Claude Code CLI (native installer) ─────────────────────────────────────── +# Native installer replaces deprecated npm method (npm install -g @anthropic-ai/claude-code) +# See: https://code.claude.com/docs/en/getting-started +# The installer places the binary in ~/.local/bin/ (previously ~/.claude/bin/). +# Runtime volume mounts over ~/.claude/ (for config persistence) could shadow it, +# so copy to a system path to ensure the binary survives volume mounts. +RUN curl -fsSL https://claude.ai/install.sh | bash +USER root +RUN if [ -d /home/node/.local/bin ] && ls /home/node/.local/bin/claude* >/dev/null 2>&1; then \ + cp /home/node/.local/bin/claude* /usr/local/bin/; \ + elif [ -d /home/node/.claude/bin ]; then \ + cp /home/node/.claude/bin/* /usr/local/bin/; \ + else \ + echo "ERROR: Claude Code binary not found in ~/.local/bin/ or ~/.claude/bin/" && exit 1; \ + fi +USER node + +# ─── DEFAULT — full dev environment ─────────────────────────────────────────── +FROM base AS default + +# Passwordless sudo for node user — standard practice for devcontainer images. +# Required by the canonical "sudo chown" pattern for named volume ownership. +# See: https://code.visualstudio.com/remote/advancedcontainers/improve-performance +USER root +RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \ + && chmod 0440 /etc/sudoers.d/node-nopasswd +USER node + +# agent-browser: headless browser automation for AI agents +# Installs its own bundled playwright-core + full chromium (separate from Playwright above) +# Requires root for system deps (apt-get install triggered by --with-deps) +USER root +RUN npm install -g agent-browser@${AGENT_BROWSER_VERSION} \ + && $(npm root -g)/agent-browser/node_modules/.bin/playwright-core install --with-deps chromium \ + && chown -R node:node /usr/local/share/npm-global +USER node + +# ─── SANDBOX — network-restricted environment ───────────────────────────────── +FROM base AS sandbox + +# Firewall packages (not needed in default target) +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + iptables \ + ipset \ + iproute2 \ + dnsutils \ + aggregate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Firewall sudo rule for node user. +# The init-firewall.sh script is NOT baked into the image — each project +# mounts its own script via bind mount in devcontainer.json: +# "source=${localWorkspaceFolder}/.devcontainer/claude-sandbox/init-firewall.sh,target=/usr/local/bin/init-firewall.sh,type=bind" +# This allows different projects to define their own domain allowlists. +RUN echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall \ + && chmod 0440 /etc/sudoers.d/node-firewall +USER node diff --git a/claude-code/README.md b/claude-code/README.md new file mode 100644 index 0000000..a4522bc --- /dev/null +++ b/claude-code/README.md @@ -0,0 +1,261 @@ +# claude-code + +Shared devcontainer image for Claude Code development environments. Two variants from a single multi-stage Dockerfile: **default** (full dev environment) and **sandbox** (network-restricted). + +Projects consume these pre-built images and control their own tool versions via `.mise.toml`. + +## Image Variants + +| Variant | Image | Use Case | +|---------|-------|----------| +| **default** | `ghcr.io/gatezh/devcontainer-images/claude-code:latest` | Full dev environment with agent-browser and passwordless sudo | +| **sandbox** | `ghcr.io/gatezh/devcontainer-images/claude-code-sandbox:latest` | Network-restricted environment with iptables firewall packages | + +## What's Included + +| Layer | What | Why | +|-------|------|-----| +| OS | `node:22-trixie-slim` + system packages | Node is needed during the build (Playwright, npm globals) | +| Shell | Fish, Starship, fzf | Built-in syntax highlighting, autosuggestions, completions | +| Tools | git-delta, gh CLI, jq, nano, vim, wget, unzip, less, man-db, procps | Standard dev utilities | +| Mise | The tool manager itself (not the tools) | Projects run `mise install` at container creation for their tool versions | +| Claude Code | Native CLI installer | Binary copied to `/usr/local/bin/` to survive volume mounts | +| Playwright | System deps + browser binary at a pinned version | See [Playwright version strategy](#playwright-version-strategy) | + +**Default-only:** passwordless sudo, agent-browser + full Chromium + +**Sandbox-only:** iptables, ipset, iproute2, dnsutils, aggregate, firewall sudo rule + +## Multi-platform Support + +Both variants are built for: +- `linux/amd64` (x86_64) +- `linux/arm64` (ARM64/Apple Silicon) + +## Image Tags + +- `:latest` — most recent build +- `:` — pinned to a specific commit +- `:` — date-based tag (e.g., `20260319`) + +## Quick Start + +### Default variant + +Add to your project's `.devcontainer/devcontainer.json`: + +```jsonc +{ + "name": "Local Development", + "image": "ghcr.io/gatezh/devcontainer-images/claude-code:latest", + "init": true, + "remoteUser": "node", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "workspaceFolder": "/workspace", + // Named volumes persist node_modules, Claude config, and fish history across rebuilds. + // Dirs are pre-created in the image with node:node ownership, so fresh volumes + // inherit correct permissions via Docker volume population. + "mounts": [ + "source=myproject-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume", + "source=myproject-claude-config-${devcontainerId},target=/home/node/.claude,type=volume", + "source=myproject-fish-data-${devcontainerId},target=/home/node/.local/share/fish,type=volume" + ], + "containerEnv": { + "TZ": "${localEnv:TZ:America/Los_Angeles}", + "DEVCONTAINER": "true", + "NODE_OPTIONS": "--max-old-space-size=4096", + "CLAUDE_CONFIG_DIR": "/home/node/.claude" + }, + // mise install reads .mise.toml and installs project-specific tool versions. + // sudo chown fixes volume ownership — safety net in case Docker volume population didn't apply. + "updateContentCommand": "sudo chown node /workspace/node_modules && sudo chown -R node /home/node/.claude && mise install && bun install", + "waitFor": "postCreateCommand" +} +``` + +### Sandbox variant + +```jsonc +{ + "name": "Claude Sandbox", + "image": "ghcr.io/gatezh/devcontainer-images/claude-code-sandbox:latest", + // Capabilities required for iptables firewall setup + "capAdd": ["NET_ADMIN", "NET_RAW"], + "init": true, + "remoteUser": "node", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "workspaceFolder": "/workspace", + "mounts": [ + "source=sandbox-fish-${devcontainerId},target=/home/node/.local/share/fish,type=volume", + "source=sandbox-config-${devcontainerId},target=/home/node/.claude,type=volume", + // Mount project's firewall script into the expected path. + // The image provides iptables/ipset packages and sudo rule but NOT the script itself. + "source=${localWorkspaceFolder}/.devcontainer/claude-sandbox/init-firewall.sh,target=/usr/local/bin/init-firewall.sh,type=bind" + ], + "containerEnv": { + "TZ": "${localEnv:TZ:America/Los_Angeles}", + "DEVCONTAINER": "true", + "NODE_OPTIONS": "--max-old-space-size=4096", + "CLAUDE_CONFIG_DIR": "/home/node/.claude" + }, + "postCreateCommand": "mise install", + // Firewall init — script is bind-mounted from the project + "postStartCommand": "sudo /usr/local/bin/init-firewall.sh", + "waitFor": "postStartCommand" +} +``` + +## Project Setup Guide + +Projects consuming these images need the following files in their repository. + +### Required: `.mise.toml` (project root) + +Each project defines its own tool versions: + +```toml +[tools] +bun = "1.3.8" +# node is provided by the base image (node:22-trixie-slim). +# This entry is for CI environments where the base image isn't available. +# Inside the devcontainer, mise detects Node is already on $PATH and skips installation. +node = "22" +``` + +### Optional: `.devcontainer/init-plugins.sh` + +Claude Code plugin initialization. Runs once at container creation. Idempotent. + +```bash +#!/bin/bash +set -euo pipefail + +# Mark onboarding complete so claude CLI doesn't hang on interactive prompts +if [ -f "$HOME/.claude/.claude.json" ]; then + jq '.hasCompletedOnboarding = true' "$HOME/.claude/.claude.json" > /tmp/.claude.json \ + && mv /tmp/.claude.json "$HOME/.claude/.claude.json" +else + mkdir -p "$HOME/.claude" + echo '{"hasCompletedOnboarding":true}' > "$HOME/.claude/.claude.json" +fi + +# Install plugins (customize this list) +for plugin in \ + "frontend-design@claude-plugins-official" \ + "code-review@claude-plugins-official"; do + claude plugin install "$plugin" 2>/dev/null || true +done +``` + +Mark as executable: `chmod +x init-plugins.sh` + +### Sandbox-only: `.devcontainer/claude-sandbox/init-firewall.sh` + +Default-deny iptables firewall. The image provides the packages and sudo rule; the project provides this script via bind mount. Customize the domain allowlist for your project. + +See the [devcontainer-claude-bun firewall script](../devcontainer-claude-bun/.devcontainer/init-firewall.sh) for a complete example. + +Mark as executable: `chmod +x init-firewall.sh` + +### Sandbox-only: `.devcontainer/claude-sandbox/.env.example` + +Template for sandbox authentication: + +```bash +# Claude Code authentication +# Generate a token with: claude setup-token +# Copy this file to .env.local and fill in your token: +# cp .env.example .env.local +CLAUDE_CODE_OAUTH_TOKEN=your-token-here +``` + +Add `.env.local` to `.gitignore`. + +### Complete file structure + +``` +.devcontainer/ +├── devcontainer.json ← default devcontainer +├── init-plugins.sh ← Claude Code plugin setup (optional) +└── claude-sandbox/ + ├── devcontainer.json ← sandbox devcontainer + ├── init-firewall.sh ← firewall script (customize domain allowlist) + ├── .env.example ← template for auth token + └── .env.local ← actual auth token (gitignored) +``` + +## Workspace Directory Layout + +The image pre-creates these directories with `node:node` ownership so Docker's volume population seeds fresh named volumes with correct permissions: + +``` +/workspace/ +├── node_modules/ +├── services/ +│ ├── api/node_modules/ +│ ├── app/node_modules/ +│ └── www/node_modules/ +└── packages/ + ├── shared/node_modules/ + └── database/node_modules/ +``` + +If your project has additional services, create volume mounts in `devcontainer.json` — Docker creates directories at container start. The `sudo chown` in `updateContentCommand` fixes ownership. + +## Playwright Version Strategy + +The image bakes in a Playwright browser binary at a specific version (controlled by the `PLAYWRIGHT_VERSION` build arg). + +- If your project's `@playwright/test` version **matches** the image — zero startup cost, browser is ready +- If your project's version **differs** — Playwright auto-downloads the correct browser on first test run (~10s graceful fallback) +- This is a **best-effort optimization**, not a hard contract + +## Build Args + +| Arg | Default | Description | +|-----|---------|-------------| +| `GIT_DELTA_VERSION` | `0.18.2` | git-delta version | +| `PLAYWRIGHT_VERSION` | `1.50.1` | Playwright browser binary version | +| `AGENT_BROWSER_VERSION` | `0.14.0` | agent-browser version (default target only) | + +## Building Locally + +```bash +# Default variant +docker build --target default -t claude-code:default .devcontainer + +# Sandbox variant +docker build --target sandbox -t claude-code:sandbox .devcontainer + +# Multi-platform +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target default \ + -t ghcr.io/gatezh/devcontainer-images/claude-code:latest \ + --push \ + .devcontainer +``` + +## Startup Timeline + +### Warm start (Playwright version matches image) +``` +Pull image ───────────────────── (cached) +mise install (bun, hugo, etc.) ─ (~15s, downloads pre-built binaries) +bun install ─────────────────── (~15s, cached in named volume) +project setup ───────────────── (db:migrate, init-plugins, etc.) + Total: ~45s warm +``` + +### Playwright version mismatch +``` +Same as above. First test run triggers browser download (~10s, one-time). +Not a blocking startup cost — tests work, just slightly slower first run. +``` + +## Resources + +- [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code) +- [VS Code Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) +- [Mise Documentation](https://mise.jdx.dev/) +- [Docker Volume Population](https://docs.docker.com/engine/storage/volumes/#populate-a-volume-using-a-container)