feat(dev): self-contained dev container, and setup docs to match - #3039
Merged
Merged
Conversation
The existing .devcontainer was left over from the PHP/Apache era: it built apache and php services and mounted paths like ./public/api that no longer exist, so it could not start. Replace it with one that matches the current monorepo — Node pinned to .nvmrc, MySQL, an S3 mock, and Chrome, so the API, both frontends, and all three test suites run without anything on the host except Docker. The dev servers are published on the host's loopback and Vite/Astro bind to all interfaces via DEV_SERVER_HOST, so the app is reachable in a browser without an editor's port forwarding. DEV_SERVER_HOST is unset on a normal checkout, which leaves host-based development on localhost as before. Database and media addresses are supplied as environment variables rather than written into apps/api/.env. Both dotenv and the Prisma CLI leave already-set variables alone, so the container's values win while the checkout's .env — shared with the host through the bind mount — is never rewritten. Consequently dev-preflight now resolves the database host the same way, instead of assuming 127.0.0.1. node_modules directories are named volumes so the container's Linux-native installs never collide with the host's. The list is limited to the workspaces that npm actually populates, and initializeCommand pre-creates those mount points as the host user: Docker would otherwise create them as root, leaving directories in the checkout that a host-side npm ci cannot write to. Verified in the container: 408 api tests, 7 shared tests, Cypress e2e (including the s3mock image-upload path) and component specs, plus lint, format:check, and a full build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
Setup lived entirely in the README, offered one path, and pointed `git clone` at the old DoenetTools repository. Follow the usual split instead: a short quick start in the README, the details in CONTRIBUTING.md, which GitHub also surfaces from issue and pull request pages. Both present the three environments easiest first — Codespaces, then the dev container, then installing the toolchain — so a newcomer needs a browser to get started and reads about Node and MySQL only if they want them. CONTRIBUTING.md gathers what a contributor needs beyond that first command: what runs on which port, how to run each test suite, the format and lint pass CI expects, and the fork and expand-migrate-contract conventions previously documented only in AGENTS.md. .devcontainer/README.md drops its getting-started half and keeps what is genuinely container-specific. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
Creating a codespace failed: on a 2-core machine s3mock, which is a JVM service, took longer than the healthcheck budget of 100s to answer, so the dev service's depends_on condition was never met and Codespaces fell back to a recovery container. MySQL was healthy in 27s; only s3mock timed out. Nothing needs s3mock until the first image upload, minutes after the container is created, so depend on it having started rather than being healthy. Both healthchecks also get a start_period and more retries, so a slow start on a small machine reports as starting rather than unhealthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
The image is based on a slim Node image, which has no SSH server, so `gh codespace ssh`, `logs`, and `cp` all fail against a codespace built from it. Add the sshd dev container feature, which is the documented remedy. No effect on local use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
Adds the CLI to the image and the VS Code extension to the recommended set, so `claude` is available in any terminal in the container. Credentials and settings live in a named volume, with CLAUDE_CONFIG_DIR pointing at it so the config file lands there too rather than in ~/.claude.json outside the volume. A login therefore survives a container rebuild — verified by recreating the dev service and confirming the directory's contents remain. ANTHROPIC_API_KEY is passed through from the host when set; Codespaces exposes a secret of that name automatically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
Opening a forwarded port in a codespace could land on the API's debug root route, which renders "Express + TypeScript Serverundefined" — a confusing first impression when the app is what you want. The app proxies /api to the API and /blog to Astro, so it is the only port worth opening. Auto-open it, keep the other two labelled but silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
Navigating from a blog page back to the app in a codespace went to localhost:8000, which is nothing in a browser pointed at *.app.github.dev. The blog builds absolute links from PUBLIC_APP_URL, and apps/web/.env ships the local default. Detect Codespaces in post-create and write apps/web/.env.local with the forwarded URLs. Vite and Astro load .env.local ahead of .env, the same mechanism `npm run setup` already uses to override these for worktrees. CODESPACES is in the container environment, but CODESPACE_NAME and the forwarding domain are only exported to login shells, so they are read from the file Codespaces writes rather than from the environment. Verified in a live codespace: the rendered header logo links to the forwarded app origin, and no localhost URLs remain in the blog HTML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
In a codespace, both the magic-link email and the auto-login banner printed by `npm run dev` pointed at http://localhost:8000, which a browser on *.app.github.dev cannot follow — clicking sign-in produced an unusable link. The API already builds its links from APP_URL, so post-create now points that at the forwarded origin alongside the blog's PUBLIC_APP_URL. The dev banner hardcoded localhost instead, so it now reads the same value through a new appUrl export, which falls back to localhost on the current port exactly as before for a normal checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSJtzuUZ75ikxdZHXFPwu5
The "How it is wired" section said apps/api/.env is never rewritten, which stopped being true once post-create.sh started pointing APP_URL at the forwarded origin in a codespace. Note the exception and why it is safe there. Also put the comments in scripts/worktree-env.js next to the exports they describe; the two blocks had ended up stacked above the wrong lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Written by the devcontainer CLI on the first `up` after the sshd feature was added. Committing it pins the feature to a resolved digest, so every build (Codespaces included) gets the same version rather than whatever the tag points at that day, which is what the spec recommends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each of the three setup paths is now complete on its own: the dev container path was missing the clone and the "then run npm run dev" step, and none of them said how to sign in once the app was up. Add a Signing in section around the auto-login link that `npm run dev` prints, note that magic links are written to the API log in development, say how to get a shell in the container and how long the first build takes, and add a short troubleshooting list for the failures a newcomer actually hits (MySQL stopped, port in use, stale container, stale shared build). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The container was meant to need only Docker on the host, but the documented way to use it went through `npx @devcontainers/cli`, which needs Node — and every action was a long command with ports to pass by hand on a worktree. `scripts/dc` makes the container feel like a local toolchain: `dc dev`, `dc shell`, `dc claude`, or `dc <any command>` run inside it, building and seeding the container on first use. It drives docker-compose.yml directly, so Docker really is the only requirement; reads this checkout's ports from apps/api/.env; runs post-create.sh once per container via a marker file; and uses the `<folder>_devcontainer` project name VS Code and the CLI use, so it attaches to a container started by either. `up`, `down`, `rebuild`, `reset`, `status` manage the stack. The docs now lead option 2 with it, say how editing files from the host works (and where a host editor falls short), and drop the pass-the-ports step. Verified on this worktree: `dc up` from a stopped stack (post-create ran once, 72s with warm volumes; 1.5s to re-up), `dc npm test` passthrough, a TTY for interactive commands, and `dc dev` serving the app and blog on the offset ports with the auto-login banner. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PID 1 was `sleep infinity`, which never reaps children, so stopping `npm run dev` with a signal left concurrently, vite, and esbuild behind as zombies and a stray dev server still bound to the app port. `init: true` puts docker-init in front, and the leftovers are reaped as they should be. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opening the folder in VS Code now recommends the Dev Containers extension, so the "Reopen in Container" prompt appears for a newcomer. The container waits for post-create.sh before VS Code connects (`waitFor`), so the first thing seen is a seeded, working environment, and a `postAttachCommand` starts the dev servers in a terminal on every attach. That runs through the new .devcontainer/start-dev.sh, which is a no-op when the API port is already answering, so a window reload never starts a second copy on the same ports. The same script is the default build task in .vscode/tasks.json (Ctrl/Cmd+Shift+B restarts the servers) and what `dc dev` runs, so every entry point behaves the same. tasks.json is un-ignored for that. Verified in the running container: the script starts the servers and prints the auto-login banner on first run, and on a second run reports they are already up and exits 0; devcontainer.json parses with the new keys. The VS Code attach flow itself is configured per the spec but not exercised here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An agent (or a person) in the container could not commit or open a pull request: there was no `gh`, no git identity, no GitHub credentials, and on a linked worktree git could not even find its repository. - The image installs `gh`, with its config in a volume so `gh auth login` inside survives rebuilds. - post-create.sh marks all directories safe for git, points git's credential helper at `gh`, and rewrites SSH remotes to HTTPS, since the host's keys are not in the container. - scripts/dc passes the host's `gh auth token` as GH_TOKEN on every exec (so nothing is baked into the container), copies the host git identity on `up`, and, on a linked worktree, mounts the main repository's git directory at the same path via a generated compose override so git works. - devcontainer.json passes GH_TOKEN through from the host environment for VS Code; Codespaces provides its own token. Verified on this worktree from inside the container: git status/log resolve the linked repository, commits carry the host identity (and run the husky hook), `gh auth status` and `gh pr list` work with the passed token, the credential helper returns a GitHub token for HTTPS, and `git push --dry-run` authenticated against the remote. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A working dev container, a reorganization of the setup instructions around it, and a handful of follow-up fixes from actually using it in a codespace.
The dev container
The existing
.devcontainer/was left over from the PHP/Apache era of DoenetTools — it builtapacheandphpservices and mounted paths like./public/apiand./docker/mysqlthat no longer exist, so it could not start at all. Replaced with one that matches the current monorepo.Everything needed to run and test the repo lives in the container; the only host requirement is Docker.
dev.nvmrc), Chrome + Cypress runtime libs, the workspacemysqls3mockadobe/s3mock, so image upload works — the same image CI usesWorks with Codespaces, VS Code, the
devcontainerCLI, or plaindocker compose.scripts/dc— the container as a local toolchainThe point of option 2 is "only Docker on the host", but the
devcontainerCLI needs Node.scripts/dcdrivesdocker-compose.ymldirectly instead, so the whole loop is:plus
up,down,rebuild,reset,status. It reads the checkout's ports fromapps/api/.env(worktrees get their offset automatically), runspost-create.shonce per container via a marker file, and uses the same<folder>_devcontainerproject name as VS Code and the CLI, so it attaches to a container started by either. Files are edited on the host as usual; the docs spell out the one gap (a host editor cannot seenode_modulesfor type hints) and point at Reopen in Container for that.Notable decisions
The app is browsable without an editor. Vite and Astro bind to
127.0.0.1, which Docker's published ports cannot reach, so both configs now read an optionalDEV_SERVER_HOST. It is unset on a normal checkout — host-based development stays on localhost exactly as before — and the container sets it to0.0.0.0. Ports are published on the host's loopback only.apps/api/.envis never rewritten. The database and media addresses are supplied as environment variables in the compose file. Bothdotenvand the Prisma CLI leave already-set variables alone, so the container's values win while the checkout's.env— shared with the host through the bind mount — is left alone. That lets the same checkout be used on the host and in the container.dev-preflight.jsnow resolves the database host the same way instead of assuming127.0.0.1; behaviour on the host is unchanged, sinceDATABASE_HOSTislocalhostthere.node_modulesare named volumes, so the container's Linux-native installs never collide with the host's (which matters on macOS/arm64, given the platform-specificoptionalDependenciesinapps/apiandapps/app). The list is limited to the workspaces npm actually populates, andinitializeCommandpre-creates those mount points as the host user — Docker otherwise creates them as root, leaving directories in the checkout that a later host-sidenpm cicannot write to.Follow-ups from using it
s3mockisservice_started, notservice_healthy: the JVM can take minutes to answer on a 2-core machine, and gating container creation on it cost a failed codespace build.sshdfeature is added sogh codespace ssh|logs|cpwork; the slim Node base image has no SSH server.*.app.github.dev, sopost-create.shwritesapps/web/.env.local(blog links back to the app) and pointsAPP_URLinapps/api/.envat the forwarded origin (magic-link sign-in and thenpm run devauto-login banner). The banner now builds its URL fromAPP_URLviaworktree-env.jsrather than hardcoding localhost.ANTHROPIC_API_KEYpassed through from the host or a Codespaces secret.VS Code: one click to a running app
Opening the folder recommends the Dev Containers extension, so the Reopen in Container prompt appears.
waitFor: postCreateCommandkeeps VS Code from connecting until the database is seeded, and apostAttachCommandthen runs.devcontainer/start-dev.sh, which startsnpm run devin a terminal unless the API port is already answering (so a window reload does not start a second copy). The same script is the default build task (Ctrl/Cmd+Shift+B) and whatdc devruns. The Claude Code extension is in the container's extension list.Git and GitHub from inside
An agent in the container can commit, push, and open PRs. The image installs
gh(config in a volume);post-create.shmarks directories safe for git, setsghas the credential helper, and rewrites SSH remotes to HTTPS.scripts/dcpasses the host'sgh auth tokenasGH_TOKENper command, copies the host git identity onup, and on a linked worktree mounts the main repository's git directory so git can find it. VS Code getsGH_TOKENfrom the host environment; Codespaces has its own token. Verified from inside the container on a linked worktree: commit with the host identity (husky hook ran),gh pr list, credential fill, and a realgit pushof the commit that added this.The docs
Setup lived entirely in the README, offered one path, and pointed
git cloneat the oldDoenetToolsrepository. Now split the conventional way — a short quick start in the README, detail inCONTRIBUTING.md, which GitHub also surfaces from issue and pull request pages — with the three environments presented easiest first:Each path now runs from
git cloneto a signed-in browser without leaving the page: a Signing in section explains the auto-login linknpm run devprints (and that magic links land in the API log in development), the container path says how to get a shell and how long the first build takes, and a short Troubleshooting list covers the failures a newcomer actually hits.CONTRIBUTING.mdalso gathers what a contributor needs past that first command: what runs on which port, how to run each test suite, the format/lint pass CI expects, and the fork and expand-migrate-contract conventions that were previously documented only inAGENTS.md..devcontainer/README.mdkeeps only what is genuinely container-specific.Verification
Run inside the container, on a fresh clone:
npm test --workspace @doenet-tools/api— 408 passed, 30 filesnpm test --workspace @doenet-tools/shared— 7 passedimageUpload.cy.ts— 2 passed, exercising browser → Vite → API → MySQL → s3mockChatConversation.cy.tsx— 3 passednpm run dev— api, app, and blog all serving; app and/blogreachable from a host browserformat:check,lint:check,build— cleanThe full setup path was verified twice end to end via
devcontainer upon fresh clones, and every internal doc link and anchor was checked.Note for reviewers
The Codespaces path is the one thing not verified end to end.Since verified in a live codespace: it builds,npm run devserves, and the app and the blog's links work from the forwarded host. The fixes that took are listed above. Two things have not been exercised end to end yet: the sign-in-link fix (APP_URL) in a fresh codespace, and the VS Code attach flow (waitFor+postAttachCommand), which is configured per the spec and whose script is verified in the container but has not been driven from VS Code itself.The
e2e-tests (group2)failure is the pre-existing race inaddToCompoundActivity.cy.ts(the problem-set title is read back as "Untitled Problem Set" before the rename lands). It fails the same way on other branches and nothing here touches that code.Only three suites were run as single specs rather than in full — enough to prove Chrome, the s3mock upload path, and both Cypress modes work in the container, without the full suite runtime. CI covers the rest.
On arm64 hosts Google ships no Chrome build, so Chromium is installed instead and Cypress needs
-b chromium; the package scripts hardcode-b chrome. Documented rather than changed, since changing them would affect CI.🤖 Generated with Claude Code