Skip to content

feat(dev): self-contained dev container, and setup docs to match - #3039

Merged
cqnykamp merged 17 commits into
Doenet:mainfrom
cqnykamp:devcontainer
Sep 25, 2026
Merged

cqnykamp merged 17 commits into
Doenet:mainfrom
cqnykamp:devcontainer

Conversation

@cqnykamp

@cqnykamp cqnykamp commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

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 built apache and php services and mounted paths like ./public/api and ./docker/mysql that 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.

Service Purpose
dev Node 24.15.0 (matching .nvmrc), Chrome + Cypress runtime libs, the workspace
mysql MySQL 8.0, migrated and seeded by the post-create step
s3mock adobe/s3mock, so image upload works — the same image CI uses

Works with Codespaces, VS Code, the devcontainer CLI, or plain docker compose.

scripts/dc — the container as a local toolchain

The point of option 2 is "only Docker on the host", but the devcontainer CLI needs Node. scripts/dc drives docker-compose.yml directly instead, so the whole loop is:

./scripts/dc dev      # builds + seeds on first run, then npm run dev; prints the auto-login link
./scripts/dc shell    # a terminal inside
./scripts/dc claude   # Claude Code inside (sign in once; kept in a volume)
./scripts/dc <cmd>    # anything else, run inside

plus up, down, rebuild, reset, status. It reads the checkout's ports from apps/api/.env (worktrees get their offset automatically), runs post-create.sh once per container via a marker file, and uses the same <folder>_devcontainer project 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 see node_modules for 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 optional DEV_SERVER_HOST. It is unset on a normal checkout — host-based development stays on localhost exactly as before — and the container sets it to 0.0.0.0. Ports are published on the host's loopback only.

apps/api/.env is never rewritten. The database and media addresses are supplied as environment variables in the compose file. 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 left alone. That lets the same checkout be used on the host and in the container. dev-preflight.js now resolves the database host the same way instead of assuming 127.0.0.1; behaviour on the host is unchanged, since DATABASE_HOST is localhost there.

node_modules are named volumes, so the container's Linux-native installs never collide with the host's (which matters on macOS/arm64, given the platform-specific optionalDependencies in apps/api and apps/app). The list is limited to the workspaces npm actually populates, and initializeCommand pre-creates those mount points as the host user — Docker otherwise creates them as root, leaving directories in the checkout that a later host-side npm ci cannot write to.

Follow-ups from using it

  • Codespaces verified end to end (see the original caveat below, now resolved). Using it turned up four fixes:
    • s3mock is service_started, not service_healthy: the JVM can take minutes to answer on a 2-core machine, and gating container creation on it cost a failed codespace build.
    • The sshd feature is added so gh codespace ssh|logs|cp work; the slim Node base image has no SSH server.
    • Port forwarding opens the app (8000), not the API. The API and blog ports are labelled but silent.
    • In a codespace the browser is on *.app.github.dev, so post-create.sh writes apps/web/.env.local (blog links back to the app) and points APP_URL in apps/api/.env at the forwarded origin (magic-link sign-in and the npm run dev auto-login banner). The banner now builds its URL from APP_URL via worktree-env.js rather than hardcoding localhost.
  • Claude Code is installed in the image, with its config in a named volume so a login survives rebuilds, and ANTHROPIC_API_KEY passed 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: postCreateCommand keeps VS Code from connecting until the database is seeded, and a postAttachCommand then runs .devcontainer/start-dev.sh, which starts npm run dev in 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 what dc dev runs. 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.sh marks directories safe for git, sets gh as the credential helper, and rewrites SSH remotes to HTTPS. scripts/dc passes the host's gh auth token as GH_TOKEN per command, copies the host git identity on up, and on a linked worktree mounts the main repository's git directory so git can find it. VS Code gets GH_TOKEN from 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 real git push of the commit that added this.

The docs

Setup lived entirely in the README, offered one path, and pointed git clone at the old DoenetTools repository. Now split the conventional way — a short quick start in the README, detail in CONTRIBUTING.md, which GitHub also surfaces from issue and pull request pages — with the three environments presented easiest first:

  1. GitHub Codespaces — a browser, one click
  2. Dev container locally — Docker, one command
  3. The toolchain on your machine — Node 24 + Docker, full control

Each path now runs from git clone to a signed-in browser without leaving the page: a Signing in section explains the auto-login link npm run dev prints (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.md also 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 in AGENTS.md. .devcontainer/README.md keeps only what is genuinely container-specific.

Verification

Run inside the container, on a fresh clone:

  • npm test --workspace @doenet-tools/api — 408 passed, 30 files
  • npm test --workspace @doenet-tools/shared — 7 passed
  • Cypress e2e imageUpload.cy.ts — 2 passed, exercising browser → Vite → API → MySQL → s3mock
  • Cypress component ChatConversation.cy.tsx — 3 passed
  • npm run dev — api, app, and blog all serving; app and /blog reachable from a host browser
  • format:check, lint:check, build — clean

The full setup path was verified twice end to end via devcontainer up on 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 dev serves, 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 in addToCompoundActivity.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

cqnykamp and others added 2 commits August 24, 2026 16:41
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
@cqnykamp cqnykamp changed the title feat(dev): self-contained dev container feat(dev): self-contained dev container, and setup docs to match Aug 24, 2026
cqnykamp and others added 15 commits August 24, 2026 17:15
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>
@cqnykamp
cqnykamp merged commit deea2d9 into Doenet:main Sep 25, 2026
16 checks passed
@cqnykamp
cqnykamp deleted the devcontainer branch September 25, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant