feat: shell + dotfiles setup for a fresh box
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>
This commit is contained in:
189
setup.sh
Normal file
189
setup.sh
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user