Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 39 additions & 10 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions .devcontainer/mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion .devcontainer/scripts/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
45 changes: 40 additions & 5 deletions .devcontainer/scripts/lib/base-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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:-<unset>}"
"${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
}

Expand All @@ -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 ---
Expand All @@ -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 ---
Expand All @@ -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}"
}
59 changes: 59 additions & 0 deletions .devcontainer/scripts/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <seconds> <command...>}"
shift
if has_cmd timeout; then
timeout --foreground "${seconds}" "$@"
else
"$@"
fi
}

# Runs a command with sudo if available, otherwise without.
#
# Arguments:
Expand Down
10 changes: 9 additions & 1 deletion .devcontainer/scripts/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down