Skip to content

fix(devcontainer): honour the documented install switches, stream setup output, and bound every install - #62

Merged
amiralis1365 merged 4 commits into
mainfrom
fix/devcontainer-cold-build
Aug 25, 2026
Merged

fix(devcontainer): honour the documented install switches, stream setup output, and bound every install#62
amiralis1365 merged 4 commits into
mainfrom
fix/devcontainer-cold-build

Conversation

@amiralis1365

Copy link
Copy Markdown
Collaborator

What changes

Nothing about the document contract. This is .devcontainer/ only — it closes
the six cold-build defects catalogued in #59, one commit each for the four
distinct causes: the host-side CRLF strip was written in GNU sed and failed
silently on every macOS host; postCreateCommand was the object form, which
runs its entries in parallel and buffers their output, so the strip raced the
setup script and a cold install printed nothing for minutes; MUSHER_INSTALL_*
and MUSHER_LOG_LEVEL were documented in .env.example and read by no script,
while containerEnv pinned two of them so the documented .env opt-out could
not have won anyway; and every network install ran unbounded, with
base_verify_tools aborting post-create.sh under set -e so a single missing
CLI also cost the container its git hooks and its bun install.

Why

Closes #59. The dev container is the supported environment — CONTRIBUTING.md
sends every contributor to Reopen in Container and offers nothing else — so
these are defects in the one path a new contributor is guaranteed to take. No
ADR: nothing structural changes.

Compatibility

  • No schema change (docs, tooling, or CI only)

Checklist

  • task check passes locally
  • Commit messages are Conventional and correctly scoped (the scope drives release-please)
  • Commits are DCO signed off (git commit -s)
  • n/a schemas/dist/ — no schema touched
  • n/a Conformance fixtures — no behavioural change to a document contract
  • n/a Normative prose — no spec.md affected

Notes for the reviewer

Two changes are worth a second look because they are trade-offs rather than
straight fixes, and both are commented in place:

  • waitFor moves to onCreateCommand. Provisioning no longer gates the VS
    Code connection. post-create.sh still runs to completion and still reports
    failure through its exit code. The cost: a terminal opened in the first couple
    of minutes will not have task, lefthook or claude on PATH yet.
    remoteEnv already points PATH at the mise shim directory, so they appear
    in the next shell — no reload, no reconnect.
  • Codex now defaults OFF, Claude Code defaults ON. Codex is an npm package
    carrying a platform binary and it dominates the cold install; Claude Code is
    this repository's harness. Codex leaves the resolved set via
    MISE_DISABLE_TOOLS rather than by editing mise.toml, so the pin stays
    recorded and re-enabling it is a one-line change to a known version. Anyone
    who wants it back sets MUSHER_INSTALL_CODEX=1 in .devcontainer/.env.

Verification was also narrowed to the tools this container was asked to
install — reporting a missing codex to the developer who switched it off is
reporting a failure they requested.

Testing

task check passes locally (exit 0), including check:shell, which
ShellChecks exactly the files this touches. Worth stating plainly: no check in
this repository covers the lifecycle configuration, and the failures live on
host families and cold paths CI never takes — which is why none of these six
were caught. The end-to-end confirmation is a rebuild without cache, ideally
one from a macOS host for the perl change.

🤖 Generated with Claude Code

amiralis1365 and others added 4 commits August 21, 2026 17:39
…host

Two host-side strips used `sed -i 's/\r$//' FILE`. That is a GNU spelling.
BSD sed -- every macOS host -- reads the argument after `-i` as the backup
suffix, so it consumes the expression and then treats the file as the script.
It exits non-zero.

Inline in `initializeCommand`, `2>/dev/null` swallowed the error, so the strip
silently never happened. In `strip_crlf()`, `set -e` propagated it and would
have taken the whole `initializeCommand` down with it -- the bootstrap failing
before the container is even created.

Either way this is the CRLF guard failing on hosts it exists to protect: a
Windows checkout opened through a macOS host is exactly the case that produces
CRLF and exactly the case where the strip did not run.

`perl -i -pe` means the same thing on both host families, and perl is present
on macOS and on every Linux distribution carrying git.

Refs #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ali S <amirali.sadrzadeh@gmail.com>
…e connection on it

`postCreateCommand` was the object form, with two named entries: a CRLF strip
and the setup script. That shape reads as "do this, then that". It is not what
it does.

An object-form lifecycle command runs its entries in PARALLEL, and buffers each
entry's output until that entry exits. So the pair did neither of the two things
it looked like it did. The strip raced post-create.sh instead of preceding it --
both started in the same millisecond, and whether the scripts were stripped
before they were sourced was a coin toss no one had noticed winning. And the
setup entry printed nothing for the several minutes a cold tool install takes.

Minutes of total silence from a container that is provisioning normally is
indistinguishable from a deadlock. It sent someone looking for one that was not
there, which is what makes this a defect rather than a preference: the output
was being withheld precisely when it was the only evidence available.

One string joined with `&&` fixes both halves. The ordering is now real, and a
string command streams line by line as it runs.

`waitFor` moves to `onCreateCommand` for the other half of the same complaint.
post-create still runs to completion and still reports failure; it no longer
holds the VS Code connection while it does. The trade-off is stated in the file
rather than left for someone to discover: a terminal opened in the first couple
of minutes will not have `task`, `lefthook` or `claude` on PATH yet. `remoteEnv`
already points PATH at the mise shim directory, so they appear in the next
shell -- no reload, no reconnect.

Refs #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ali S <amirali.sadrzadeh@gmail.com>
`.env.example` offered MUSHER_INSTALL_CLAUDE and MUSHER_INSTALL_CODEX as the
way to "skip the AI CLI installs on a slow connection", and devcontainer.json
declared both in containerEnv. Nothing read either one:

    $ git grep -n MUSHER_INSTALL -- .devcontainer/
    .env.example:29:# MUSHER_INSTALL_CLAUDE=0
    .env.example:30:# MUSHER_INSTALL_CODEX=0
    devcontainer.json:64:    "MUSHER_INSTALL_CLAUDE": "1",
    devcontainer.json:65:    "MUSHER_INSTALL_CODEX": "1"

Four lines describing a behaviour, and no fifth line implementing it. Both CLIs
installed unconditionally, and they are most of the several minutes a cold
container spends in postCreateCommand -- so the one switch a developer on a slow
connection would reach for was the one that did nothing. MUSHER_LOG_LEVEL was
the same defect in miniature: documented in the same block, read by nothing.

`install_wanted()` reads the switch and `debug()` implements the log level.
Claude Code defaults ON as this repository's harness; Codex defaults OFF,
because it is an npm package carrying a platform binary and no one should pay
for a tool they do not use on every rebuild. Codex leaves the resolved set via
MISE_DISABLE_TOOLS rather than by editing mise.toml, so the pin stays recorded
and enabling it stays a one-line change to a known version.

The pins come out of containerEnv, and this is the half that would otherwise
have made the fix cosmetic. containerEnv becomes `docker run -e`, and an
explicit -e outranks --env-file for the same name whichever order they appear
in. Pinned to "1" there, they would have overridden the .devcontainer/.env that
.env.example tells developers to edit -- two places claiming to decide this, and
the documented one losing. Defaulting in the scripts leaves .env as the single
place that decides, and a comment says so where the pins used to be.

Verification follows the same rule: it now checks the tools this container was
asked to install, because reporting a missing `codex` to the developer who
switched it off is reporting a failure they asked for.

Refs #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ali S <amirali.sadrzadeh@gmail.com>
…r a missing tool

Two ways a cold build ended badly, neither of them the fault of the thing that
went wrong.

Every network install ran unbounded -- `curl | sh` for mise, `mise install`,
`curl | bash` for Claude Code. A stalled connection parks postCreateCommand for
as long as the kernel keeps the socket open, which is effectively forever, and
the developer has no output to tell them apart from a container that is simply
slow. `bounded()` wraps `timeout --foreground`, and the curls grow
`--connect-timeout 10 --max-time 120`. A stall now fails, retries, and
eventually reports -- "hangs forever" becomes "failed, and you can still work".
The ceilings sit above the whole pipeline, not the fetch of the script:
install.sh downloads a platform binary of its own after it is fetched.

Separately, `base_verify_tools` ran last under `set -e`, so one missing CLI
ended post-create.sh right there -- skipping install_lefthook_hooks and
install_spec_tools, which is the git hooks and `bun install`. A container that
was missing one tool ended up missing its hooks and its dependencies too, and
the developer learned about the second failure later than the first. That is a
worse outcome than the tool being missing warranted.

Verification now reports through its return code. The repo-specific steps run,
the status is carried to post-create.sh's exit code, and a final line names the
condition rather than leaving the developer to scroll for a ✗. The container is
still reported as half-provisioned, because it is -- it is just half-provisioned
with working git hooks.

Refs #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ali S <amirali.sadrzadeh@gmail.com>
@amiralis1365
amiralis1365 merged commit decf19b into main Aug 25, 2026
9 checks passed
@amiralis1365
amiralis1365 deleted the fix/devcontainer-cold-build branch August 25, 2026 14:36
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.

The dev container's documented switches do nothing, and a cold rebuild is indistinguishable from a hang

2 participants