Two layers with a deliberate privilege split: setup.sh does the root work (apt, rustup, cargo tools, starship, chsh) and hands off to chezmoi, which owns everything under $HOME. Tools are built with `cargo install --locked` to match the versions currently running rather than whatever apt ships, at the cost of a 10-20 min cold setup. Excluded on purpose: the atuin sync key and ~/.git-credentials (secrets), ~/.claude/settings.json (per-box decision), fnm/opencode conf.d (out of scope), and fish_variables (machine-local). chezmoi tracks only the executable and private attributes and derives modes from the umask, so setup.sh pins umask 022 and atuin uses private_ to keep 0700/0600. Without both, a 002 umask silently relaxes them to 0775/0664. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
9.0 KiB
Bash
190 lines
9.0 KiB
Bash
#!/usr/bin/env bash
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# dotfiles/setup.sh — provision a fresh box, then hand off to chezmoi
|
|
#
|
|
# Root work (apt, starship, chsh) happens here. Everything under $HOME is
|
|
# owned by chezmoi. Run: sudo bash setup.sh
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
set -euo pipefail
|
|
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
|
|
|
PASS=0; WARN=0; FAIL=0
|
|
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
log_success() { echo -e "${GREEN}[ OK ]${NC} $*"; PASS=$((PASS+1)); }
|
|
log_warning() { echo -e "${YELLOW}[WARN]${NC} $*"; WARN=$((WARN+1)); }
|
|
log_error() { echo -e "${RED}[FAIL]${NC} $*"; FAIL=$((FAIL+1)); }
|
|
log_step() { echo -e "\n${BLUE}▶${NC} ${1}"; }
|
|
|
|
DOTFILES_REPO="${DOTFILES_REPO:-https://git.domverse-berlin.eu/domverse/dotfiles.git}"
|
|
CARGO_TOOLS=(atuin eza macchina oxker)
|
|
APT_PKGS=(fish bat ripgrep jq git curl nano build-essential pkg-config libssl-dev)
|
|
|
|
echo -e "${BLUE}"
|
|
cat <<'EOF'
|
|
╔══════════════════════════════════════════════════════════════╗
|
|
║ Shell & dotfiles bootstrap ║
|
|
║ fish · starship · atuin · eza · chezmoi · claude ║
|
|
╚══════════════════════════════════════════════════════════════╝
|
|
EOF
|
|
echo -e "${NC}"
|
|
|
|
# ── Preflight ────────────────────────────────────────────────────────────────
|
|
log_step "Preflight"
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
log_error "Must run as root: sudo bash setup.sh"
|
|
exit 1
|
|
fi
|
|
|
|
TARGET_USER="${SUDO_USER:-}"
|
|
if [ -z "$TARGET_USER" ] || [ "$TARGET_USER" = "root" ]; then
|
|
log_error "Could not determine the non-root target user (SUDO_USER unset)."
|
|
log_error "Run via 'sudo bash setup.sh' as your normal user, not as root directly."
|
|
exit 1
|
|
fi
|
|
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6)
|
|
log_success "Target user: $TARGET_USER (home: $TARGET_HOME)"
|
|
|
|
# Run a command as the target user with a sane login-ish env.
|
|
as_user() { sudo -u "$TARGET_USER" -H bash -lc "$*"; }
|
|
|
|
# ── APT packages ─────────────────────────────────────────────────────────────
|
|
log_step "APT packages"
|
|
|
|
MISSING=()
|
|
for p in "${APT_PKGS[@]}"; do
|
|
dpkg -s "$p" >/dev/null 2>&1 || MISSING+=("$p")
|
|
done
|
|
|
|
if [ ${#MISSING[@]} -eq 0 ]; then
|
|
log_success "All apt packages already present (${APT_PKGS[*]})"
|
|
else
|
|
log_info "Installing: ${MISSING[*]}"
|
|
apt-get update -qq
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${MISSING[@]}"
|
|
log_success "Installed ${#MISSING[@]} apt package(s)"
|
|
fi
|
|
|
|
# config.fish aliases `cat` to batcat; on Ubuntu the binary really is batcat.
|
|
if command -v batcat >/dev/null 2>&1; then
|
|
log_success "batcat present (config.fish aliases cat -> batcat)"
|
|
else
|
|
log_warning "batcat missing — the 'cat' alias in config.fish will break"
|
|
fi
|
|
|
|
# ── rustup + cargo tools ─────────────────────────────────────────────────────
|
|
log_step "Rust toolchain (build dependency for ${CARGO_TOOLS[*]})"
|
|
|
|
if as_user 'command -v cargo' >/dev/null 2>&1; then
|
|
log_success "rustup/cargo already installed"
|
|
else
|
|
log_info "Installing rustup (non-interactive, default profile)..."
|
|
as_user "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path"
|
|
log_success "rustup installed"
|
|
fi
|
|
|
|
# `cargo install` is idempotent-ish: it rebuilds if the version differs.
|
|
# --locked keeps builds reproducible. This is the slow part (~10-20 min cold).
|
|
for tool in "${CARGO_TOOLS[@]}"; do
|
|
if as_user "test -x \$HOME/.cargo/bin/$tool"; then
|
|
log_success "$tool already built"
|
|
else
|
|
log_info "Building $tool from source (this takes a while)..."
|
|
if as_user "\$HOME/.cargo/bin/cargo install --locked $tool"; then
|
|
log_success "$tool built"
|
|
else
|
|
log_error "$tool failed to build"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# ── starship ─────────────────────────────────────────────────────────────────
|
|
log_step "Starship prompt"
|
|
|
|
if command -v starship >/dev/null 2>&1; then
|
|
log_success "starship already installed ($(starship --version | head -1))"
|
|
else
|
|
log_info "Installing starship to /usr/local/bin..."
|
|
curl -sS https://starship.rs/install.sh | sh -s -- --yes >/dev/null
|
|
log_success "starship installed"
|
|
fi
|
|
|
|
# ── chezmoi + dotfiles ───────────────────────────────────────────────────────
|
|
log_step "chezmoi"
|
|
|
|
if as_user 'command -v chezmoi' >/dev/null 2>&1; then
|
|
log_success "chezmoi already installed"
|
|
else
|
|
log_info "Installing chezmoi to ~/.local/bin..."
|
|
as_user "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \$HOME/.local/bin"
|
|
log_success "chezmoi installed"
|
|
fi
|
|
|
|
CHEZMOI="\$HOME/.local/bin/chezmoi"
|
|
|
|
# chezmoi does NOT preserve exact file modes — it tracks only the executable and
|
|
# private attributes, then derives the mode from the umask. A umask of 002 (the
|
|
# default here) would write world/group-writable 0664 dotfiles. Pin 022 so files
|
|
# land as 0644, and let the private_ attribute handle the 0600 cases.
|
|
log_step "chezmoi config"
|
|
CHEZMOI_CFG="$TARGET_HOME/.config/chezmoi/chezmoi.toml"
|
|
if [ -f "$CHEZMOI_CFG" ] && grep -q 'umask' "$CHEZMOI_CFG"; then
|
|
log_success "chezmoi umask already configured"
|
|
else
|
|
as_user "mkdir -p \$HOME/.config/chezmoi"
|
|
as_user "printf 'umask = 0o022\n' >> \$HOME/.config/chezmoi/chezmoi.toml"
|
|
log_success "chezmoi umask pinned to 022"
|
|
fi
|
|
|
|
log_step "Applying dotfiles"
|
|
if as_user "test -d \$HOME/.local/share/chezmoi/.git"; then
|
|
log_info "chezmoi source already initialised — applying current state"
|
|
as_user "$CHEZMOI apply" && log_success "dotfiles applied" || log_error "chezmoi apply failed"
|
|
else
|
|
log_info "Initialising chezmoi from $DOTFILES_REPO"
|
|
if as_user "$CHEZMOI init --apply '$DOTFILES_REPO'"; then
|
|
log_success "dotfiles initialised and applied"
|
|
else
|
|
log_error "chezmoi init failed — check repo access (private repo needs credentials)"
|
|
fi
|
|
fi
|
|
|
|
# ── Default shell ────────────────────────────────────────────────────────────
|
|
log_step "Default shell"
|
|
|
|
FISH_BIN=$(command -v fish || true)
|
|
if [ -z "$FISH_BIN" ]; then
|
|
log_error "fish not found — cannot set default shell"
|
|
else
|
|
grep -qxF "$FISH_BIN" /etc/shells || echo "$FISH_BIN" >> /etc/shells
|
|
CURRENT_SHELL=$(getent passwd "$TARGET_USER" | cut -d: -f7)
|
|
if [ "$CURRENT_SHELL" = "$FISH_BIN" ]; then
|
|
log_success "fish is already the default shell for $TARGET_USER"
|
|
else
|
|
chsh -s "$FISH_BIN" "$TARGET_USER"
|
|
log_success "default shell changed: $CURRENT_SHELL -> $FISH_BIN"
|
|
fi
|
|
fi
|
|
|
|
# ── Summary ──────────────────────────────────────────────────────────────────
|
|
echo
|
|
echo "────────────────────────────────────────────────────────────────"
|
|
echo -e " ${GREEN}pass: $PASS${NC} ${YELLOW}warn: $WARN${NC} ${RED}fail: $FAIL${NC}"
|
|
echo "────────────────────────────────────────────────────────────────"
|
|
echo
|
|
log_info "Manual steps that cannot be automated:"
|
|
echo " 1. atuin sync — key is NOT in this repo (tracked in secrets.yml):"
|
|
echo " atuin login -u <user>"
|
|
echo " atuin sync"
|
|
echo " Restore the key from secrets.yml into ~/.local/share/atuin/key"
|
|
echo " BEFORE first sync, or history encrypted on other hosts won't decrypt."
|
|
echo " 2. git identity is not set globally (only credential.helper=store):"
|
|
echo " git config --global user.name '<name>'"
|
|
echo " git config --global user.email '<email>'"
|
|
echo " 3. Log out and back in for the fish shell change to take effect."
|
|
echo
|
|
|
|
[ "$FAIL" -gt 0 ] && exit 1
|
|
exit 0
|