diff --git a/.chezmoiignore b/.chezmoiignore index adc167bcdb4..c2620fc460c 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -6,11 +6,20 @@ README.md LICENSE-MIT.txt Brewfile +packages-apt.txt bootstrap.sh tests init theme +# Package-install scripts run only on their own OS +{{ if ne .chezmoi.os "darwin" -}} +.chezmoiscripts/darwin/** +{{ end -}} +{{ if ne .chezmoi.os "linux" -}} +.chezmoiscripts/linux/** +{{ end -}} + {{ if ne .chezmoi.os "darwin" -}} # macOS GUI configuration has no business on Linux/WSL com.googlecode.iterm2.plist @@ -18,10 +27,11 @@ com.googlecode.iterm2.plist {{ end -}} {{ if eq .machineClass "ephemeral" -}} -# Disposable boxes get shell config only: no identity, no secrets, and no -# tooling that assumes a long-lived machine. +# Disposable boxes get shell config only: no identity, no secrets, no +# package installs, and no tooling that assumes a long-lived machine. .extra .gitconfig.local .claude CLAUDE.md +.chezmoiscripts/** {{ end -}} diff --git a/.chezmoiscripts/darwin/run_onchange_install-packages.sh.tmpl b/.chezmoiscripts/darwin/run_onchange_install-packages.sh.tmpl new file mode 100644 index 00000000000..bb2469f1584 --- /dev/null +++ b/.chezmoiscripts/darwin/run_onchange_install-packages.sh.tmpl @@ -0,0 +1,11 @@ +#!/bin/bash +# Installs/updates Homebrew packages whenever the Brewfile changes. +# Brewfile hash: {{ include "Brewfile" | sha256sum }} +set -e + +if ! command -v brew > /dev/null 2>&1; then + echo "Homebrew not installed — skipping brew bundle (install from https://brew.sh)"; + exit 0; +fi + +brew bundle --file={{ joinPath .chezmoi.sourceDir "Brewfile" | quote }} diff --git a/.chezmoiscripts/linux/run_onchange_install-packages.sh.tmpl b/.chezmoiscripts/linux/run_onchange_install-packages.sh.tmpl new file mode 100644 index 00000000000..92db9eb60a2 --- /dev/null +++ b/.chezmoiscripts/linux/run_onchange_install-packages.sh.tmpl @@ -0,0 +1,26 @@ +#!/bin/sh +# Installs the Linux CLI toolset whenever packages-apt.txt changes. +# Homebrew is deliberately macOS-only in this setup; Linux/WSL use the +# distro's native package manager with native package names. +# packages-apt.txt hash: {{ include "packages-apt.txt" | sha256sum }} +set -e + +if ! command -v apt-get > /dev/null 2>&1; then + echo "No apt-get on this system — install packages-apt.txt equivalents manually."; + exit 0; +fi + +if [ "$(id -u)" -ne 0 ] && ! sudo -n true 2> /dev/null; then + echo "No root and no passwordless sudo — skipping package install."; + echo "Run manually: xargs -a packages-apt.txt sudo apt-get install -y"; + exit 0; +fi + +SUDO=""; +[ "$(id -u)" -ne 0 ] && SUDO="sudo"; + +$SUDO apt-get update -qq; +# Per-package so one missing name (distro drift) doesn't abort the rest. +grep -vE '^[[:space:]]*(#|$)' {{ joinPath .chezmoi.sourceDir "packages-apt.txt" | quote }} | while read -r pkg; do + $SUDO apt-get install -y -qq "$pkg" || echo "skipped (unavailable): $pkg"; +done diff --git a/CLAUDE.md b/CLAUDE.md index 5e8d31cc3b6..52b549b79e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,8 @@ VMs; the test skips everywhere else unless `MACOS_APPLY_OK=1` is set. - **`.chezmoiignore`** — target paths chezmoi must not manage (repo-level files everywhere; macOS GUI config off-mac; identity/secrets on `ephemeral`) - **`bootstrap.sh`** — deprecated wrapper around `chezmoi init --source . --apply` - **`.macos`** — macOS `defaults write` settings; reads `$COMPUTER_NAME` env var for machine-specific naming -- **`Brewfile`** — Homebrew formulae, casks, and Mac App Store apps +- **`Brewfile`** — Homebrew formulae, casks, and Mac App Store apps (macOS only; `.chezmoiscripts/darwin/` runs `brew bundle` when it changes) +- **`packages-apt.txt`** — Debian/Ubuntu/WSL package list (`.chezmoiscripts/linux/` installs it when it changes; skips gracefully without apt or sudo) - **`bin/`** — personal scripts added to `$PATH` - **Claude Code statusline** — lives in `icanalytica/ica-skills` (skill `claude-statusline`), not here. Install once per machine with `/claude-statusline`; the plugin's session-start hook keeps the installed copies in `~/.claude` current after that. On a fresh machine, `.claude/settings.json`'s statusLine command is harmlessly dead until that one-time install. - **`init/`** — one-time setup scripts @@ -112,6 +113,18 @@ set `user.signingkey` to the literal public key (not `op-ssh-sign`, which only exists locally). There is **no GPG keypair** in this setup despite the `gpgsign` name. +### Package lists + +CLI tools are declared per-OS — `Brewfile` (macOS), `packages-apt.txt` +(Debian/Ubuntu/WSL). **When adding or removing a tool, update every list +where it's available**, using each distro's native package name (e.g. +Ubuntu's `bat` package installs the binary as `batcat`; the shell config's +`command -v` guards tolerate the difference). If a tool exists on one +platform only, note it in the list file. Homebrew is deliberately not used +on Linux. The install scripts re-run automatically on `chezmoi apply` +whenever their list's hash changes; the `ephemeral` machine class never +runs them. + ### Machine-local customization Add `~/.extra` (not committed) for per-machine overrides. Add `~/.path` for per-machine PATH entries. The `.macos` script skips the computer name block if `$COMPUTER_NAME` is unset. diff --git a/packages-apt.txt b/packages-apt.txt new file mode 100644 index 00000000000..d1ad9159f29 --- /dev/null +++ b/packages-apt.txt @@ -0,0 +1,24 @@ +# Debian/Ubuntu/WSL package list — the Linux counterpart of the Brewfile. +# Keep the two in sync: when adding or removing a CLI tool, update every +# list where the tool is available, using each distro's NATIVE package +# name (e.g. the bat package here installs the binary as batcat; the +# shell config's command -v guards tolerate either). +# Consumed by .chezmoiscripts/linux/run_onchange_install-packages.sh. + +zsh +git +curl +wget +vim +tmux +tree +jq +fzf +bat +ripgrep +fd-find +zoxide +direnv +git-delta +xclip +# lazygit and k9s are not in Ubuntu's repos — install manually if wanted diff --git a/tests/chezmoi.bats b/tests/chezmoi.bats index ef959547242..94b025f7312 100644 --- a/tests/chezmoi.bats +++ b/tests/chezmoi.bats @@ -1,7 +1,9 @@ #!/usr/bin/env bats # Full init + apply into an isolated $HOME per machine class, so the real -# machine is never touched and no real config is read or written. XDG dirs +# machine is never touched and no real config is read or written. Applies +# use --exclude scripts: the .chezmoiscripts package installers must never +# run brew/apt inside a test. XDG dirs # must be pinned too, not just HOME: GitHub's ubuntu runners export # XDG_CONFIG_HOME, which chezmoi prefers over $HOME/.config — without the # override, the first test's machineClass leaks into every later test via @@ -27,7 +29,7 @@ teardown() { @test "ephemeral class deploys shell config and nothing sensitive" { cd "$BATS_TEST_DIRNAME/.." - chez init --source "$PWD" --promptString machineClass=ephemeral --apply + chez init --source "$PWD" --promptString machineClass=ephemeral --apply --exclude scripts # Shell layer lands [ -f "$TMPHOME/.zshrc" ] [ -f "$TMPHOME/.aliases" ] @@ -44,13 +46,14 @@ teardown() { # Repo-level files never deploy [ ! -e "$TMPHOME/README.md" ] [ ! -e "$TMPHOME/Brewfile" ] + [ ! -e "$TMPHOME/packages-apt.txt" ] [ ! -e "$TMPHOME/tests" ] [ ! -e "$TMPHOME/.macos" ] } @test "linux class deploys identity without signing" { cd "$BATS_TEST_DIRNAME/.." - chez init --source "$PWD" --promptString machineClass=linux --apply + chez init --source "$PWD" --promptString machineClass=linux --apply --exclude scripts [ -f "$TMPHOME/.gitconfig.local" ] grep -q "email = evan.alter@gmail.com" "$TMPHOME/.gitconfig.local" grep -q "user = hadees" "$TMPHOME/.gitconfig.local" @@ -66,7 +69,7 @@ teardown() { @test "wsl class wires git through Windows ssh.exe" { cd "$BATS_TEST_DIRNAME/.." - chez init --source "$PWD" --promptString machineClass=wsl --apply + chez init --source "$PWD" --promptString machineClass=wsl --apply --exclude scripts grep -q "sshCommand = ssh.exe" "$TMPHOME/.gitconfig.local" ! grep -q "gpgsign = true" "$TMPHOME/.gitconfig.local" } @@ -75,6 +78,6 @@ teardown() { cd "$BATS_TEST_DIRNAME/.." mkdir -p "$TMPHOME/.claude" echo "my custom notes" > "$TMPHOME/.claude/CLAUDE.local.md" - chez init --source "$PWD" --promptString machineClass=linux --apply + chez init --source "$PWD" --promptString machineClass=linux --apply --exclude scripts grep -q "my custom notes" "$TMPHOME/.claude/CLAUDE.local.md" } diff --git a/tests/packages.bats b/tests/packages.bats new file mode 100644 index 00000000000..278793a5ecb --- /dev/null +++ b/tests/packages.bats @@ -0,0 +1,13 @@ +#!/usr/bin/env bats + +# Sanity floor for the per-OS package lists: non-empty, one package per +# line, no duplicates. The Brewfile is covered by brew bundle itself. + +@test "packages-apt.txt is non-empty with no duplicates" { + cd "$BATS_TEST_DIRNAME/.." + pkgs=$(grep -vE '^[[:space:]]*(#|$)' packages-apt.txt) + [ -n "$pkgs" ] + [ -z "$(echo "$pkgs" | sort | uniq -d)" ] + # One package name per line, no stray whitespace + ! echo "$pkgs" | grep -qE '[[:space:]]' +}