Files
dotfiles/bootstrap.py
2026-03-27 09:24:28 +00:00

1084 lines
43 KiB
Python

#!/usr/bin/env python3
"""
bootstrap.py — Dotfiles manager for Ben's machines.
OVERVIEW
--------
This script is the single entry point for setting up and maintaining any of
Ben's machines (macOS or Arch Linux). It handles three concerns:
1. Packages — installing formulae/casks (brew) or AUR packages (yay)
2. Tooling — shell plugins, tmux plugins, etc. declared in setup.toml
3. Dotfiles — rsyncing the overlay directory (home/<user>/) into $HOME,
and optionally /etc on Linux
DESIGN DECISIONS
----------------
- OS is the only variation axis. All Macs are identical; all Arch machines
are identical. There are no per-hostname overrides.
- The repo's overlay directory IS the manifest. Any file placed under
home/<user>/ will be synced to $HOME. Use `add` to track new files.
- Backups are always taken before overwriting ($HOME/dotfiles.bak/).
- When syncing, files that are NEWER at the destination (system) than in the
repo are flagged for per-file review rather than silently overwritten.
This protects edits made on a work machine that haven't been pulled yet.
- setup.toml holds declarative tooling config (fish/tmux plugins).
Packages stay in packages/*.txt so they're easy to grep and diff.
USAGE
-----
python3 bootstrap.py → interactive TUI
python3 bootstrap.py install → full setup (non-interactive)
python3 bootstrap.py copy → sync dotfiles only
python3 bootstrap.py pull → save local edits back to repo
python3 bootstrap.py status → check drift, no changes made
python3 bootstrap.py add <path> → start tracking a new file
STRUCTURE
---------
packages/brew.txt formulae for all Macs
packages/brew-casks.txt cask apps for all Macs
packages/arch.txt packages for all Arch machines
setup.toml fish/tmux plugin declarations
home/<user>/ dotfile overlay (mirrors $HOME structure)
etc/ optional /etc overlay (applied with sudo)
themes/ colour schemes (catppuccin submodule etc.)
"""
from __future__ import annotations
import os, sys, subprocess, shutil, platform
from pathlib import Path
# ── dependency bootstrap ──────────────────────────────────────────────────────
# This block runs before any third-party imports so the script is self-contained
# on a fresh machine. It installs rich, questionary, and (on Python < 3.11) tomli,
# then re-execs the current process so the new packages are on sys.path.
# We cannot simply importlib.reload after pip because sys.path is already frozen.
def _ensure_deps() -> None:
"""Install TUI/TOML deps if missing, then re-exec so they're importable."""
import importlib.util
needed = ["rich", "questionary"]
if sys.version_info < (3, 11):
# tomllib was added to stdlib in 3.11; older Pythons need the backport
needed.append("tomli")
missing = [p for p in needed if not importlib.util.find_spec(p)]
if not missing:
return
print(f"[bootstrap] Installing missing deps: {', '.join(missing)} ...")
try:
# --user avoids needing root; --quiet suppresses pip's noisy output
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet", "--user"] + missing,
stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
# PEP 668 (Arch, Ubuntu 23+) marks the system Python as externally
# managed, requiring --break-system-packages for user-level installs
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet",
"--break-system-packages", "--user"] + missing
)
# Re-exec this process with the same args; now deps will be importable
os.execv(sys.executable, [sys.executable] + sys.argv)
_ensure_deps()
# tomllib is stdlib on 3.11+; fall back to the tomli backport below that
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore
from dataclasses import dataclass
from typing import Callable
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
import questionary
console = Console()
# ── paths ─────────────────────────────────────────────────────────────────────
DOTFILES_DIR = Path(__file__).parent.resolve()
BACKUP_DIR = Path.home() / "dotfiles.bak" # originals go here before overwrite
USERNAME = os.environ.get("USER", os.environ.get("LOGNAME", "benk"))
# ── setup.toml ────────────────────────────────────────────────────────────────
# setup.toml holds tooling that doesn't belong in a plain text package list:
# fish plugins, tmux plugins, and future post-install hooks.
# Packages stay in packages/*.txt so they remain easy to diff and grep.
def _load_setup() -> dict:
"""Load setup.toml from the repo root. Returns {} if the file doesn't exist."""
p = DOTFILES_DIR / "setup.toml"
if not p.exists():
return {}
with open(p, "rb") as f:
return tomllib.load(f)
SETUP = _load_setup()
# ── OS detection ──────────────────────────────────────────────────────────────
def detect_os() -> str:
"""
Detect the current operating system.
Returns one of: 'macos', 'arch', 'linux', 'unknown'.
"""
if platform.system() == "Darwin":
return "macos"
if Path("/etc/arch-release").exists():
return "arch"
if Path("/etc/os-release").exists():
return "linux"
return "unknown"
OS = detect_os()
# ── overlay resolution ────────────────────────────────────────────────────────
# OS is the ONLY variation axis — all Macs are identical, all Arch machines
# are identical. We fall back through home/<username>/ → home/benk/.
def overlay_home() -> Path:
"""
Return the dotfile overlay directory for the current user.
Checks home/<username>/ first, then falls back to home/benk/.
Creates home/<username>/ if neither exists.
"""
for candidate in (USERNAME, "benk"):
p = DOTFILES_DIR / "home" / candidate
if p.is_dir():
return p
# No overlay found — create one for this user rather than silently using benk/
p = DOTFILES_DIR / "home" / USERNAME
p.mkdir(parents=True, exist_ok=True)
return p
# ── OS-specific path exclusions ───────────────────────────────────────────────
# Some paths in the overlay only make sense on a specific OS.
# These are excluded from syncing when we're on a different OS.
#
# Format: { "owner_os": ["path/to/exclude/"] }
#
# Current rules:
# macOS only → Library/ (iTerm2 prefs, macOS app support)
# Linux only → .config/wezterm/ (WezTerm is the Linux terminal;
# iTerm2 is used on macOS instead)
OS_ONLY: dict[str, list[str]] = {
"macos": ["Library/"],
"linux": [".config/wezterm/"],
"arch": [".config/wezterm/"],
}
def _load_dotignore() -> list[str]:
"""
Read ~/.dotignore and return patterns to exclude from syncing.
Each non-blank, non-comment line becomes an rsync --exclude pattern.
"""
p = Path.home() / ".dotignore"
if not p.exists():
return []
return [
ln.strip() for ln in p.read_text().splitlines()
if ln.strip() and not ln.startswith("#")
]
def home_excludes() -> list[str]:
"""
Build rsync --exclude flags for paths that don't belong on this OS
and paths listed in ~/.dotignore on this machine.
"""
flags: list[str] = []
for owner_os, paths in OS_ONLY.items():
if OS != owner_os:
for p in paths:
flags += ["--exclude", p]
for p in _load_dotignore():
flags += ["--exclude", p]
return flags
# ── rsync helpers ─────────────────────────────────────────────────────────────
# We use rsync for all file syncing because it handles:
# - incremental transfers (only changed files)
# - directory trees
# - backups (--backup --backup-dir)
# - dry-run previews (--dry-run --itemize-changes)
#
# The --itemize-changes format is: YXcstpogba path
# Y = update type: > (transfer to dst), < (transfer from dst)
# X = file type: f (file), d (dir), L (symlink), ...
# flags = 9 chars; all '+' means new file; any other char means attribute changed
#
# We parse this output to distinguish new files from overwrites,
# and to decide which overwrites need per-file confirmation.
def _parse_changes(rsync_output: str) -> list[tuple[str, str]]:
"""
Parse rsync --itemize-changes output into (kind, path) tuples.
Only regular files transferred TO the destination ('>f') are included.
kind values:
'new' — file doesn't exist at dst yet (flags are all '+')
'changed' — file exists at dst and will be overwritten
"""
results = []
for line in rsync_output.splitlines():
# Only care about files being sent to the destination ('>f...')
if not line.startswith(">f"):
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
flag, path = parts
# All-'+' flags = brand new file; any variation = existing file updated
kind = "new" if "+++++++++" in flag else "changed"
results.append((kind, path))
return results
def confirm_rsync(src: str, dst: str, flags: list[str], *, sudo: bool = False, label: str = "") -> bool:
"""
Smart rsync wrapper that protects against accidentally overwriting newer work.
Flow:
1. Dry-run rsync to find what would change
2. For files that already exist at dst:
- If dst is NEWER than src → add to per-file confirmation list
(unchecked by default, so the user must actively choose to overwrite)
- If dst is OLDER than src → overwrite silently (repo wins)
3. Run the real rsync, excluding any files the user declined to overwrite
Returns True if the sync ran, False if the user cancelled.
"""
all_args = flags + [src, dst]
# Dry run first — no files are touched
dry = subprocess.run(
["rsync", "--dry-run", "--itemize-changes"] + all_args,
capture_output=True, text=True,
)
changes = _parse_changes(dry.stdout)
overwrites = [p for kind, p in changes if kind == "changed"]
extra_excludes: list[str] = []
if overwrites:
src_p = Path(src.rstrip("/"))
dst_p = Path(dst.rstrip("/"))
newer_at_dst: list[str] = [] # dst is newer → needs review
auto_count = 0 # dst is older → safe to overwrite
for rel in overwrites:
src_f, dst_f = src_p / rel, dst_p / rel
if (dst_f.exists() and src_f.exists()
and dst_f.stat().st_mtime > src_f.stat().st_mtime):
newer_at_dst.append(rel)
else:
auto_count += 1
if newer_at_dst:
# Inform the user about silent overwrites before showing the checkbox
if auto_count:
console.print(
f" [dim]Auto-overwriting {auto_count} older file(s) — "
f"repo is newer, no action needed[/dim]"
)
console.print()
# Show a checkbox for files where the system has newer edits.
# All unchecked by default — the user must opt IN to overwrite.
prefix = f"[{label}] " if label else ""
selected = questionary.checkbox(
f"{prefix}These files are newer on this machine than in the repo.\n"
f" Check any you want to overwrite with the repo version:",
choices=[
questionary.Choice(
title=f"{r} [dim](system is newer)[/dim]",
value=r,
checked=False, # opt-in, not opt-out
)
for r in newer_at_dst
],
).ask()
if selected is None:
console.print("[yellow] Cancelled.[/yellow]")
return False
# Exclude files the user chose NOT to overwrite
for rel in newer_at_dst:
if rel not in selected:
extra_excludes += ["--exclude", rel]
cmd = (["sudo"] if sudo else []) + ["rsync"] + extra_excludes + all_args
subprocess.run(cmd, check=True)
return True
# ── directory bootstrap ───────────────────────────────────────────────────────
def create_home_dirs() -> None:
"""
Ensure standard XDG and home directories exist before syncing.
rsync will fail if a parent directory doesn't exist.
.ssh must be 700 or SSH will refuse to use it.
"""
for d in [
Path.home() / ".config",
Path.home() / ".local" / "bin",
Path.home() / ".local" / "share",
Path.home() / ".local" / "state",
Path.home() / ".ssh",
]:
d.mkdir(parents=True, exist_ok=True)
(Path.home() / ".ssh").chmod(0o700)
# ── package file helpers ──────────────────────────────────────────────────────
def _read_packages(path: Path) -> list[str]:
"""
Read a package list file. Returns an empty list if the file doesn't exist.
Lines starting with '#' and blank lines are ignored.
"""
if not path.exists():
return []
return [
ln.strip() for ln in path.read_text().splitlines()
if ln.strip() and not ln.startswith("#")
]
def _install_one(cmd: list[str], pkg: str) -> None:
"""
Run `cmd pkg` and prompt to continue if it fails.
Exits the whole script if the user says no — a half-installed machine
is worse than an aborted one.
"""
console.print(f" [dim]{' '.join(cmd)} {pkg}[/dim]")
result = subprocess.run(cmd + [pkg], capture_output=True, text=True)
if result.returncode != 0:
console.print(f" [red][!] Failed to install:[/red] {pkg}")
if not questionary.confirm(" Continue anyway?", default=False).ask():
sys.exit(1)
# ── package installation ──────────────────────────────────────────────────────
def install_packages() -> None:
"""Dispatch to the correct package manager based on OS."""
if OS == "macos":
_install_brew()
elif OS == "arch":
_install_arch()
else:
console.print("[yellow]Unknown OS — skipping package install.[/yellow]")
console.print("[dim]Install packages manually from packages/*.txt[/dim]")
def _install_brew() -> None:
"""
Install Homebrew if not present, then install all formulae from brew.txt
and all casks from brew-casks.txt.
brew.txt → `brew install <pkg>` (CLI tools, libraries)
brew-casks.txt → `brew install --cask <app>` (GUI apps, fonts)
"""
if not shutil.which("brew"):
console.print(" [bold]Homebrew not found — installing...[/bold]")
subprocess.run(
["/bin/bash", "-c",
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"],
check=True,
)
pkgs = _read_packages(DOTFILES_DIR / "packages" / "brew.txt")
console.print(f" Installing [bold]{len(pkgs)}[/bold] formulae...")
for pkg in pkgs:
_install_one(["brew", "install"], pkg)
casks = _read_packages(DOTFILES_DIR / "packages" / "brew-casks.txt")
if casks:
console.print(f" Installing [bold]{len(casks)}[/bold] casks...")
for cask in casks:
_install_one(["brew", "install", "--cask"], cask)
def _install_arch() -> None:
"""
Bootstrap yay (AUR helper) if not present, then install all packages
from arch.txt using yay (which also handles official repo packages).
"""
if not shutil.which("yay"):
console.print(" [bold]yay not found — bootstrapping from AUR...[/bold]")
subprocess.run(
["sudo", "pacman", "-S", "--needed", "--noconfirm", "git", "base-devel"],
check=True,
)
tmp = Path(subprocess.check_output(["mktemp", "-d"]).decode().strip())
subprocess.run(
["git", "clone", "https://aur.archlinux.org/yay.git", str(tmp / "yay")],
check=True,
)
subprocess.run(["makepkg", "-si", "--noconfirm"], cwd=tmp / "yay", check=True)
shutil.rmtree(tmp)
pkgs = _read_packages(DOTFILES_DIR / "packages" / "arch.txt")
console.print(f" Installing [bold]{len(pkgs)}[/bold] packages...")
for pkg in pkgs:
_install_one(["yay", "-S", "--needed", "--noconfirm"], pkg)
# ── shell tooling ─────────────────────────────────────────────────────────────
# "Tooling" means things that aren't packages but need to be set up once:
# tmux plugin manager, oh-my-fish, fisher, and the plugins declared in setup.toml.
def setup_shell_tooling() -> None:
"""Set up tmux and fish tooling in sequence."""
_setup_tmux()
_setup_fish()
def _setup_tmux() -> None:
"""
Clone tpm (Tmux Plugin Manager) if not present, then install any tmux
plugins listed in setup.toml [tmux] plugins via tpm's headless install script.
tpm lives at ~/.tmux/plugins/tpm.
Plugins are installed to ~/.tmux/plugins/<plugin-name>.
The headless install script is at ~/.tmux/plugins/tpm/bin/install_plugins.
"""
tpm = Path.home() / ".tmux" / "plugins" / "tpm"
if not tpm.exists():
console.print(" Installing tmux plugin manager (tpm)...")
subprocess.run(
["git", "clone", "https://github.com/tmux-plugins/tpm", str(tpm)],
check=True,
)
else:
console.print(" [green]✓[/green] tmux plugin manager")
plugins = SETUP.get("tmux", {}).get("plugins", [])
if plugins:
install_script = tpm / "bin" / "install_plugins"
if install_script.exists():
console.print(f" Installing {len(plugins)} tmux plugin(s) headlessly...")
subprocess.run([str(install_script)], capture_output=True)
console.print(" [green]✓[/green] tmux plugins")
else:
# Fallback: manual instruction if the script isn't present
console.print(" [yellow]![/yellow] Run [dim]<C-b>I[/dim] inside tmux to install plugins")
def _setup_fish() -> None:
"""
Set up the fish shell plugin ecosystem:
1. oh-my-fish (OMF) — theming and utility functions
2. fisher — fish plugin manager (manages plugins declared in setup.toml)
3. fish plugins from setup.toml [fish] plugins
Skipped entirely if fish is not installed on this machine.
Fisher install is idempotent — it only installs plugins not already present.
"""
if not shutil.which("fish"):
console.print(" [dim]fish not installed — skipping fish setup[/dim]")
return
omf = Path.home() / ".local" / "share" / "omf"
if not omf.exists():
console.print(" Installing oh-my-fish...")
subprocess.run(["fish", "-c", "curl -L https://get.oh-my.fish | fish"], check=True)
else:
console.print(" [green]✓[/green] oh-my-fish")
# Check if fisher is available as a fish function
result = subprocess.run(["fish", "-c", "type -q fisher"], capture_output=True)
if result.returncode != 0:
console.print(" Installing fisher...")
subprocess.run(
["fish", "-c",
"curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/"
"functions/fisher.fish | source && fisher install jorgebucaran/fisher"],
check=True,
)
else:
console.print(" [green]✓[/green] fisher")
# Install any fish plugins declared in setup.toml that aren't already installed
plugins = SETUP.get("fish", {}).get("plugins", [])
if plugins:
installed_r = subprocess.run(
["fish", "-c", "fisher list"], capture_output=True, text=True
)
installed = set(installed_r.stdout.strip().splitlines())
to_install = [p for p in plugins if p not in installed]
if to_install:
console.print(f" Installing {len(to_install)} fish plugin(s)...")
subprocess.run(
["fish", "-c", f"fisher install {' '.join(to_install)}"],
check=True,
)
console.print(" [green]✓[/green] fish plugins")
# ── sync: repo → home (copy / install) ───────────────────────────────────────
def sync_to_home() -> None:
"""
Sync the overlay directory (home/<user>/) into $HOME.
Original files are backed up to ~/dotfiles.bak/home/ before overwriting.
OS-specific paths are excluded via home_excludes().
"""
src = overlay_home()
dst = Path.home()
(BACKUP_DIR / "home").mkdir(parents=True, exist_ok=True)
flags = [
"-av", "--itemize-changes",
"--backup", f"--backup-dir={BACKUP_DIR / 'home'}",
*home_excludes(),
]
console.print(
f" [bold]Syncing[/bold] [cyan]{src.relative_to(DOTFILES_DIR)}[/cyan]"
f" → [cyan]~[/cyan]"
)
confirm_rsync(f"{src}/", f"{dst}/", flags, label="repo → home")
# ── sync: home → repo (pull) ─────────────────────────────────────────────────
def sync_to_repo() -> None:
"""
Pull live config from $HOME back into the overlay (home/<user>/).
Only files that already exist in the overlay are candidates — we build
an explicit file list from the overlay and pass it via --files-from.
Why --files-from instead of --existing:
--existing tells rsync "only update files at dst that exist there", but
rsync still traverses the ENTIRE source tree to find candidates. On a
large home directory this is slow, verbose, and can create ghost
directories in the overlay that become "tracked" on the next run.
--files-from is an explicit manifest: rsync only touches those paths.
Also pulls /etc if an etc/ overlay directory exists in the repo.
"""
import tempfile
src, dst = Path.home(), overlay_home()
# Build explicit file list from the overlay (relative to overlay root)
tracked = [
str(f.relative_to(dst))
for f in dst.rglob("*")
if f.is_file() and not any(
str(f.relative_to(dst)).startswith(excl.lstrip("/"))
for excl in home_excludes()[1::2] # every other element is the path
)
]
if not tracked:
console.print(" [dim]No files tracked in overlay — nothing to pull.[/dim]")
return
console.print(
f" [bold]Pulling[/bold] [cyan]~[/cyan]"
f" → [cyan]{dst.relative_to(DOTFILES_DIR)}[/cyan]"
f" [dim]({len(tracked)} tracked files)[/dim]"
)
# Write the manifest to a temp file and pass it to rsync
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as fh:
fh.write("\n".join(tracked))
manifest = fh.name
try:
flags = [
"-av", "--itemize-changes",
f"--files-from={manifest}", # only sync these exact paths
"--update", # skip files where dst (overlay) is newer than src (home)
]
confirm_rsync(f"{src}/", f"{dst}/", flags, label="home → repo")
finally:
Path(manifest).unlink(missing_ok=True)
etc_dir = DOTFILES_DIR / "etc"
if etc_dir.is_dir():
etc_tracked = [
str(f.relative_to(etc_dir))
for f in etc_dir.rglob("*") if f.is_file()
]
console.print(f" [bold]Pulling[/bold] [cyan]/etc[/cyan] → [cyan]etc/[/cyan]")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as fh:
fh.write("\n".join(etc_tracked))
etc_manifest = fh.name
try:
confirm_rsync(
"/etc/", f"{etc_dir}/",
["-av", "--itemize-changes", f"--files-from={etc_manifest}", "--update"],
sudo=True, label="/etc → repo",
)
finally:
Path(etc_manifest).unlink(missing_ok=True)
# ── sync: etc/ → /etc ─────────────────────────────────────────────────────────
def sync_etc() -> None:
"""
Apply the etc/ overlay to /etc on the system (requires sudo).
Original /etc files are backed up to ~/dotfiles.bak/etc/ before overwriting.
This is exposed as an optional stage in mode_install; it's excluded from
mode_copy because /etc changes are usually intentional and should be reviewed.
"""
etc_dir = DOTFILES_DIR / "etc"
files = [f for f in etc_dir.rglob("*") if f.is_file()]
console.print(" [bold]System config files to apply:[/bold]")
for f in files:
console.print(f" [dim]/{f.relative_to(DOTFILES_DIR)}[/dim]")
console.print()
(BACKUP_DIR / "etc").mkdir(parents=True, exist_ok=True)
confirm_rsync(
f"{etc_dir}/", "/etc/",
["-av", "--itemize-changes", "--backup", f"--backup-dir={BACKUP_DIR / 'etc'}"],
sudo=True, label="etc → /etc",
)
def _has_etc() -> bool:
"""Return True if the repo has an etc/ overlay with at least one file."""
etc_dir = DOTFILES_DIR / "etc"
return etc_dir.is_dir() and any(f for f in etc_dir.rglob("*") if f.is_file())
# ── status command ────────────────────────────────────────────────────────────
# Status is read-only — it never modifies any files.
# It answers the question: "how does this machine differ from the repo?"
def cmd_status() -> None:
"""
Show a full drift report: packages, dotfiles, and tooling.
No files are written or modified.
"""
console.print(Panel(
"[bold]Status[/bold] [dim]comparing this machine against the repo — "
"no changes will be made[/dim]",
border_style="bright_blue", padding=(0, 1),
))
console.rule("[bold]Packages[/bold]", style="bright_blue")
_status_packages()
console.rule("[bold]Dotfiles[/bold]", style="bright_blue")
_status_dotfiles()
console.rule("[bold]Tooling[/bold]", style="bright_blue")
_status_tooling()
console.rule(style="bright_blue")
def _status_packages() -> None:
"""
Compare wanted packages (from packages/*.txt) against what's installed.
Shows missing packages individually; installed ones are summarised as a count.
"""
console.print()
if OS == "macos":
if not shutil.which("brew"):
console.print(" [red]✗[/red] Homebrew is not installed")
console.print()
return
installed_formulae = set(
subprocess.check_output(["brew", "list", "--formula"], text=True).splitlines()
)
installed_casks = set(
subprocess.check_output(["brew", "list", "--cask"], text=True).splitlines()
)
_status_pkg_list(
"formulae",
_read_packages(DOTFILES_DIR / "packages" / "brew.txt"),
installed_formulae,
)
_status_pkg_list(
"casks",
_read_packages(DOTFILES_DIR / "packages" / "brew-casks.txt"),
installed_casks,
)
elif OS in ("arch", "linux"):
installed = set(subprocess.check_output(["pacman", "-Qq"], text=True).splitlines())
_status_pkg_list(
"packages",
_read_packages(DOTFILES_DIR / "packages" / "arch.txt"),
installed,
)
else:
console.print(" [dim]Package status not supported on this OS[/dim]")
console.print()
def _status_pkg_list(label: str, wanted: list[str], installed: set[str]) -> None:
"""Print missing packages individually and summarise installed ones as a count."""
missing = [p for p in wanted if p not in installed]
ok_count = sum(1 for p in wanted if p in installed)
for p in missing:
console.print(f" [red]✗[/red] {p} [dim]({label} — not installed)[/dim]")
console.print(f" [green]✓[/green] {ok_count}/{len(wanted)} {label} installed")
def _status_dotfiles() -> None:
"""
Dry-run rsync repo→home and classify each changed file as:
new — in repo, not yet on this machine (copy will add it)
repo ahead — repo is newer (copy will update it)
system ahead — system is newer (pull to capture, or leave it)
Shows up to 5 examples per category with a count for the rest.
"""
console.print()
src = overlay_home()
dst = Path.home()
result = subprocess.run(
["rsync", "--dry-run", "--itemize-changes", "-av",
*home_excludes(), f"{src}/", f"{dst}/"],
capture_output=True, text=True,
)
changes = _parse_changes(result.stdout)
if not changes:
console.print(" [green]✓[/green] All dotfiles are up to date")
console.print()
return
new_files = [p for kind, p in changes if kind == "new"]
changed_files = [p for kind, p in changes if kind == "changed"]
# For each changed file, decide which side is "ahead" by mtime
repo_ahead = []
system_ahead = []
for rel in changed_files:
dst_f = dst / rel
src_f = src / rel
if dst_f.exists() and src_f.exists() and dst_f.stat().st_mtime > src_f.stat().st_mtime:
system_ahead.append(rel)
else:
repo_ahead.append(rel)
def _show_files(files: list[str], limit: int = 5) -> None:
for p in files[:limit]:
console.print(f" [dim]{p}[/dim]")
if len(files) > limit:
console.print(f" [dim]... and {len(files) - limit} more[/dim]")
if new_files:
console.print(
f" [blue]+[/blue] {len(new_files)} file(s) in repo, "
f"not yet on this machine [dim](run Copy to apply)[/dim]"
)
_show_files(new_files)
if repo_ahead:
console.print(
f" [cyan]↓[/cyan] {len(repo_ahead)} file(s) where repo is newer "
f"[dim](run Copy to apply)[/dim]"
)
_show_files(repo_ahead)
if system_ahead:
console.print(
f" [yellow]↑[/yellow] {len(system_ahead)} file(s) where this machine is newer "
f"[dim](run Pull to save, or leave as-is)[/dim]"
)
_show_files(system_ahead)
console.print()
def _status_tooling() -> None:
"""
Check whether tmux tpm, oh-my-fish, fisher, and declared plugins are installed.
Cross-references setup.toml for expected plugin lists.
"""
console.print()
# tmux
tpm = Path.home() / ".tmux" / "plugins" / "tpm"
_status_line("tmux plugin manager (tpm)", tpm.exists())
for plugin in SETUP.get("tmux", {}).get("plugins", []):
plugin_dir = Path.home() / ".tmux" / "plugins" / plugin.split("/")[-1]
_status_line(f" tmux: {plugin}", plugin_dir.exists())
# fish
if shutil.which("fish"):
omf = Path.home() / ".local" / "share" / "omf"
_status_line("oh-my-fish", omf.exists())
fisher_ok = subprocess.run(["fish", "-c", "type -q fisher"], capture_output=True)
_status_line("fisher", fisher_ok.returncode == 0)
if fisher_ok.returncode == 0:
installed_r = subprocess.run(
["fish", "-c", "fisher list"], capture_output=True, text=True
)
installed = set(installed_r.stdout.strip().splitlines())
for plugin in SETUP.get("fish", {}).get("plugins", []):
_status_line(f" fish: {plugin}", plugin in installed)
else:
console.print(" [dim]fish not installed — skipping fish tooling check[/dim]")
console.print()
def _status_line(label: str, ok: bool) -> None:
"""Print a single ✓ / ✗ status line."""
icon = "[green]✓[/green]" if ok else "[red]✗[/red]"
console.print(f" {icon} {label}")
# ── add command ───────────────────────────────────────────────────────────────
def cmd_add(path_str: str) -> None:
"""
Start tracking a new file by copying it into the dotfile overlay.
The file must be under $HOME. Its path relative to $HOME is preserved
in the overlay, so the sync will deploy it back to the same location.
Example:
python3 bootstrap.py add ~/.config/starship.toml
→ copies to home/benk/.config/starship.toml
→ `git add` and commit to make it permanent
"""
path = Path(path_str).expanduser().resolve()
if not path.exists():
console.print(f"[red]File not found:[/red] {path}")
sys.exit(1)
try:
rel = path.relative_to(Path.home())
except ValueError:
console.print(
f"[red]Error:[/red] path must be under $HOME\n"
f" Got: {path}\n"
f" $HOME: {Path.home()}"
)
sys.exit(1)
dst = overlay_home() / rel
if dst.exists():
console.print(f"[yellow]Already tracked:[/yellow] {rel}")
console.print(f" [dim]Currently at: {dst}[/dim]")
if not questionary.confirm(" Overwrite the repo copy?", default=False).ask():
sys.exit(0)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dst)
console.print(f" [green]✓[/green] Tracked: [cyan]~/{rel}[/cyan]")
console.print(
f" [dim]Repo path: {dst.relative_to(DOTFILES_DIR)}[/dim]\n"
f" [dim]Commit: git add {dst.relative_to(DOTFILES_DIR)} && git commit[/dim]"
)
# ── stage runner ──────────────────────────────────────────────────────────────
# A Stage is a named, describable unit of work within a mode.
# run_stages() shows a checkbox so the user can skip stages they don't need,
# then runs each selected stage with a clear progress header.
@dataclass
class Stage:
"""A discrete unit of work within a mode (e.g. 'Install packages')."""
key: str # unique identifier used for checkbox selection
title: str # short display name shown in the checkbox and header
desc: str # one-line description shown alongside the title
fn: Callable[[], None] # the function to call when this stage runs
# Set once at import time: True if a CLI argument was passed (non-interactive mode).
# run_stages() skips the checkbox and runs all stages when this is True.
NON_INTERACTIVE = len(sys.argv) > 1
def run_stages(mode_title: str, mode_desc: str, stages: list[Stage]) -> None:
"""
Display a mode panel, let the user select which stages to run via checkbox,
then execute each selected stage with a numbered progress header.
In non-interactive mode (CLI argument provided) all stages run without prompting.
The stage rule format is: Stage N/M Title description
This makes it easy to see at a glance where you are in a multi-stage run.
"""
console.print(Panel(
f"[bold]{mode_title}[/bold]\n[dim]{mode_desc}[/dim]",
border_style="bright_blue", padding=(0, 1),
))
console.print()
if NON_INTERACTIVE:
# Skip the checkbox when called from the command line (scripted / CI use)
to_run = stages
else:
selected_keys = questionary.checkbox(
"Select which stages to run "
"[dim](space to toggle, enter to confirm, ctrl-c to cancel)[/dim]",
choices=[
questionary.Choice(
title=f"{s.title} [dim]— {s.desc}[/dim]",
value=s.key,
checked=True, # all stages selected by default
)
for s in stages
],
).ask()
if selected_keys is None:
# questionary returns None on Ctrl-C
console.print("\n[yellow]Cancelled.[/yellow]")
sys.exit(0)
if not selected_keys:
console.print("[yellow]No stages selected — nothing to do.[/yellow]")
return
to_run = [s for s in stages if s.key in selected_keys]
total = len(to_run)
for i, stage in enumerate(to_run, 1):
console.print()
console.rule(
f"[bold bright_blue]Stage {i} of {total}[/bold bright_blue]"
f" [bold]{stage.title}[/bold] [dim]{stage.desc}[/dim]",
style="bright_blue",
)
console.print()
stage.fn()
console.print()
console.rule("[bold green]All done[/bold green]", style="green")
console.print()
# ── modes ─────────────────────────────────────────────────────────────────────
# Each mode is a curated list of stages suited to a particular intent.
# Modes are presented in the TUI select and can also be called from the CLI.
def _stage_sync_dotfiles() -> None:
"""Ensure home dirs exist then sync dotfiles. Used in both install and copy."""
create_home_dirs()
sync_to_home()
def mode_install() -> None:
"""
Full machine setup: packages → tooling → dotfiles → /etc (if present).
Intended for bootstrapping a new machine from scratch.
All stages are shown in the checkbox; deselect any you want to skip.
"""
pkg_mgr = {"macos": "brew", "arch": "yay"}.get(OS, "unknown")
stages = [
Stage("packages", "Install packages", f"via {pkg_mgr}", install_packages),
Stage("tooling", "Shell tooling", "tmux tpm · fish plugins", setup_shell_tooling),
Stage("dotfiles", "Sync dotfiles", "repo → home", _stage_sync_dotfiles),
]
if _has_etc():
stages.append(Stage("etc", "Sync /etc", "requires sudo", sync_etc))
run_stages(
"Set up this machine",
"Install packages, shell tooling, and sync dotfiles from the repo",
stages,
)
def mode_copy() -> None:
"""
Apply dotfiles from the repo to this machine.
Skips packages and shell tooling — use this on a machine that's already set up
when you just want to push a config change out.
"""
run_stages(
"Apply dotfiles",
"Sync repo → home (skip packages and shell tooling)",
[Stage("dotfiles", "Sync dotfiles", "repo → home", _stage_sync_dotfiles)],
)
def mode_pull() -> None:
"""
Pull edits made on this machine back into the repo.
Only files already tracked in the overlay are updated (no new files are added).
Use `add` to start tracking a new file.
Files newer on this machine will be shown for per-file confirmation.
"""
run_stages(
"Save changes",
"Pull edits from this machine back into the repo",
[Stage("pull", "Pull config", "home + /etc → repo", sync_to_repo)],
)
# ── mode registry ─────────────────────────────────────────────────────────────
# Modes are keyed by their CLI name. Each entry is (display_title, description, fn).
# The TUI select and the CLI dispatcher both read from this dict,
# so adding a new mode here is the only change needed.
MODES: dict[str, tuple[str, str, Callable[[], None]]] = {
"install": (
"Set up this machine",
"install packages, shell tooling, and sync dotfiles",
mode_install,
),
"copy": (
"Apply dotfiles",
"push repo changes to this machine (skip packages)",
mode_copy,
),
"pull": (
"Save changes",
"pull edits made on this machine back into the repo",
mode_pull,
),
"status": (
"Check status",
"see what's different between this machine and the repo",
cmd_status,
),
}
# ── entry point ───────────────────────────────────────────────────────────────
def main() -> None:
"""
Parse CLI arguments or show the interactive TUI.
CLI usage (non-interactive, skips all checkboxes):
bootstrap.py install | copy | pull | status
bootstrap.py add <path>
Interactive usage (no arguments):
Shows a mode-select menu, then a stage-select checkbox.
"""
# ── header ──
console.print(Panel(
Text.assemble(
("dotfiles", "bold cyan"), "\n",
(" repo ", "dim"), str(DOTFILES_DIR), "\n",
(" user ", "dim"), USERNAME, "\n",
(" os ", "dim"), OS,
),
border_style="bright_blue",
padding=(0, 1),
))
console.print()
# ── CLI: bootstrap.py add <path> ──
if len(sys.argv) > 1 and sys.argv[1] == "add":
if len(sys.argv) < 3:
console.print(
"[red]Usage:[/red] bootstrap.py add [cyan]<path>[/cyan]\n"
" Example: bootstrap.py add ~/.config/starship.toml"
)
sys.exit(1)
cmd_add(sys.argv[2])
return
# ── CLI: bootstrap.py <mode> ──
if len(sys.argv) > 1:
key = sys.argv[1].lstrip("-") # accept --install as well as install
if key in MODES:
MODES[key][2]()
return
console.print(
f"[red]Unknown command:[/red] {sys.argv[1]}\n"
f" Valid commands: add <path> | {' | '.join(MODES)}"
)
sys.exit(1)
# ── TUI: interactive mode select ──
console.print("[dim]Use arrow keys to navigate, enter to select.[/dim]\n")
choice = questionary.select(
"What would you like to do?",
choices=[
questionary.Choice(
title=f"{title} [dim]— {desc}[/dim]",
value=key,
)
for key, (title, desc, _) in MODES.items()
],
).ask()
if choice is None:
# Ctrl-C
console.print("\n[yellow]Cancelled.[/yellow]")
sys.exit(0)
MODES[choice][2]()
if __name__ == "__main__":
main()