diff --git a/bootstrap b/bootstrap index f86525f..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 } @@ -100,8 +133,37 @@ 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 +} + +# 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 -------------------------------------------------------- @@ -123,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" } @@ -136,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)" @@ -156,15 +222,15 @@ 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[@]}" # 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" @@ -174,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" } @@ -195,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" } @@ -216,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