initial commit
This commit is contained in:
41
.devcontainer/Dockerfile
Normal file
41
.devcontainer/Dockerfile
Normal file
@@ -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
|
||||||
50
.devcontainer/devcontainer.json
Normal file
50
.devcontainer/devcontainer.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
.devcontainer/post-create.sh
Executable file
28
.devcontainer/post-create.sh
Executable file
@@ -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
|
||||||
87
README.md
Normal file
87
README.md
Normal file
@@ -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.
|
||||||
32
dev.sh
Executable file
32
dev.sh
Executable file
@@ -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
|
||||||
22
stack.code-workspace
Normal file
22
stack.code-workspace
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
stack.conf
Normal file
25
stack.conf
Normal file
@@ -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/<directory> (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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user