diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bccd715..c3287f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,9 @@ jobs: ca-certificates \ curl \ gnupg \ - shellcheck + shellcheck \ + python3 \ + zsh - name: Cache chezmoi binary id: cache-chezmoi diff --git a/README.md b/README.md index 6c8e2ae..829a013 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [![Chezmoi](https://img.shields.io/badge/managed%20with-chezmoi-6e4c1e)](https://www.chezmoi.io/) ![Platform](https://img.shields.io/badge/platform-macOS%20%2B%20Linux-lightgrey) -Personal macOS development environment managed with Chezmoi and Homebrew. -Shell, Git, editor, and XDG configuration also work on Linux where supported. +Personal development environment managed with Chezmoi: Homebrew on macOS, +native apt packages on Ubuntu 26.04. Shell, Git, editor, and XDG configuration +are shared across both systems. ## Overview @@ -15,7 +16,7 @@ Shell, Git, editor, and XDG configuration also work on Linux where supported. | Shell | Zsh, Oh My Zsh, Starship, fzf, mise, and direnv | | Editors | Zed, VS Code, Vim, and Neovim | | Shortcuts | Raycast Caps Lock Hyper translated by Ghostty into tmux commands | -| Packages | Homebrew bundle with personal and work profiles | +| Packages | Homebrew profiles on macOS; common native apt packages on Ubuntu | | SSH | Portable client config; keys stay local to each machine | | macOS | Dock, Finder, keyboard, Safari, and general defaults | @@ -35,6 +36,72 @@ settings in `~/.gitconfig.local`. On new machines, the root config includes repository defaults from the managed `~/.config/git/dotfiles.gitconfig`. Omit `--macos` to apply the dotfiles without changing macOS defaults. +### Ubuntu 26.04 + +Install the bootstrap prerequisites and Chezmoi, then preview before applying: + +```bash +sudo apt-get update +sudo apt-get install -y git curl ca-certificates +sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" +export PATH="$HOME/.local/bin:$PATH" +git clone https://github.com/froppa/dotfiles.git ~/.local/share/chezmoi +cd ~/.local/share/chezmoi +chezmoi init --source="$PWD" --promptString 'name=sdf' +chezmoi diff +chezmoi apply -v +``` + +If already cloned, use that checkout instead of cloning again. Ubuntu setup +installs missing packages with apt and never installs or updates Linuxbrew. +An existing Linuxbrew installation is left on disk, but managed Zsh startup +no longer adds it to PATH. Personal and work profiles currently share the same +Ubuntu package set. Raycast, Hammerspoon, and iTerm2 configuration is macOS-only. + +The native set covers shell, editor, Git, and common build tools. It does not +add third-party repositories or install mise, Codex, Claude Code, Docker, +OpenTofu, AWS CLI, lazygit, Ghostty, GUI editors, or Nerd Fonts. These remain +separate bootstrap steps; Ubuntu's `yq` is also not assumed equivalent to the +Homebrew formula. Once mise is installed, `mise install` installs the declared +runtimes; copying its configuration alone does not install them. Some Neovim +plugins need those runtimes before their first launch. + +After applying, choose Zsh as your login shell if desired: + +```bash +chsh -s "$(command -v zsh)" +``` + +Log out and back in. `fd` and `bat` aliases handle Ubuntu's executable names, +and fzf uses an executable path that also works in its subprocesses. The Linux +`update` alias uses apt. Mac-specific Hyper shortcuts require separate desktop +key bindings on Ubuntu. + +Authenticate GitHub, Codex, and Claude freshly on Ubuntu as your normal user. +Do not copy tokens, private keys, or provider credential stores from another +machine. SSH access and the SSH server belong to the machine's infrastructure +setup; these dotfiles manage only portable client configuration. A remote +tmux session continues when you disconnect, but not when the Ubuntu VM shuts +down. + +### Ubuntu AI tools + +After applying the native package configuration, install Claude Code and mise +through their signed apt repositories, and Codex through its official standalone +installer: + +```bash +./scripts/bootstrap-ubuntu-ai.sh +codex login +claude auth login +gh auth login --web +``` + +This does not authenticate automatically or copy credentials from your Mac. +Codex is pinned to the validated CLI version in the script. Runtime installation +is separate; do not run source-building plugin installers until the required +Depot build wrapper is available on the machine. + ### SSH SSH keys are never managed by this repository. Generate a new key on each diff --git a/home/.chezmoidata/40-packages.yml b/home/.chezmoidata/40-packages.yml index e2f33ad..ecf1e84 100644 --- a/home/.chezmoidata/40-packages.yml +++ b/home/.chezmoidata/40-packages.yml @@ -63,6 +63,54 @@ packages: - chezmoi - shellcheck + # Ubuntu package names are explicit: Brew formula names are not portable. + # Vendor tools and language runtimes are documented separately in README.md. + ubuntu: + apt: + - ca-certificates + - curl + - unzip + - coreutils + - findutils + - sed + - grep + - moreutils + - tree + - build-essential + - ripgrep + - zsh + - starship + - tmux + - fzf + - direnv + - ncdu + - gnupg + - openssh-client + - openssl + - nmap + - age + - bat + - fd-find + - jq + - htop + - gh + - git + - git-lfs + - pipx + - eza + - httpie + - vim + - neovim + - cmake + - m4 + - autoconf + - automake + - libtool + - libboost-dev + - pkgconf + - sccache + - shellcheck + casks: - ghostty - raycast diff --git a/home/.chezmoiignore b/home/.chezmoiignore index e70428a..368568d 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -12,3 +12,9 @@ # stray one read as a file to delete on the next apply. **/.DS_Store .oh-my-zsh/cache/** + +{{ if ne .chezmoi.os "darwin" -}} +.hammerspoon +.config/raycast +.config/iterm2 +{{ end -}} diff --git a/home/.chezmoiscripts/run_once_10-install-homebrew.sh b/home/.chezmoiscripts/run_once_10-install-homebrew.sh index 023e67d..fbd004b 100644 --- a/home/.chezmoiscripts/run_once_10-install-homebrew.sh +++ b/home/.chezmoiscripts/run_once_10-install-homebrew.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +# Ubuntu uses apt; never install or update Linuxbrew during bootstrap. +[[ "$(uname -s)" == Darwin ]] || exit 0 + if ! command -v brew &>/dev/null; then echo "=> Homebrew installing..." bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" diff --git a/home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl b/home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl index 40a0984..c9024ab 100644 --- a/home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl +++ b/home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +{{ if eq .chezmoi.os "darwin" -}} command -v brew >/dev/null 2>&1 || { echo "=> brew not found, skipping package install" exit 0 @@ -43,3 +44,25 @@ cask "{{ . }}" BREWFILE brew cleanup +{{ else if and (eq .chezmoi.os "linux") (eq .chezmoi.osRelease.id "ubuntu") -}} +packages=( +{{ range .packages.ubuntu.apt }} "{{ . }}" +{{ end -}} +) +missing=() +for package in "${packages[@]}"; do + if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != "install ok installed" ]]; then + missing+=("$package") + fi +done + +if [[ ${#missing[@]} -gt 0 ]]; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends "${missing[@]}" +else + echo "=> Ubuntu packages already installed" +fi +{{ else -}} +echo "Package bootstrap supports macOS and Ubuntu; install packages manually on this OS." >&2 +exit 1 +{{ end -}} diff --git a/home/dot_aliases b/home/dot_aliases index 2bc9cd2..063f1cd 100644 --- a/home/dot_aliases +++ b/home/dot_aliases @@ -135,7 +135,12 @@ if [[ "$OSTYPE" == darwin* ]]; then alias afk='/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend' fi -alias brewup='brew update && brew upgrade && brew cleanup' +if [[ "$OSTYPE" == linux* ]] && command -v apt-get >/dev/null 2>&1; then + alias update='sudo apt-get update && sudo apt-get upgrade' +fi +if [[ "$OSTYPE" == darwin* ]]; then + alias brewup='brew update && brew upgrade && brew cleanup' +fi alias cleanup="find . -type f -name '*.DS_Store' -ls -delete" [[ -f "$HOME/.aliases.local" ]] && source "$HOME/.aliases.local" diff --git a/home/dot_config/mise/config.toml b/home/dot_config/mise/config.toml deleted file mode 100644 index 98ad84b..0000000 --- a/home/dot_config/mise/config.toml +++ /dev/null @@ -1,13 +0,0 @@ -# ~/.config/mise/config.toml -# Managed by chezmoi — global default runtimes (replaces nvm/pyenv/brew runtimes) -[tools] -node = "lts" -python = "3.12" -go = "latest" -ruby = "latest" -pnpm = "latest" -yarn = "latest" -java = "temurin-21" - -[settings.ruby] -compile = false diff --git a/home/dot_config/mise/config.toml.tmpl b/home/dot_config/mise/config.toml.tmpl new file mode 100644 index 0000000..b221a09 --- /dev/null +++ b/home/dot_config/mise/config.toml.tmpl @@ -0,0 +1,27 @@ +{{- $ubuntu := and (eq .chezmoi.os "linux") (eq .chezmoi.osRelease.id "ubuntu") -}} +# ~/.config/mise/config.toml +# Managed by chezmoi — global default runtimes (replaces nvm/pyenv/brew runtimes) +[tools] +node = "lts" +python = "3.12" +go = "latest" +ruby = "latest" +{{ if $ubuntu -}} +"aqua:pnpm/pnpm" = "latest" +rust = "stable" +bun = "latest" +{{ else -}} +pnpm = "latest" +yarn = "latest" +{{ end -}} +java = "temurin-21" + +[settings.ruby] +compile = false +{{ if $ubuntu }} +[settings.node] +compile = false + +[settings.python] +compile = false +{{ end -}} diff --git a/home/dot_zprofile b/home/dot_zprofile index 2d5ac4f..65faa82 100644 --- a/home/dot_zprofile +++ b/home/dot_zprofile @@ -2,20 +2,20 @@ # Managed by chezmoi # Login shell initialization only -# Homebrew -if [[ -x /opt/homebrew/bin/brew ]]; then - eval "$(/opt/homebrew/bin/brew shellenv)" -elif [[ -x /usr/local/bin/brew ]]; then - eval "$(/usr/local/bin/brew shellenv)" -elif [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" -fi +# Homebrew is the macOS package manager; Ubuntu uses apt. +if [[ "$OSTYPE" == darwin* ]]; then + if [[ -x /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -x /usr/local/bin/brew ]]; then + eval "$(/usr/local/bin/brew shellenv)" + fi -# Homebrew zsh completions -if (( $+commands[brew] )); then - brew_prefix="$(brew --prefix 2>/dev/null)" - if [[ -n "$brew_prefix" && -d "$brew_prefix/share/zsh/site-functions" ]]; then - fpath=("$brew_prefix/share/zsh/site-functions" $fpath) + # Homebrew zsh completions + if (( $+commands[brew] )); then + brew_prefix="$(brew --prefix 2>/dev/null)" + if [[ -n "$brew_prefix" && -d "$brew_prefix/share/zsh/site-functions" ]]; then + fpath=("$brew_prefix/share/zsh/site-functions" $fpath) + fi fi fi diff --git a/home/dot_zshrc b/home/dot_zshrc index 3cc65ae..8962c00 100644 --- a/home/dot_zshrc +++ b/home/dot_zshrc @@ -93,7 +93,9 @@ fi # ======================================= alias g="git" -command -v fdfind >/dev/null 2>&1 && alias fd="fdfind" +# Ubuntu exposes these programs under different executable names. +(( ! $+commands[fd] && $+commands[fdfind] )) && alias fd="fdfind" +(( ! $+commands[bat] && $+commands[batcat] )) && alias bat="batcat" gpgkeys() { gpg -K --with-colons 2>/dev/null | awk -F: '$1 == "sec" { print $5 }' @@ -138,11 +140,14 @@ bindkey "^[[1;5D" backward-word # ======================================= if command -v fzf >/dev/null 2>&1; then source <(fzf --zsh) - if command -v fd >/dev/null 2>&1; then - export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git' + # fzf launches a child shell, so a Zsh alias alone cannot provide fd. + fd_command="${commands[fd]:-${commands[fdfind]:-}}" + if [[ -n "$fd_command" ]]; then + export FZF_DEFAULT_COMMAND="${(q)fd_command} --type f --hidden --follow --exclude .git" export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND" - export FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git' + export FZF_ALT_C_COMMAND="${(q)fd_command} --type d --hidden --follow --exclude .git" fi + unset fd_command fi # ======================================= diff --git a/scripts/bootstrap-ubuntu-ai.sh b/scripts/bootstrap-ubuntu-ai.sh new file mode 100755 index 0000000..899026e --- /dev/null +++ b/scripts/bootstrap-ubuntu-ai.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Native Ubuntu agent tools; authenticate as the normal user after installation. +set -euo pipefail + +[[ $EUID -ne 0 ]] || { echo "Run as your normal Ubuntu user, not root." >&2; exit 1; } +[[ -r /etc/os-release ]] || { echo "Ubuntu is required." >&2; exit 1; } +# shellcheck disable=SC1091 +source /etc/os-release +[[ "$ID" == ubuntu && "$VERSION_ID" == 26.04 ]] || { + echo "This bootstrap is validated for Ubuntu 26.04." >&2; exit 1; +} + +sudo apt-get update +sudo apt-get install -y --no-install-recommends curl gnupg ca-certificates software-properties-common +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +curl -fsSL https://downloads.claude.ai/keys/claude-code.asc -o "$scratch/claude-code.asc" +fingerprint=$(gpg --show-keys --with-colons "$scratch/claude-code.asc" 2>/dev/null | awk -F: '$1 == "fpr" {print $10; exit}') +[[ "$fingerprint" == 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE ]] || { + echo "Unexpected Claude signing key; no repository was trusted." >&2; exit 1; +} +sudo install -d -m 0755 /etc/apt/keyrings +sudo install -m 0644 "$scratch/claude-code.asc" /etc/apt/keyrings/claude-code.asc +printf '%s\n' 'deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main' | + sudo tee /etc/apt/sources.list.d/claude-code.list >/dev/null +sudo add-apt-repository -y --no-update ppa:jdxcode/mise +sudo apt-get update +sudo apt-get install -y --no-install-recommends claude-code mise + +# Codex publishes a standalone Linux installer, not an apt package. +export PATH="$HOME/.local/bin:$PATH" +curl -fsSL https://chatgpt.com/codex/install.sh -o "$scratch/install-codex.sh" +CODEX_NON_INTERACTIVE=1 sh "$scratch/install-codex.sh" --release 0.154.0 +codex --version +claude --version +mise --version +printf '%s\n' 'Authenticate freshly: codex login; claude auth login; gh auth login --web' diff --git a/test.sh b/test.sh index 2446db9..a5f791b 100755 --- a/test.sh +++ b/test.sh @@ -16,6 +16,8 @@ section "Checking required tools" command -v shellcheck >/dev/null 2>&1 || fail "shellcheck is required" command -v chezmoi >/dev/null 2>&1 || fail "chezmoi is required" +command -v python3 >/dev/null 2>&1 || fail "python3 is required" +command -v zsh >/dev/null 2>&1 || fail "zsh is required" section "Running shellcheck" @@ -111,7 +113,9 @@ fi ! grep -Fq -- '--exclude encrypted' README.md || fail "the documented install must apply cleanly without an age identity" sed -n '/^ darwin:/,/^ personal:/p' home/.chezmoidata/40-packages.yml | grep -Fq -- '- xcode-build-server' || fail "xcode-build-server must be macOS-only" grep -Fq '/usr/local/bin/brew' home/.chezmoiscripts/run_once_10-install-homebrew.sh || fail "Homebrew setup must support Intel macOS" -grep -Fq '/home/linuxbrew/.linuxbrew/bin/brew' home/.chezmoiscripts/run_once_10-install-homebrew.sh || fail "Homebrew setup must support Linux" + +section "Testing Ubuntu and macOS bootstrap behavior" +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v section "Testing fresh-home chezmoi dry-run apply" diff --git a/tests/test_ubuntu_bootstrap.py b/tests/test_ubuntu_bootstrap.py new file mode 100644 index 0000000..076327d --- /dev/null +++ b/tests/test_ubuntu_bootstrap.py @@ -0,0 +1,182 @@ +"""Exercise platform routing without touching package managers or the real home.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import tomllib +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CHEZMOI = shutil.which("chezmoi") +SHELLCHECK = shutil.which("shellcheck") +ZSH = shutil.which("zsh") + + +class PackageBootstrapTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="dotfiles platform-") + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.bin = self.base / "bin" + self.bin.mkdir() + self.log = self.base / "commands" + self.config = self.base / "config.toml" + self.config.write_text("") + self.env = dict(os.environ, HOME=str(self.base), PATH=f"{self.bin}:/usr/bin:/bin", + TEST_LOG=str(self.log), BREWFILE=str(self.base / "Brewfile")) + self.stub("sudo", 'printf "sudo %s\\n" "$*" >> "$TEST_LOG"\nexec "$@"\n') + self.stub("apt-get", 'printf "apt-get %s\\n" "$*" >> "$TEST_LOG"\n' + 'if [ "$1" = update ] && [ "${FAIL_UPDATE:-0}" = 1 ]; then exit 9; fi\n') + self.stub("dpkg-query", 'if [ "${ALL_INSTALLED:-0}" = 1 ] || [ "$3" != fd-find ]; then\n' + ' printf "install ok installed"\nelse exit 1; fi\n') + self.stub("brew", 'printf "brew %s\\n" "$*" >> "$TEST_LOG"\n' + 'if [ "$1" = bundle ]; then cat > "$BREWFILE"; fi\n') + self.stub("uname", 'printf "%s\\n" "${TEST_KERNEL:-Linux}"\n') + self.stub("curl", 'echo "unexpected curl" >> "$TEST_LOG"; exit 77\n') + + def stub(self, name, body): + path = self.bin / name + path.write_text("#!/bin/sh\nset -eu\n" + body) + path.chmod(0o755) + + def render(self, name, system="linux", distro="ubuntu", work=False): + data = {"chezmoi": {"os": system, "osRelease": {"id": distro}}, + "personal": not work, "work": work} + return subprocess.check_output([ + CHEZMOI, "--source", str(ROOT), "--config", str(self.config), + "--override-data", json.dumps(data), "execute-template", "--file", + str(ROOT / name)], text=True) + + def execute(self, script): + return subprocess.run(["/bin/bash"], input=script, text=True, env=self.env, + capture_output=True) + + def commands(self): + return self.log.read_text() if self.log.exists() else "" + + def test_ubuntu_installs_only_missing_native_packages(self): + for work in (False, True): + with self.subTest(work=work): + self.log.unlink(missing_ok=True) + result = self.execute(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl", work=work)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands().splitlines(), [ + "sudo apt-get update", "apt-get update", + "sudo apt-get install -y --no-install-recommends fd-find", + "apt-get install -y --no-install-recommends fd-find"]) + + def test_installed_ubuntu_packages_do_not_call_sudo_or_brew(self): + self.env["ALL_INSTALLED"] = "1" + result = self.execute(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl")) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands(), "") + + def test_apt_update_failure_stops_install(self): + self.env["FAIL_UPDATE"] = "1" + result = self.execute(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl")) + self.assertEqual(result.returncode, 9) + self.assertNotIn("apt-get install", self.commands()) + + def test_linux_never_bootstraps_homebrew(self): + result = self.execute((ROOT / "home/.chezmoiscripts/run_once_10-install-homebrew.sh").read_text()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands(), "") + + def test_darwin_preserves_brew_profiles(self): + for work in (False, True): + with self.subTest(work=work): + self.log.unlink(missing_ok=True) + result = self.execute(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl", system="darwin", work=work)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands().splitlines(), ["brew bundle --file=/dev/stdin", "brew cleanup"]) + brewfile = (self.base / "Brewfile").read_text() + self.assertIn('brew "xcode-build-server"', brewfile) + self.assertIn('cask "raycast"', brewfile) + self.assertIn('brew "awscli"' if work else 'brew "opentofu"', brewfile) + self.assertNotIn('brew "opentofu"' if work else 'brew "awscli"', brewfile) + + def test_unsupported_linux_fails_without_package_mutations(self): + result = self.execute(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl", distro="fedora")) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.commands(), "") + + def test_ubuntu_shell_aliases_and_fzf_subprocess(self): + self.stub("fdfind", 'printf "fdfind %s\\n" "$*" >> "$TEST_LOG"\n') + self.stub("batcat", 'printf "batcat %s\\n" "$*" >> "$TEST_LOG"\n') + for name in ("fzf", "gpgconf", "mise", "direnv", "starship"): + self.stub(name, "exit 0\n") + result = subprocess.run([ZSH, "-f", "-c", + 'source "$1"; eval "fd --version"; eval "bat --version"; ' + 'sh -c "$FZF_DEFAULT_COMMAND"; sh -c "$FZF_ALT_C_COMMAND"', + "test", str(ROOT / "home/dot_zshrc")], env=self.env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands().splitlines(), [ + "fdfind --version", "batcat --version", + "fdfind --type f --hidden --follow --exclude .git", + "fdfind --type d --hidden --follow --exclude .git"]) + + def test_linux_login_does_not_initialize_brew(self): + result = subprocess.run([ZSH, "-f", "-c", 'OSTYPE=linux-gnu; source "$1"; true', + "test", str(ROOT / "home/dot_zprofile")], env=self.env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.commands(), "") + + def test_ubuntu_update_alias_uses_apt(self): + result = subprocess.run([ZSH, "-f", "-c", + 'OSTYPE=linux-gnu; source "$1"; eval update; alias brewup', + "test", str(ROOT / "home/dot_aliases")], env=self.env, text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) # brewup must not exist on Ubuntu. + self.assertEqual(self.commands().splitlines(), [ + "sudo apt-get update", "apt-get update", "sudo apt-get upgrade", "apt-get upgrade"]) + + def test_managed_files_exclude_mac_only_apps_on_linux(self): + for system in ("darwin", "linux"): + with self.subTest(system=system): + data = {"chezmoi": {"os": system, "osRelease": {"id": "ubuntu"}}, + "name": "CI", "email": "ci@example.invalid", "personal": True, "work": False} + output = subprocess.check_output([ + CHEZMOI, "--source", str(ROOT), "--config", str(self.config), + "--destination", str(self.base), "--override-data", json.dumps(data), + "managed", "--exclude=externals", "--path-style=relative"], text=True) + paths = output.splitlines() + for app in (".hammerspoon/init.lua", ".config/iterm2/com.googlecode.iterm2.plist", + ".config/raycast/scripts/quick-capture.sh"): + self.assertEqual(app in paths, system == "darwin", app) + self.assertIn(".config/nvim/init.lua", paths) + self.assertIn(".config/ghostty/config.ghostty", paths) + + def test_mise_preserves_mac_and_scopes_runtime_changes_to_ubuntu(self): + original = '# ~/.config/mise/config.toml\n# Managed by chezmoi — global default runtimes (replaces nvm/pyenv/brew runtimes)\n[tools]\nnode = "lts"\npython = "3.12"\ngo = "latest"\nruby = "latest"\npnpm = "latest"\nyarn = "latest"\njava = "temurin-21"\n\n[settings.ruby]\ncompile = false\n' + for system, distro in (("darwin", ""), ("linux", "fedora")): + with self.subTest(system=system, distro=distro): + self.assertEqual(self.render("home/dot_config/mise/config.toml.tmpl", + system=system, distro=distro), original) + expected = tomllib.loads(original) + del expected["tools"]["yarn"] + del expected["tools"]["pnpm"] + expected["tools"].update({"rust": "stable", "bun": "latest", "aqua:pnpm/pnpm": "latest"}) + expected["settings"].update(node={"compile": False}, python={"compile": False}) + for work in (False, True): + with self.subTest(work=work): + rendered = self.render("home/dot_config/mise/config.toml.tmpl", work=work) + self.assertEqual(tomllib.loads(rendered), expected) + + def test_rendered_shell_templates_pass_shellcheck(self): + for system, distro in (("darwin", ""), ("linux", "ubuntu"), ("linux", "fedora")): + for work in (False, True): + with self.subTest(system=system, work=work): + script = self.base / "packages.sh" + script.write_text(self.render( + "home/.chezmoiscripts/run_onchange_20-install-pkgs.sh.tmpl", system=system, distro=distro, work=work)) + subprocess.run([SHELLCHECK, str(script)], check=True, capture_output=True, text=True) + + +if __name__ == "__main__": + unittest.main()