Skip to content
Open
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
116 changes: 90 additions & 26 deletions bootstrap
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# chmod +x ./bootstrap && ./bootstrap
#

set -euo pipefail
set -Eeuo pipefail

# ==============================================================================
# ✍ MANUAL EDIT — pick your layers
Expand Down Expand Up @@ -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: "<extrepo-name> <pkg> [pkg...]"
[[ $# -gt 0 ]] || return 0
step "third-party repos via extrepo ($#)"
Expand All @@ -72,10 +103,11 @@ apt_extrepo() { # each arg: "<extrepo-name> <pkg> [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
Expand All @@ -85,6 +117,7 @@ apt_extrepo() { # each arg: "<extrepo-name> <pkg> [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
}
Expand All @@ -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='<your-dotfiles-repo>'
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 --------------------------------------------------------
Expand All @@ -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"
}

Expand All @@ -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)"
Expand All @@ -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"
Expand All @@ -174,30 +240,27 @@ 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"
}

# --- layer: chezmoi + dotfiles (both) ------------------------------------------
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"
}

Expand All @@ -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
Expand Down