migrate to the new tiered structure

This commit is contained in:
Justin
2026-06-20 10:18:18 -04:00
parent 535c837dd3
commit 4b3df1ddae
17 changed files with 495 additions and 224 deletions

View File

@ -0,0 +1,3 @@
# Copy to .env (gitignored) for secrets injected into the container at runtime.
# Private gem registry token used by hooks/20-bundle.sh:
# BUNDLE_GEMS__GRAPHQL__PRO=user:token

1
example/.safeclaude/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.env

View File

@ -0,0 +1,31 @@
# Example .safeclaude/Dockerfile for a Ruby + Postgres app.
#
# Everything here runs once when the container is built (and is cached), so it
# won't slow down launches. You're root during the build, so apt just works.
FROM safeclaude-base:latest
# System packages: what's needed to build Ruby, talk to Postgres, proxy the
# database (socat), and run browser tests (headless Chrome).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
libpq-dev \
socat \
chromium chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Capybara/Selenium look for Chrome at these paths.
ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER=/usr/bin/chromedriver
# rbenv and nvm get installed by the hooks at launch instead of here, because
# they live in the home folder — and that folder is swapped in fresh each run, so
# anything we installed there now would just be thrown away. Here we only set the
# paths and shell setup; the two lines below do nothing until a hook installs the
# matching tool.
ENV RBENV_ROOT=/home/coder/.rbenv
ENV NVM_DIR=/home/coder/.nvm
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc && \
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc

View File

@ -0,0 +1,31 @@
#!/bin/bash
# Sets up rbenv and the project's Ruby. Safe to run every launch — it only does
# real work the first time, or when the Ruby version changes.
set -euo pipefail
# Grab rbenv (cloned into the home folder so it sticks around between runs).
if [ ! -d "$RBENV_ROOT/bin" ]; then
echo "[ruby] installing rbenv..."
git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT"
git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build"
fi
eval "$(rbenv init - bash)"
# Pick the Ruby version: the project's .ruby-version wins, then whatever rbenv
# was last set to, then a sensible default.
if [ -f /code/.ruby-version ]; then
RUBY_VERSION="$(tr -d '[:space:]' < /code/.ruby-version)"
elif [ -f "$RBENV_ROOT/version" ]; then
RUBY_VERSION="$(cat "$RBENV_ROOT/version")"
else
RUBY_VERSION="3.3.6"
fi
if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
echo "[ruby] installing Ruby $RUBY_VERSION (first time only — takes a few minutes)..."
rbenv install "$RUBY_VERSION"
fi
rbenv global "$RUBY_VERSION"
# Make sure bundler is installed for this Ruby.
gem list bundler -i &>/dev/null || gem install bundler --no-document

View File

@ -0,0 +1,15 @@
#!/bin/bash
# OPTIONAL — only needed if your project pins a Node version via .nvmrc (the base
# already includes a working Node). Rename to 15-node.sh to turn it on.
set -euo pipefail
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "[node] installing nvm..."
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \
| NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash
fi
. "$NVM_DIR/nvm.sh"
if [ -f /code/.nvmrc ]; then
( cd /code && nvm install && nvm use )
fi

View File

@ -0,0 +1,21 @@
#!/bin/bash
# Installs gems, but only when Gemfile.lock has changed: it remembers the last
# version it installed (saved between runs), so an unchanged lockfile is a
# near-instant no-op.
set -euo pipefail
[ -f /code/Gemfile ] || exit 0
eval "$(rbenv init - bash)"
LOCK=/code/Gemfile.lock
MARKER="$HOME/.safeclaude-deps/gemfile.sha"
mkdir -p "$(dirname "$MARKER")"
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then
echo "[bundle] installing gems..."
# The token below (for a private gem source) comes from .safeclaude/.env — see
# .env.example. It's fine to leave unset if your project doesn't need it.
( cd /code && BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install )
echo "$CUR" > "$MARKER"
fi

View File

@ -0,0 +1,15 @@
#!/bin/bash
# Makes the host machine's Postgres reachable at the usual 127.0.0.1:5432 inside
# the container, so your database settings work the same in or out of Docker.
set -euo pipefail
# If something's already answering on 5432 (like a proxy from a previous launch),
# leave it be. We test the port with a built-in bash trick so we don't have to
# install extra tools just to check.
if (exec 3<>/dev/tcp/127.0.0.1/5432) 2>/dev/null; then
exec 3>&- # port answered — already running
else
echo "[pg] proxying 127.0.0.1:5432 -> host.docker.internal:5432"
socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \
TCP:host.docker.internal:5432 &
fi

View File

@ -1,69 +0,0 @@
FROM node:22-slim
# System dependencies:
# - curl/ca-certificates: downloads (Claude Code installer, nvm, git-spice)
# - git + ripgrep: Claude Code requirements
# - build-essential, libssl-dev, libreadline-dev, zlib1g-dev: rbenv/ruby-build deps
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
ripgrep \
bash \
build-essential \
libssl-dev \
libreadline-dev \
zlib1g-dev \
libffi-dev \
libyaml-dev \
libpq-dev \
socat \
chromium \
chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Selenium/Capybara discover Chrome via these env vars.
# chromium-driver installs chromedriver at /usr/bin/chromedriver.
ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER=/usr/bin/chromedriver
# --- rbenv (installed into home volume on first run, via entrypoint) ---
# RBENV_ROOT points into the home volume so the install persists across rebuilds.
ENV RBENV_ROOT=/home/coder/.rbenv
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
# Initialise rbenv shims for all bash sessions (no-op if not yet installed)
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc
# --- nvm (installed into home volume on first run, via entrypoint) ---
# NVM_DIR points into the home volume so the install persists across rebuilds.
ENV NVM_DIR=/home/coder/.nvm
# Source nvm for all bash sessions (nvm.sh is a no-op if not yet installed)
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc
# --- git-spice ---
# Releases are named git-spice.Linux-<arch>.tar.gz; uname -m gives the right arch directly.
RUN ARCH=$(uname -m) && \
GS_VERSION=$(curl -fsSL https://api.github.com/repos/abhinav/git-spice/releases/latest \
| grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/') && \
mkdir -p /tmp/gs-install && \
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/v${GS_VERSION}/git-spice.Linux-${ARCH}.tar.gz" \
| tar -xz -C /tmp/gs-install && \
find /tmp/gs-install -maxdepth 1 -type f -executable -exec cp {} /usr/local/bin/gs \; && \
chmod +x /usr/local/bin/gs && \
rm -rf /tmp/gs-install
# --- non-root user ---
RUN useradd -m -s /bin/bash -u 1001 coder
# Claude Code native installer lands in ~/.local/bin or ~/.claude/bin
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
USER coder
WORKDIR /code
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["bash"]

28
example/README.md Normal file
View File

@ -0,0 +1,28 @@
# Example: a Ruby + Postgres project
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the
Postgres client, and headless Chrome for browser tests. It's here as a reference
you can copy from when setting up your own project.
## How to use it
- **Start fresh, then borrow:** run `safeclaude init` in your project for a blank
starter, then copy whatever pieces you need from here. (Recommended — you only
pull in what applies to you.)
- **Copy the whole thing:** `cp -r .../safeclaude/example/.safeclaude ~/your-project/`
and tweak from there.
## What's where
| File | When it runs | What it does |
| --- | --- | --- |
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers |
| `hooks/10-ruby.sh` | each launch | installs the project's Ruby and bundler (skips if already done) |
| `hooks/20-bundle.sh` | each launch | runs `bundle install`, but only when `Gemfile.lock` changed |
| `hooks/30-pg-proxy.sh` | each launch | lets the app reach the host's Postgres at the usual `127.0.0.1:5432` |
| `hooks/15-node.sh.example` | off by default | optional Node setup — rename to `15-node.sh` to turn on |
| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) |
The pattern to take away: slow, one-time installs go in the **Dockerfile** so
they're cached; anything that needs your code or has to stick around between
runs goes in a **hook**.

View File

@ -1,40 +0,0 @@
services:
claude-code:
build: .
image: claude-code:local
container_name: claude-code
volumes:
# Fixed home volume — persists Claude Code install, config, and credentials
# across container restarts and image rebuilds.
- claude-home:/home/coder
# Swappable project folder — override PROJECT_DIR to point at any directory:
# PROJECT_DIR=/path/to/myproject docker compose run --rm claude-code
- ${PROJECT_DIR:-./code}:/code
# Drop all Linux capabilities except NET_BIND_SERVICE, which socat needs
# to proxy port 5432 on 127.0.0.1 inside the container.
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
# Allow the container to reach the host's network (e.g. a local postgres).
# On Linux, host.docker.internal isn't automatic — this creates it.
# On Mac/Windows Docker Desktop it's already available but this is harmless.
extra_hosts:
- "host.docker.internal:host-gateway"
# Interactive terminal so `claude` works properly
stdin_open: true
tty: true
working_dir: /code
volumes:
claude-home:
# Named volume — Docker manages it; survives `docker compose down`
# (use `docker compose down -v` to wipe it along with the install)

View File

@ -1,78 +0,0 @@
#!/bin/bash
set -e
# Install nvm if not already present in the home volume.
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "nvm not found — running installer..."
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh |
NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash
echo "nvm installed successfully."
fi
. "$NVM_DIR/nvm.sh"
# Install rbenv + ruby-build if not already present in the home volume.
if [ ! -d "$RBENV_ROOT/bin" ]; then
echo "rbenv not found — installing..."
git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT"
git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build"
echo "rbenv installed successfully."
fi
eval "$(rbenv init - bash)"
# Determine the desired Ruby version: prefer .ruby-version in the workspace,
# fall back to the rbenv global setting, then a hardcoded default.
if [ -f "/code/.ruby-version" ]; then
RUBY_VERSION=$(tr -d '[:space:]' </code/.ruby-version)
elif [ -f "$RBENV_ROOT/version" ]; then
RUBY_VERSION=$(cat "$RBENV_ROOT/version")
else
RUBY_VERSION="3.3.6"
fi
# Install the Ruby version if it isn't already built.
if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
echo "Ruby $RUBY_VERSION not found — installing (this may take a few minutes)..."
rbenv install "$RUBY_VERSION"
echo "Ruby $RUBY_VERSION installed."
fi
rbenv global "$RUBY_VERSION"
# Ensure bundler is available for this Ruby version.
if ! gem list bundler -i &>/dev/null; then
gem install bundler --no-document
fi
# Install gem dependencies for the linked workspace so rspec (and other gems)
# are available without needing an explicit `bundle install` step.
if [ -f "/code/Gemfile" ]; then
echo "Gemfile found — running bundle install..."
pushd /code
# TODO: Elaborate or expand — wire secrets (e.g. a private gem registry token)
# in from the environment instead of hardcoding. Compose's auto-loaded .env only
# does YAML interpolation (next to the compose file), not container injection;
# injecting needs `environment:`/`env_file:`. In the project-local model, point
# this at the mounted project's .safeclaude/.env so secrets live with the project.
BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install
popd
fi
# Install Claude Code if not already present in the home volume.
# Because the home directory is a volume, this install persists across
# container restarts and rebuilds.
if ! command -v claude &>/dev/null; then
echo "Claude Code not found — running installer..."
curl -fsSL https://claude.ai/install.sh | bash
echo "Claude Code installed successfully."
else
echo "Claude Code $(claude --version 2>/dev/null || echo '(version unknown)') ready."
fi
# Proxy host postgres to 127.0.0.1:5432 inside the container so the app can
# use the same DATABASE_URL whether running inside or outside Docker.
if ! ss -tlnp 2>/dev/null | grep -q ':5432'; then
echo "Starting postgres proxy 127.0.0.1:5432 -> host.docker.internal:5432"
socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \
TCP:host.docker.internal:5432 &
fi
exec "$@"

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") <path-to-project> [claude-args...]" >&2
exit 1
fi
PROJECT_DIR="$(cd "$1" && pwd)" # resolve to absolute path
shift
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
PROJECT_DIR="$PROJECT_DIR" docker compose -f "$SCRIPT_DIR/docker-compose.yml" run -w /code --rm claude-code claude "$@"