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 b1be113..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": { @@ -72,17 +76,42 @@ // 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", - "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": { 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/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 } diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 4573143..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), @@ -112,9 +113,22 @@ 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 + retry 3 5 bounded 900 "${mise}" install "${mise}" reshim >/dev/null 2>&1 || true } @@ -127,12 +141,19 @@ 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 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 --- @@ -149,7 +170,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 --- @@ -167,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 e6bb13c..cb77be0 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,48 @@ 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 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" }