From 301ba5f76862931d72a51f8fd98b8b76ba09f52b Mon Sep 17 00:00:00 2001 From: Ali S Date: Fri, 21 Aug 2026 17:39:39 +0000 Subject: [PATCH 1/4] fix(devcontainer): strip CRLF with perl, so the guard works on a BSD 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) Signed-off-by: Ali S --- .devcontainer/devcontainer.json | 6 +++++- .devcontainer/scripts/initialize.sh | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b1be113..1ee5202 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -72,10 +72,14 @@ // Host-side: creates .devcontainer/.env from the template and strips CRLF so // --env-file above has a valid target on a fresh clone. Run through `bash -c` // so CRLF in the script files cannot break the strip-and-run bootstrap. + // + // `perl -i`, not `sed -i`. This one runs on the host, and BSD sed — every + // macOS host — reads the next argument as the backup suffix, so the strip + // exited non-zero and never happened; `2>/dev/null` then hid that it hadn't. "initializeCommand": [ "bash", "-c", - "find .devcontainer/scripts -name '*.sh' -exec sed -i 's/\\r$//' {} + 2>/dev/null; bash .devcontainer/scripts/initialize.sh" + "find .devcontainer/scripts -name '*.sh' -exec perl -i -pe 's/\\r$//' {} + 2>/dev/null; bash .devcontainer/scripts/initialize.sh" ], "waitFor": "postCreateCommand", diff --git a/.devcontainer/scripts/initialize.sh b/.devcontainer/scripts/initialize.sh index 2b4b333..2529df2 100644 --- a/.devcontainer/scripts/initialize.sh +++ b/.devcontainer/scripts/initialize.sh @@ -39,11 +39,16 @@ ensure_env_file() { fi } +# `perl -i`, not `sed -i`: this runs on the HOST, and BSD sed — every macOS +# host — reads the next argument as the backup suffix, so `sed -i 's/\r$//' f` +# consumes the expression and then treats f as the script. It exits non-zero, +# which under `set -e` would take the whole initializeCommand down with it. +# perl -i means the same thing on both host families. strip_crlf() { [[ -f "${ENV_FILE}" ]] || return 0 if grep -q $'\r' "${ENV_FILE}" 2>/dev/null; then log "Stripping CRLF from .devcontainer/.env" - sed -i 's/\r$//' "${ENV_FILE}" + perl -i -pe 's/\r$//' "${ENV_FILE}" fi } From efc12e1800c09b7a4a6277ef6c881358118021d3 Mon Sep 17 00:00:00 2001 From: Ali S Date: Fri, 21 Aug 2026 17:40:04 +0000 Subject: [PATCH 2/4] fix(devcontainer): run post-create as one command, and stop gating the 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) Signed-off-by: Ali S --- .devcontainer/devcontainer.json | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1ee5202..de2266c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -82,11 +82,32 @@ "find .devcontainer/scripts -name '*.sh' -exec perl -i -pe 's/\\r$//' {} + 2>/dev/null; bash .devcontainer/scripts/initialize.sh" ], - "waitFor": "postCreateCommand", - "postCreateCommand": { - "fix-crlf": "find .devcontainer/scripts -type f -name '*.sh' -exec sed -i 's/\\r$//' {} +", - "setup": ["bash", ".devcontainer/scripts/post-create.sh"] - }, + // Connect as soon as the container exists, not when provisioning ends. + // + // postCreateCommand still runs to completion and still reports failure — it + // simply no longer gates the VS Code connection. That matters because the + // cold path is genuinely minutes long (the Claude Code installer dominates + // it), and waiting for it meant a developer stared at a connecting window + // with no output for the entire install. + // + // The trade-off, stated plainly: a terminal opened in the first couple of + // minutes will not have task, lefthook or claude on PATH yet. They appear as + // mise finishes — `remoteEnv` already points PATH at the shim directory, so + // no reload is needed, only a new shell. + "waitFor": "onCreateCommand", + + // ONE string, deliberately — not the object form this used to be. + // + // An object-form lifecycle command runs its entries in PARALLEL and buffers + // each entry's output until that entry exits. As two named entries, this pair + // therefore did neither of the things it looks like it does: the CRLF strip + // raced post-create.sh rather than preceding it (both started in the same + // millisecond), and the setup entry printed nothing at all for the several + // minutes the cold tool install takes — which is indistinguishable from a + // hung container, and is what sent a developer looking for a deadlock that + // was not there. `&&` restores the ordering, and a string command streams its + // output line by line while it runs. + "postCreateCommand": "find .devcontainer/scripts -type f -name '*.sh' -exec sed -i 's/\\r$//' {} + && bash .devcontainer/scripts/post-create.sh", "postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"], "customizations": { From d83f5816ec566031a13639c5dc12201cc672ee96 Mon Sep 17 00:00:00 2001 From: Ali S Date: Fri, 21 Aug 2026 17:41:01 +0000 Subject: [PATCH 3/4] fix(devcontainer): make the documented install switches real `.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) Signed-off-by: Ali S --- .devcontainer/.env.example | 11 ++++++-- .devcontainer/devcontainer.json | 12 ++++++--- .devcontainer/mise.toml | 4 +++ .devcontainer/scripts/lib/base-setup.sh | 25 ++++++++++++++++- .devcontainer/scripts/lib/common.sh | 36 +++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 09ae4c9..253f56a 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -25,6 +25,13 @@ # Uncomment to raise the log level of the setup scripts. # MUSHER_LOG_LEVEL=debug -# Skip the AI CLI installs on a slow connection. +# AI harnesses. Claude Code is the default and installs when nothing here is +# set; Codex is opt-in, because it is an npm package carrying a platform binary +# and it dominates the time a cold container spends in postCreateCommand. +# +# Set CLAUDE=0 to skip the install on a slow connection, CODEX=1 to add Codex. +# These belong here and nowhere else: devcontainer.json deliberately does not +# put them in containerEnv, because `docker run -e` outranks --env-file and +# would override whatever you write below. # MUSHER_INSTALL_CLAUDE=0 -# MUSHER_INSTALL_CODEX=0 +# MUSHER_INSTALL_CODEX=1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index de2266c..fe4d1d4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -59,10 +59,14 @@ "XDG_CACHE_HOME": "/home/vscode/.cache", "NPM_CONFIG_CACHE": "/home/vscode/.cache/npm", - "BUN_INSTALL_CACHE_DIR": "/home/vscode/.cache/bun", - - "MUSHER_INSTALL_CLAUDE": "1", - "MUSHER_INSTALL_CODEX": "1" + "BUN_INSTALL_CACHE_DIR": "/home/vscode/.cache/bun" + + // MUSHER_INSTALL_CLAUDE and MUSHER_INSTALL_CODEX are deliberately NOT set + // here. containerEnv becomes `docker run -e`, and an explicit -e beats + // --env-file for the same name whichever order they appear in — so pinning + // them to "1" here silently overrode the `.devcontainer/.env` opt-out that + // .env.example tells developers to use. The scripts default to on when the + // variable is unset, which leaves .env as the single place that decides. }, "remoteEnv": { diff --git a/.devcontainer/mise.toml b/.devcontainer/mise.toml index ba28470..6481a45 100644 --- a/.devcontainer/mise.toml +++ b/.devcontainer/mise.toml @@ -19,6 +19,10 @@ # Docs: https://mise.jdx.dev [tools] +# @openai/codex is pinned here but NOT installed by default: it is opt-in via +# MUSHER_INSTALL_CODEX=1 in .devcontainer/.env, which the setup scripts turn +# into MISE_DISABLE_TOOLS. The pin stays recorded so enabling it is a one-line +# change that still resolves to a known version. "actionlint" = "1.7.11" "task" = "3.52.0" "npm:@openai/codex" = "0.143.0" diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 4573143..6db998f 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -112,7 +112,20 @@ base_install_tools() { local mise mise="$(command -v mise || echo "${_MISE_BIN}")" local config="${MISE_GLOBAL_CONFIG_FILE:-${_LIB_DIR}/../../mise.toml}" + + # Claude Code is this repository's default harness; Codex is opt-IN. Codex is + # an npm package carrying a platform binary and it dominates the cold- + # container install, so a developer who does not use it should not pay for it + # on every rebuild. MISE_DISABLE_TOOLS drops it from the resolved set without + # editing the manifest, so the pin stays recorded either way and CI still + # sees it — set MUSHER_INSTALL_CODEX=1 in .devcontainer/.env to install it. + if ! install_wanted "${MUSHER_INSTALL_CODEX:-0}"; then + log "MUSHER_INSTALL_CODEX is off — leaving the Codex CLI out of this install" + export MISE_DISABLE_TOOLS="npm:@openai/codex${MISE_DISABLE_TOOLS:+,${MISE_DISABLE_TOOLS}}" + fi + log "Installing pinned CLIs from ${config}..." + debug "MISE_DISABLE_TOOLS=${MISE_DISABLE_TOOLS:-}" "${mise}" trust "${config}" >/dev/null 2>&1 || true retry 3 5 "${mise}" install "${mise}" reshim >/dev/null 2>&1 || true @@ -127,6 +140,10 @@ base_install_tools() { # Returns: # 0 on success, non-zero on failure base_install_claude() { + if ! install_wanted "${MUSHER_INSTALL_CLAUDE:-1}"; then + log "MUSHER_INSTALL_CLAUDE is off — skipping Claude Code" + return 0 + fi if has_cmd claude; then log "Claude Code already installed, skipping" return 0 @@ -149,7 +166,13 @@ base_install_claude() { # Returns: # 0 if all tools found, 1 if any are missing base_verify_tools() { - verify_tools gh task codex lefthook claude actionlint shellcheck + # Verify what this container was actually asked to install. Checking a CLI the + # developer deliberately switched off would report a self-inflicted failure. + local -a tools=(gh task lefthook actionlint shellcheck) + if install_wanted "${MUSHER_INSTALL_CODEX:-0}"; then tools+=(codex); fi + if install_wanted "${MUSHER_INSTALL_CLAUDE:-1}"; then tools+=(claude); fi + debug "verifying: ${tools[*]}" + verify_tools "${tools[@]}" } # --- Orchestrator --- diff --git a/.devcontainer/scripts/lib/common.sh b/.devcontainer/scripts/lib/common.sh index e6bb13c..5f264c2 100644 --- a/.devcontainer/scripts/lib/common.sh +++ b/.devcontainer/scripts/lib/common.sh @@ -20,6 +20,23 @@ log() { echo "[$(date '+%H:%M:%S')] $*" >&2 } +# Logs a message only when MUSHER_LOG_LEVEL=debug. +# +# .env.example advertises MUSHER_LOG_LEVEL as the way to raise the setup +# scripts' log level. Nothing read it, so the switch was documentation for a +# feature that did not exist. This is that feature. +# +# Globals: +# MUSHER_LOG_LEVEL — read, defaults to "info" +# Arguments: +# $@ — message text +# Outputs: +# Writes to stderr via log() when debug is on, nothing otherwise +debug() { + [[ "${MUSHER_LOG_LEVEL:-info}" == "debug" ]] || return 0 + log "DEBUG: $*" +} + # --- Command helpers --- # Checks whether a command exists on the PATH. @@ -32,6 +49,25 @@ has_cmd() { command -v "$1" &>/dev/null } +# Reports whether an install guarded by a MUSHER_INSTALL_* switch is wanted. +# +# devcontainer.json declares MUSHER_INSTALL_CLAUDE and MUSHER_INSTALL_CODEX and +# .env.example documents them as the way to "skip the AI CLI installs on a slow +# connection". No script read either one, so both CLIs installed unconditionally +# — and they are most of the several minutes a cold container spends inside +# postCreateCommand. This is what makes the documented switch real. +# +# Arguments: +# $1 — the switch's value, e.g. "${MUSHER_INSTALL_CLAUDE:-1}" +# Returns: +# 0 when the install is wanted, 1 when it is switched off +install_wanted() { + case "${1:-1}" in + 0 | false | no | off) return 1 ;; + *) return 0 ;; + esac +} + # Runs a command with sudo if available, otherwise without. # # Arguments: From 2eac10899775843e5cda46ca9a01c5997c7adc98 Mon Sep 17 00:00:00 2001 From: Ali S Date: Fri, 21 Aug 2026 17:42:23 +0000 Subject: [PATCH 4/4] fix(devcontainer): bound every network install, and finish setup after a missing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Ali S --- .devcontainer/scripts/lib/base-setup.sh | 20 ++++++++++++++++---- .devcontainer/scripts/lib/common.sh | 23 +++++++++++++++++++++++ .devcontainer/scripts/post-create.sh | 10 +++++++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 6db998f..da9d1a3 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -95,7 +95,8 @@ base_install_mise() { return 0 fi log "Installing mise (https://mise.run)..." - retry 3 5 bash -c 'curl -fsSL https://mise.run | sh' + retry 3 5 bounded 300 bash -c \ + 'curl -fsSL --connect-timeout 10 --max-time 120 https://mise.run | sh' } # Installs the CLIs pinned in .devcontainer/mise.toml (tools with no Feature), @@ -127,7 +128,7 @@ base_install_tools() { log "Installing pinned CLIs from ${config}..." debug "MISE_DISABLE_TOOLS=${MISE_DISABLE_TOOLS:-}" "${mise}" trust "${config}" >/dev/null 2>&1 || true - retry 3 5 "${mise}" install + retry 3 5 bounded 900 "${mise}" install "${mise}" reshim >/dev/null 2>&1 || true } @@ -149,7 +150,10 @@ base_install_claude() { return 0 fi log "Installing Claude Code (native installer)..." - retry 3 5 bash -c 'curl -fsSL https://claude.ai/install.sh | bash' + # install.sh itself downloads a platform binary of its own, so the ceiling has + # to cover the whole pipeline rather than just the fetch of the script. + retry 3 5 bounded 900 bash -c \ + 'curl -fsSL --connect-timeout 10 --max-time 120 https://claude.ai/install.sh | bash' } # --- Verify --- @@ -190,6 +194,14 @@ base_setup() { base_install_mise base_install_tools base_install_claude - base_verify_tools + + # Verification reports; it no longer aborts. `set -e` used to let one missing + # CLI end post-create.sh right here, which skipped the repo-specific setup + # that follows it — git hooks and `bun install` — and left the container in a + # worse state than the single missing tool warranted. The status rides out on + # the return code instead, so the failure is still loud. + local verified=0 + base_verify_tools || verified=$? log "Base setup complete" + return "${verified}" } diff --git a/.devcontainer/scripts/lib/common.sh b/.devcontainer/scripts/lib/common.sh index 5f264c2..cb77be0 100644 --- a/.devcontainer/scripts/lib/common.sh +++ b/.devcontainer/scripts/lib/common.sh @@ -68,6 +68,29 @@ install_wanted() { esac } +# Runs a command under a wall-clock ceiling, when `timeout` is available. +# +# Every network install below used to run unbounded. A stalled connection parks +# postCreateCommand for as long as the kernel keeps the socket, and a lifecycle +# command shows no output until it exits, so an unbounded install is +# indistinguishable from a hung container. A ceiling turns "hangs forever" into +# "fails, and the container is still usable". +# +# Arguments: +# $1 — ceiling in seconds +# $@ — command and arguments to execute +# Returns: +# The command's status, or 124 if the ceiling was reached +bounded() { + local seconds="${1:?usage: bounded }" + shift + if has_cmd timeout; then + timeout --foreground "${seconds}" "$@" + else + "$@" + fi +} + # Runs a command with sudo if available, otherwise without. # # Arguments: diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index 93faf24..d6fdec9 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -67,10 +67,18 @@ install_spec_tools() { # Writes progress to stderr via log() main() { log "Starting post-create setup..." - base_setup + # base_setup reports a missing tool through its status rather than aborting, + # so the repo-specific steps below still run. Carry the status to the exit + # code so a half-provisioned container is still reported as one. + local status=0 + base_setup || status=$? install_lefthook_hooks # --- Repo-specific setup --- install_spec_tools + if ((status != 0)); then + log "Post-create setup completed with MISSING TOOLS (see the ✗ lines above)" + return "${status}" + fi log "Post-create setup completed" }