commit 9a39e16c1f4ef28cccc06757c6d53131005b0c6b Author: khannurien Date: Sat Sep 12 09:38:07 2026 +0200 initial commit diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a60ce64 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,41 @@ +# One image for the whole stack, so every repo's toolchain is installed here. +# +# Start from the image of whichever toolchain is the most awkward to add by +# hand, and add the others on top. The devcontainers images are at +# mcr.microsoft.com/devcontainers/: base, go, typescript-node, python, rust, +# java. The remote user is `vscode` on most of them and `node` on +# typescript-node; keep USERNAME below in sync. +FROM mcr.microsoft.com/devcontainers/base:bookworm +ARG USERNAME=vscode + +# A second toolchain, copied from its official image. Pin the same version the +# repo's CI uses. +# +# COPY --from=golang:1.26-bookworm /usr/local/go /usr/local/go +# ENV GOPATH=/home/${USERNAME}/go +# ENV PATH=/usr/local/go/bin:/home/${USERNAME}/go/bin:$PATH +# +# Watch out. `ENV PATH` alone is not enough: /etc/profile reassigns PATH +# outright, so any login shell (VS Code terminals, and anything Claude Code +# spawns) loses the toolchain. Other variables survive, only PATH needs +# replaying: +# +# RUN printf 'export PATH=/usr/local/go/bin:${GOPATH:-$HOME/go}/bin:$PATH\n' \ +# > /etc/profile.d/go.sh + +# Whatever the servers and their tests shell out to at runtime, plus the +# packages the repos' own release images install. +# +# RUN apt-get update \ +# && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +# build-essential \ +# && rm -rf /var/lib/apt/lists/* + +# Linters and package managers, at the versions CI pins. +# +# RUN su ${USERNAME} -c "npm install -g pnpm@10" + +# The repos are bind mounted as siblings under /workspaces, which is also the +# workspace root. Create it here so it belongs to the remote user rather than +# being left root-owned by the daemon creating the mount points. +RUN mkdir -p /workspaces && chown ${USERNAME}:${USERNAME} /workspaces diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e28f57c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,50 @@ +{ + "name": "dev stack", + "build": { + "dockerfile": "Dockerfile" + }, + + // Every repo lands as a sibling inside /workspaces, which is ordinary + // container filesystem, and /workspaces itself is the workspace root. + // Nesting the other repos *inside* this one's bind mount instead would + // make the Docker daemon create those mount points as root through the + // bind, leaving root-owned empty directories behind on the host. + // + // With /workspaces as the workspace root, one Claude Code session started + // there reaches every repo as an ordinary subdirectory, with no --add-dir + // and no additionalDirectories permission entry. + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind", + "workspaceFolder": "/workspaces", + + // One entry per repo, matching the directories listed in stack.conf. + "mounts": [ + "source=${localWorkspaceFolder}/../my-backend,target=/workspaces/my-backend,type=bind", + "source=${localWorkspaceFolder}/../my-frontend,target=/workspaces/my-frontend,type=bind" + ], + + // Host networking, so the servers see each other and the host browser as + // plain localhost, with no forwardPorts list to keep in sync. + "runArgs": ["--network=host"], + + // Environment for the servers: where each one keeps its dev state, which + // address it listens on, where it finds the other half of the stack. + // ${containerWorkspaceFolder} is /workspaces. + "containerEnv": { + // "MYAPP_DB_PATH": "${containerWorkspaceFolder}/my-backend/.dev/app.db", + // "MYAPP_LISTEN_ADDR": "0.0.0.0:4747" + }, + + "postCreateCommand": "${localWorkspaceFolderBasename}/.devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + // One list for the whole stack, so both toolchains are here. + ] + } + }, + + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:4": {} + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..a75fdb9 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Runs once, when the container is created. Executes each repo's setup command +# from stack.conf. +set -euo pipefail + +here="$(cd "$(dirname "$0")/.." && pwd)" +source "$here/stack.conf" + +# The workspace root, which holds every repo of the stack as a sibling. +cd "$here/.." + +for service in "${SERVICES[@]}"; do + IFS='|' read -r dir setup run <<<"$service" + [ -n "$setup" ] || continue + echo "==> $dir: $setup" + (cd "$dir" && exec bash -c "$setup") +done + +# Anything else the stack needs once: writing a dev config file one of the +# repos expects, creating state directories, seeding a database. There is no +# TTY here, so nothing may prompt. +# +# echo "==> my-frontend/public/env.js" +# cat > my-frontend/public/env.js <<'JS' +# window.env = { +# SERVER_URL: 'http://localhost:4747' +# } +# JS diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fc1f12 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# devcontainer-stack + +A template for one dev container holding several repos at once, so a single +Claude Code session can work across a whole stack (a backend and its frontend, +a service and its client library) instead of one repo at a time. + +Copy this directory next to the repos it should hold, rename it, and edit the +four files marked below. + +## Layout + +The repos live next to this directory on the host and are bind mounted into +the container as subdirectories of the workspace root: + + /workspaces/ <- workspace root + ├── my-backend/ (bind mount) + ├── my-frontend/ (bind mount) + └── devcontainer-stack/ (this repo, bind mount) + ├── .devcontainer/ + ├── stack.conf + ├── dev.sh + └── stack.code-workspace + +All of them are siblings under the workspace root, so one Claude Code session +started there reaches every repo with no `--add-dir` and no +`additionalDirectories` permission entry. + +They are deliberately not nested inside this repo's own bind mount: mount +points created inside a bind mount are made by the Docker daemon as root and +write through to the host, which leaves root-owned empty directories behind. + +## Adapting it + +1. Rename the directory. Nothing reads its name: the scripts locate themselves, + and `devcontainer.json` uses `${localWorkspaceFolderBasename}`. +2. `stack.conf`: one line per repo, `directory|setup command|run command`. + Setup runs once at container create, run starts that repo's dev server. +3. `.devcontainer/devcontainer.json`: one `mounts` entry per repo, matching + those directories. Then `containerEnv` for whatever the servers read out of + the environment, and the VS Code extension list. +4. `.devcontainer/Dockerfile`: the toolchains. It starts from the plain + `base:bookworm` image, with commented patterns for adding a language, apt + packages and pinned linters. +5. `stack.code-workspace`: one folder entry per repo. + +Steps 2 to 5 repeat the repo directories in four files. Keeping them in sync by +hand is the price of `devcontainer.json` not being able to read a config file. + +## Use + +1. Open this folder in VS Code. When prompted, choose **Reopen in Container**, + not "Open Workspace": the latter opens the workspace with host paths and + bypasses the container entirely. +2. Once inside the container, `File > Open Workspace from File...` on + `stack.code-workspace`. The separate roots make each language server root + itself at the manifest it expects. Skipping this step still works; the + language servers are just less precise. +3. `./dev.sh` starts every server declared in `stack.conf`. Ctrl-C stops all of + them. + +The container uses host networking, so the host browser reaches the servers on +`localhost` at whatever ports they bind, with no `forwardPorts` list to keep in +sync. + +## Notes + +**Signals.** `dev.sh` sends `SIGTERM` to the process it started. A wrapper that +does not forward signals leaves the real server running and holding its port +after Ctrl-C. `go run` is one: build a binary and run that. Calling a dev +server's binary directly (`./node_modules/.bin/vite`) rather than through a +package manager script is one process less to orphan. + +**pnpm.** `pnpm` records the absolute install path in `node_modules` and asks +to purge it when that path changes, which it does when the same repo was +installed on the host or in a differently-mounted container. `post-create.sh` +has no TTY, so the prompt aborts the whole create. Pass +`--config.confirmModulesPurge=false`. + +**Dev state.** Point the servers at paths inside their own repo (`.dev/` is the +convention here, and `stack.code-workspace` hides it) rather than at the +`/data`, `/cache` paths their release images expect. The state then disappears +with the repo and survives container rebuilds. + +**Per-repo containers.** A repo that has its own `.devcontainer/` keeps it, for +single-repo work and to stay independently buildable. This container supersedes +them when working across the stack; don't run both at once, since they bind the +same ports. diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..ea2ae48 --- /dev/null +++ b/dev.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Start every dev server declared in stack.conf. Ctrl-C stops all of them. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +source "$here/stack.conf" + +# The workspace root, which holds every repo of the stack as a sibling. +cd "$here/.." + +pids=() + +cleanup() { + trap - EXIT INT TERM + [ ${#pids[@]} -gt 0 ] && kill -TERM "${pids[@]}" 2>/dev/null + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +for service in "${SERVICES[@]}"; do + IFS='|' read -r dir setup run <<<"$service" + [ -n "$run" ] || continue + echo "==> $dir: $run" + (cd "$dir" && exec bash -c "$run") & + pids+=($!) +done + +[ ${#pids[@]} -gt 0 ] || { echo "no run command in stack.conf" >&2; exit 1; } + +# Return as soon as one server exits, so cleanup stops the others rather than +# leaving half a stack running. +wait -n || true diff --git a/stack.code-workspace b/stack.code-workspace new file mode 100644 index 0000000..f631d71 --- /dev/null +++ b/stack.code-workspace @@ -0,0 +1,22 @@ +{ + // One root per repo rather than the whole /workspaces tree, so each + // language server roots itself at the manifest it expects (go.mod, + // package.json, pyproject.toml) instead of at their common parent. + // Paths are relative to this file, which sits in this repo alongside the + // others. + "folders": [ + { + "name": "my-backend", + "path": "../my-backend" + }, + { + "name": "my-frontend", + "path": "../my-frontend" + } + ], + "settings": { + "files.exclude": { + "**/.dev": true + } + } +} diff --git a/stack.conf b/stack.conf new file mode 100644 index 0000000..5777d73 --- /dev/null +++ b/stack.conf @@ -0,0 +1,25 @@ +# The repos of the stack, one line per repo: +# +# directory|setup command|run command +# +# directory is a sibling of this repo on the host, bind mounted at +# /workspaces/ (see .devcontainer/devcontainer.json). +# setup runs once, when the container is created (post-create.sh). +# run starts that repo's dev server (dev.sh). Leave it empty for a repo that +# has nothing to run. +# +# Both commands run with the repo directory as the working directory, through +# `bash -c`, so shell syntax is available. +# The run command is the rest of the line, so it may contain `|`; the setup +# command may not. +# +# Watch out. dev.sh signals the process it starts. Wrappers that do not forward +# signals leave the server running after Ctrl-C: `go run` is one, so build a +# binary and run that instead. Calling a package manager's `dev` script is +# usually fine, but calling the binary directly (`./node_modules/.bin/vite`) is +# one process less to orphan. + +SERVICES=( + "my-backend|go mod download|mkdir -p .dev && go build -o .dev/server ./cmd/server && exec ./.dev/server" + "my-frontend|pnpm install --config.confirmModulesPurge=false|./node_modules/.bin/vite" +)