From aa71dce2b4a7c627848b40d993a7299d0265e1d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:46:22 +0000 Subject: [PATCH 1/2] Fix silent exit in personal packages layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apt_from_file` ended with `[[ ${#extrepo[@]} -gt 0 ]] && apt_extrepo …`. When a manifest has no `extrepo` lines the test is false, the `&&` short- circuits, and the function returns 1. As the last command in the function under `set -e`, that aborted the entire script right after the apt install output — with no error message, so `ok "personal packages installed"` and the chezmoi layer never ran. Replace the guards with `if` blocks in `apt_from_file`, and fix the same latent pattern in `layer_packages` for the macOS formulae/casks arrays. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014hnxvLH9J3K4PjhgPLyWb5 --- bootstrap | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bootstrap b/bootstrap index f86525f..d36e264 100755 --- a/bootstrap +++ b/bootstrap @@ -100,8 +100,10 @@ apt_from_file() { [[ -z "$line" ]] && continue if [[ "$line" == "extrepo "* ]]; then extrepo+=("${line#extrepo }"); else plain+=("$line"); fi done < "$file" - [[ ${#plain[@]} -gt 0 ]] && apt_packages "${plain[@]}" - [[ ${#extrepo[@]} -gt 0 ]] && apt_extrepo "${extrepo[@]}" + # Note: `test && cmd` returns 1 when the test is false, which would abort the + # whole script under `set -e` for a manifest with no extrepo lines. Use `if`. + if [[ ${#plain[@]} -gt 0 ]]; then apt_packages "${plain[@]}"; fi + if [[ ${#extrepo[@]} -gt 0 ]]; then apt_extrepo "${extrepo[@]}"; fi } # --- platform detection -------------------------------------------------------- @@ -156,8 +158,8 @@ layer_packages() { log "Layer: dev-core packages" if [[ $PLATFORM == macos ]]; then have brew || die "brew not found — enable the Homebrew layer (ENABLE_BREW=true)" - [[ ${#DEV_CORE_FORMULAE[@]} -gt 0 ]] && brew_formulae "${DEV_CORE_FORMULAE[@]}" - [[ ${#DEV_CORE_CASKS[@]} -gt 0 ]] && brew_casks "${DEV_CORE_CASKS[@]}" + if [[ ${#DEV_CORE_FORMULAE[@]} -gt 0 ]]; then brew_formulae "${DEV_CORE_FORMULAE[@]}"; fi + if [[ ${#DEV_CORE_CASKS[@]} -gt 0 ]]; then brew_casks "${DEV_CORE_CASKS[@]}"; fi else apt_packages "${DEV_CORE_APT[@]}" apt_extrepo "${DEV_CORE_EXTREPO[@]}" From 7328f74f4c123fce5309174f350b8d6fbad43fa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:52:49 +0000 Subject: [PATCH 2/2] Make bootstrap failures visible instead of silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The personal-packages halt was one instance of a general problem: this script could stop or skip work without saying anything. Address the class. - ERR trap reports the aborting line number. The original bug produced no output at all; it would have been a one-line diagnosis with this. - Download-then-run the Homebrew and chezmoi installers. `bash -c "$(curl …)"` discards curl's exit status, so a failed download ran an empty script and reported success — "dotfiles applied" with no chezmoi installed. - fetch() names the URL when a manifest download fails, instead of curl exiting silently under set -e. - validate_config() runs before any layer, so an unedited MANUAL EDIT section fails in seconds rather than after a full package install. - Verify brew is actually present before eval-ing its shellenv. Also: - Register temp files and remove them from an EXIT trap, so an aborted layer no longer leaks them. - Run `apt-get update` once and re-arm it only when extrepo changes the sources: 4 updates down to 2 on a full Debian run. - Use `id -un` rather than $USER, which is not exported by every shell and would crash under `set -u`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014hnxvLH9J3K4PjhgPLyWb5 --- bootstrap | 106 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/bootstrap b/bootstrap index d36e264..8f56340 100755 --- a/bootstrap +++ b/bootstrap @@ -8,7 +8,7 @@ # chmod +x ./bootstrap && ./bootstrap # -set -euo pipefail +set -Eeuo pipefail # ============================================================================== # ✍ MANUAL EDIT — pick your layers @@ -58,10 +58,41 @@ die() { printf '\n\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } is_enabled() { local v="ENABLE_$1"; [[ "${!v}" == true ]]; } +# --- failure + cleanup handling ------------------------------------------------ +# Without this, any command failing under `set -e` ends the run with no output at +# all — the script just stops mid-layer and looks like it succeeded. Name the line. +on_err() { + local code=$? line=$1 + printf '\n\033[1;31mERROR:\033[0m bootstrap aborted at line %s (exit %s) — the last command above failed\n' \ + "$line" "$code" >&2 + exit "$code" +} +trap 'on_err $LINENO' ERR + +# Temp files are registered here so they are removed even when a layer aborts. +TMPFILES=() +cleanup() { [[ ${#TMPFILES[@]} -gt 0 ]] && rm -f "${TMPFILES[@]}"; return 0; } +trap cleanup EXIT +# Sets $TMPFILE to a fresh temp path and registers it for cleanup. It assigns a +# global rather than printing, because `x="$(mktemp_tracked)"` would run it in a +# subshell and the registration would be lost with it. +TMPFILE="" +mktemp_tracked() { TMPFILE="$(mktemp)"; TMPFILES+=("$TMPFILE"); } + +# The invoking user. $USER is not exported by every shell, and `set -u` would +# turn that into an unbound-variable crash. +USER_NAME="$(id -un)" + # --- install primitives -------------------------------------------------------- brew_formulae() { [[ $# -gt 0 ]] || return 0; step "brew formulae ($#): $*"; brew install "$@"; } brew_casks() { [[ $# -gt 0 ]] || return 0; step "brew casks ($#): $*"; brew install --cask "$@"; } -apt_packages() { [[ $# -gt 0 ]] || return 0; step "apt packages ($#): $*"; sudo apt-get update; sudo apt-get install -y "$@"; } + +# `apt-get update` is slow and every install path wants it, so run it once and +# re-arm it only when something actually changes the sources (see apt_extrepo). +APT_STALE=true +apt_update() { [[ $APT_STALE == true ]] || return 0; step "apt-get update"; sudo apt-get update; APT_STALE=false; } +apt_mark_stale() { APT_STALE=true; } +apt_packages() { [[ $# -gt 0 ]] || return 0; step "apt packages ($#): $*"; apt_update; sudo apt-get install -y "$@"; } apt_extrepo() { # each arg: " [pkg...]" [[ $# -gt 0 ]] || return 0 step "third-party repos via extrepo ($#)" @@ -72,10 +103,11 @@ apt_extrepo() { # each arg: " [pkg. repo="${parts[0]}" step " extrepo enable $repo (provides: ${parts[*]:1})" sudo extrepo enable "$repo" + apt_mark_stale # new source — the package lists must be refetched repos+=("$repo") pkgs+=("${parts[@]:1}") done - sudo apt-get update + apt_update step "installing extrepo packages: ${pkgs[*]}" sudo apt-get install -y "${pkgs[@]}" # Some packages (e.g. 1password) add their own apt source in a post-install @@ -85,6 +117,7 @@ apt_extrepo() { # each arg: " [pkg. if [[ -e "/etc/apt/sources.list.d/$repo.list" ]]; then step " $repo ships its own apt source — disabling the extrepo entry" sudo extrepo disable "$repo" + apt_mark_stale fi done } @@ -106,6 +139,33 @@ apt_from_file() { if [[ ${#extrepo[@]} -gt 0 ]]; then apt_extrepo "${extrepo[@]}"; fi } +# Download to a file, turning curl's silent non-zero exit (a 404 on the manifest +# is the common one) into a message that says which URL was wrong. +fetch() { + local url="$1" dest="$2" + step "fetching $url" + curl -fsSL "$url" -o "$dest" \ + || die "could not fetch $url — check the URL is public and the file exists" +} + +# --- config validation --------------------------------------------------------- +# Run before any layer, so a forgotten MANUAL EDIT fails in seconds instead of +# after a full package install. +validate_config() { + local placeholder='' + if is_enabled CHEZMOI; then + [[ -n "$DOTFILES" ]] || die "DOTFILES is empty — set it in the MANUAL EDIT section" + [[ "$DOTFILES" != *"$placeholder"* ]] \ + || die "DOTFILES still holds the '$placeholder' placeholder — set it in the MANUAL EDIT section" + fi + if is_enabled PERSONAL; then + [[ -n "$PERSONAL_MANIFEST" ]] || die "PERSONAL_MANIFEST is empty — set it in the MANUAL EDIT section" + [[ "$PERSONAL_MANIFEST" != *"$placeholder"* ]] \ + || die "PERSONAL_MANIFEST still holds the '$placeholder' placeholder — set it in the MANUAL EDIT section" + fi + step "config looks good" +} + # --- platform detection -------------------------------------------------------- detect_platform() { local os; os="$(uname -s)" @@ -125,11 +185,17 @@ layer_brew() { if have brew; then step "brew already installed — skipping the installer" else - step "downloading and running the Homebrew installer (can take a few minutes)…" - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + # Download first, then run. `bash -c "$(curl …)"` throws away curl's exit + # status, so a failed download silently runs an empty script and "succeeds". + mktemp_tracked; local installer="$TMPFILE" + fetch https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh "$installer" + step "running the Homebrew installer (can take a few minutes)…" + /bin/bash "$installer" fi step "loading brew into this session (eval brew shellenv)" - eval "$(/opt/homebrew/bin/brew shellenv)" + local brew_bin; brew_bin="$(command -v brew || echo /opt/homebrew/bin/brew)" + [[ -x "$brew_bin" ]] || die "brew not found at $brew_bin — the Homebrew install did not complete" + eval "$("$brew_bin" shellenv)" ok "Homebrew ready" } @@ -138,11 +204,9 @@ layer_zsh() { is_enabled ZSH || { skip "zsh (ENABLE_ZSH=false)"; return; } [[ $PLATFORM == debian ]] || { skip "zsh (Debian only; macOS already ships zsh)"; return; } log "Layer: zsh" - step "installing zsh via apt" - sudo apt-get update - sudo apt-get install -y zsh + apt_packages zsh local zsh_path; zsh_path="$(command -v zsh)" - if [[ "$(getent passwd "$USER" | cut -d: -f7)" == "$zsh_path" ]]; then + if [[ "$(getent passwd "$USER_NAME" | cut -d: -f7)" == "$zsh_path" ]]; then step "zsh is already your default shell" else step "setting zsh as the default shell via chsh (may prompt for your password)" @@ -164,9 +228,9 @@ layer_packages() { apt_packages "${DEV_CORE_APT[@]}" apt_extrepo "${DEV_CORE_EXTREPO[@]}" # docker: let $USER run it without sudo (effective at next login) - if getent group docker >/dev/null 2>&1 && [[ " $(id -nG "$USER") " != *" docker "* ]]; then - step "adding $USER to the docker group (log out/in to take effect)" - sudo usermod -aG docker "$USER" + if getent group docker >/dev/null 2>&1 && [[ " $(id -nG "$USER_NAME") " != *" docker "* ]]; then + step "adding $USER_NAME to the docker group (log out/in to take effect)" + sudo usermod -aG docker "$USER_NAME" fi fi ok "dev-core packages installed" @@ -176,20 +240,16 @@ layer_packages() { layer_personal() { is_enabled PERSONAL || { skip "personal packages (ENABLE_PERSONAL=false)"; return; } log "Layer: personal packages" - [[ -n "$PERSONAL_MANIFEST" ]] || die "PERSONAL_MANIFEST is empty — set it in the MANUAL EDIT section" - local tmp; tmp="$(mktemp)" + mktemp_tracked; local tmp="$TMPFILE" # removed by the EXIT trap, even on abort if [[ $PLATFORM == macos ]]; then have brew || die "brew not found — enable the Homebrew layer (ENABLE_BREW=true)" - step "fetching Brewfile from $PERSONAL_MANIFEST/Brewfile" - curl -fsSL "$PERSONAL_MANIFEST/Brewfile" -o "$tmp" + fetch "$PERSONAL_MANIFEST/Brewfile" "$tmp" step "brew bundle (formulae + casks)" brew bundle --file "$tmp" else - step "fetching apt list from $PERSONAL_MANIFEST/apt.txt" - curl -fsSL "$PERSONAL_MANIFEST/apt.txt" -o "$tmp" + fetch "$PERSONAL_MANIFEST/apt.txt" "$tmp" apt_from_file "$tmp" fi - rm -f "$tmp" ok "personal packages installed" } @@ -197,9 +257,10 @@ layer_personal() { layer_chezmoi() { is_enabled CHEZMOI || { skip "chezmoi + dotfiles (ENABLE_CHEZMOI=false)"; return; } log "Layer: chezmoi + dotfiles" - [[ -n "$DOTFILES" ]] || die "DOTFILES is empty — set it in the MANUAL EDIT section" + mktemp_tracked; local installer="$TMPFILE" + fetch https://get.chezmoi.io "$installer" step "installing chezmoi to ~/.local/bin and applying dotfiles from '$DOTFILES'" - /bin/bash -c "$(curl -fsSL get.chezmoi.io)" -- -b "$HOME/.local/bin" init --apply "$DOTFILES" + /bin/bash "$installer" -- -b "$HOME/.local/bin" init --apply "$DOTFILES" ok "dotfiles applied" } @@ -218,6 +279,7 @@ next_steps() { main() { log "Plexus bootstrap — starting" step "layers: brew=$ENABLE_BREW zsh=$ENABLE_ZSH packages=$ENABLE_PACKAGES personal=$ENABLE_PERSONAL chezmoi=$ENABLE_CHEZMOI" + validate_config detect_platform layer_brew layer_zsh