diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 27498c0..2376783 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,10 +5,53 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned so CI and `brew install shellcheck` locally agree. The version + # preinstalled on ubuntu-latest is older and flags different rules, which + # makes a locally-clean script fail in CI. + SHELLCHECK_VERSION: v0.11.0 + jobs: - shellcheck: + shell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Install pinned shellcheck + run: | + url="https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + curl -fsSL "$url" | tar -xJ + sudo install "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/ + shellcheck --version + - name: Run shellcheck - run: shellcheck bootstrap.sh macos/*.sh + run: shellcheck bootstrap.sh doctor.sh macos/*.sh + + - name: Check zsh syntax + run: | + sudo apt-get update -qq + sudo apt-get install -y zsh + zsh -n zsh/.zshrc + zsh -n zsh/.zprofile + zsh -n zsh/.aliases + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Resolves every entry against Homebrew without installing anything. + - name: Validate Brewfiles + run: | + brew bundle list --file=Brewfile + brew bundle list --file=Brewfile.personal + + - name: Lint property lists + run: plutil -lint macos/launch-clipy/Info.plist macos/launch-clipy/document.wflow diff --git a/.gitignore b/.gitignore index bb3c5dd..a11bf79 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ config/.nvim/ # Brew bundle lockfile (regenerated on each `brew bundle`) Brewfile.lock.json + +# Per-machine tier record written by bootstrap.sh (PERSONAL=yes|no) +.machine diff --git a/Brewfile b/Brewfile index 1ca4288..7eeb340 100644 --- a/Brewfile +++ b/Brewfile @@ -1,21 +1,71 @@ -# Declarative macOS install list. Applied by `brew bundle` from bootstrap.sh. +# Core install list — every machine gets these, work or personal. +# Applied by `brew bundle` from bootstrap.sh. # Re-run any time: `brew bundle --file=~/Development/dotfiles/Brewfile` +# +# Optional media/games/creative apps live in Brewfile.personal; bootstrap.sh +# asks once whether to include them and records the answer in .machine. +# +# `mas` entries require the App Store to be signed in first — `brew bundle` +# cannot sign in for you. -# ---- CLI tools ---- +# ---- Shell & dotfile plumbing ---- brew "stow" # symlink manager — deploys dotfiles packages into $HOME brew "zsh" # Homebrew zsh for a current release independent of macOS brew "tmux" # terminal multiplexer -brew "neovim" # editor (configured via nvim/ package) -brew "dockutil" # CLI for managing Dock icons (used by macos/defaults.sh) brew "reattach-to-user-namespace" # tmux pbcopy bridge -brew "gh" # GitHub CLI — auth, PRs, issues -brew "mas" # Mac App Store CLI +brew "git" # .gitconfig needs >= 2.38 (rebase.updateRefs, zdiff3); macOS ships Xcode's git + +# ---- Editor & search ---- +brew "neovim" # editor (configured via nvim/ package) +brew "ripgrep" # kickstart.nvim live-grep +brew "fd" # kickstart.nvim file finder +brew "tree-sitter-cli" # nvim treesitter parser builds + +# ---- Dev tooling ---- +brew "gh" # GitHub CLI — auth, PRs, issues +brew "node" +brew "shellcheck" # lints this repo's scripts (also run in CI) +brew "xcodegen" # generate .xcodeproj from a spec +brew "helm" +brew "k9s" +brew "kubernetes-cli" + +# ---- macOS setup helpers ---- +brew "dockutil" # CLI for managing Dock icons (used by macos/defaults.sh) +brew "mas" # Mac App Store CLI (used by the mas entries below) +brew "terminal-notifier" # aliased in zsh/.aliases # ---- GUI apps ---- -cask "ghostty" # GPU-accelerated terminal emulator -cask "clipy" # clipboard manager with history -cask "sizeup" # keyboard-driven window manager -cask "mullvad-vpn" # privacy VPN +cask "ghostty" # GPU-accelerated terminal emulator +cask "claude-code@latest" +cask "google-chrome" +cask "docker-desktop" +cask "1password" +cask "jordanbaird-ice" # Ice — menu bar manager +cask "sizeup" # keyboard-driven window manager +cask "clipy" # clipboard manager with history +cask "thaw" # unquarantine downloaded apps +cask "keyboardcleantool" # blocks input for keyboard cleaning +cask "hhkb" # Happy Hacking Keyboard configurator +cask "logi-options+" # Logitech input device config +cask "mullvad-vpn" # privacy VPN +cask "pearcleaner" # app uninstaller +cask "grandperspective" # disk usage visualiser +cask "xcodes-app" # Xcode version manager +cask "zoom" # ---- Mac App Store ---- -mas "Amphetamine", id: 937984704 # prevent sleep / screen saver +mas "Amphetamine", id: 937984704 # prevent sleep / screen saver +mas "Balance Lock", id: 1019371109 # keep audio balance centred +mas "Little Snitch Mini", id: 1629008763 # outbound connection monitor +mas "Tomito", id: 1526042938 # pomodoro timer + +# Safari extensions +mas "1Password for Safari", id: 1569813296 +mas "Wipr", id: 1662217862 +mas "uBlock Origin Lite", id: 6745342698 +mas "Dark Reader for Safari", id: 1438243180 +mas "UnTrap", id: 1637438059 +mas "SponsorBlock", id: 1573461917 +mas "Control Panel for Twitter", id: 1668516167 +mas "Userscripts-Mac-App", id: 1463298887 diff --git a/Brewfile.personal b/Brewfile.personal new file mode 100644 index 0000000..79ef748 --- /dev/null +++ b/Brewfile.personal @@ -0,0 +1,32 @@ +# Opt-in install list — media, games, and creative apps. +# +# Skipped on work machines. bootstrap.sh asks once whether to include this +# tier and records the answer in .machine (gitignored); pass --personal or +# --no-personal to set it explicitly, or run on demand: +# +# brew bundle --file=~/Development/dotfiles/Brewfile.personal +# +# `mas` entries require the App Store to be signed in first. + +# ---- CLI ---- +brew "exiftool" # read/write image and video metadata + +# ---- GUI apps ---- +cask "adobe-creative-cloud" +cask "iina" # video player +cask "openemu" # retro game emulator +cask "steam" +cask "stremio" +cask "transmission" # BitTorrent client +cask "whatsapp" + +# ---- Mac App Store ---- +mas "Logic Pro", id: 634148309 # multi-GB download +mas "Infuse", id: 1136220934 +mas "Highland 2", id: 1171820258 + +# ---- Manual installs (no Homebrew cask exists) ---- +# These have to be downloaded and installed by hand: +# - DaVinci Resolve https://www.blackmagicdesign.com/products/davinciresolve +# - Blackmagic RAW https://www.blackmagicdesign.com/support +# - Blackmagic Proxy Generator https://www.blackmagicdesign.com/support diff --git a/README.md b/README.md index 3c6da06..856062f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,17 @@ cd ~/Development/dotfiles exec zsh ``` -`bootstrap.sh` detects your platform, installs dependencies (via `brew bundle` on macOS — see [`Brewfile`](Brewfile) — or `apt-get` on Linux/WSL), initializes submodules, symlinks every package, and sets zsh as the default shell. On macOS it also prompts to apply system defaults — see [macOS Configuration](#macos-configuration). +`bootstrap.sh` detects your platform, installs dependencies (via `brew bundle` on macOS — see [`Brewfile`](Brewfile) — or `apt-get` on Linux/WSL), initializes submodules, symlinks every package, and sets zsh as the default shell. On macOS it also asks whether this is a personal machine, and prompts to apply system defaults — see [macOS Configuration](#macos-configuration). + +### Options + +| Flag | Effect | +|------|--------| +| `--yes`, `-y` | Assume defaults, never prompt. Implies core-only packages. | +| `--personal` | Install [`Brewfile.personal`](Brewfile.personal) too, and remember it | +| `--no-personal` | Core packages only, and remember it | +| `--no-defaults` | Skip `macos/defaults.sh` | +| `--check` | Report drift via `doctor.sh` and exit, changing nothing | --- @@ -28,18 +38,49 @@ Each directory mirrors `$HOME` and is symlinked in by `stow`: | `zsh` | `.zshrc`, `.zprofile`, `.aliases` | Shell config + prezto init | | `git` | `.gitconfig` | Git identity and aliases | | `tmux` | `.tmux.conf` | Prefix, vim keys, copy-paste | -| `ssh` | `.ssh/config` | SSH agent, ForwardAgent | +| `ssh` | `.ssh/config` | SSH agent, ForwardAgent. Stowed `--no-folding` — see [below](#why-ssh-is-stowed-differently) | | `nvim` | `.config/nvim` | Submodule: kickstart.nvim | | `base16` | `.config/base16-shell` | Submodule: base16 color scheme | -| `claude` | `.claude/settings.json` | Claude Code plugins, theme, cross-platform notification hook | +| `claude` | `.claude/settings.json` | Claude Code plugins, theme, model, notification hook | | `zprezto` | `.zprezto`, `.zpreztorc`, etc. | Manual symlinks via bootstrap | ### Other files -| Path | Purpose | -|------------|---------------------------------------------------------| -| `macos/` | Optional macOS system defaults & login-item setup | -| `Brewfile` | Declarative list of CLI tools, Cask apps, and App Store apps | +| Path | Purpose | +|---------------------|----------------------------------------------------------------| +| `macos/` | macOS system defaults & login-item setup | +| `Brewfile` | Core CLI tools, Cask apps, and App Store apps — every machine | +| `Brewfile.personal` | Opt-in media, games, and creative apps | +| `doctor.sh` | Read-only drift check — see [Verifying](#verifying) | + +--- + +## Package Tiers + +Two Brewfiles, so a work machine doesn't pull down Logic Pro: + +- **[`Brewfile`](Brewfile)** — everything needed for a working machine: shell, editor, terminal, dev tooling, and the GUI apps used day to day. +- **[`Brewfile.personal`](Brewfile.personal)** — media, games, and creative apps. + +`bootstrap.sh` asks once whether this is a personal machine and records the +answer in `.machine` (gitignored) as `PERSONAL=yes|no`. Later runs reuse it +without asking. To change your mind, re-run with `--personal` / `--no-personal`, +or edit `.machine` directly. `doctor.sh` reads the same file, so a core-only +machine is never nagged about personal apps. + +Installing a tier on demand, without bootstrap: + +```bash +brew bundle --file=Brewfile.personal +``` + +`mas` entries need the App Store signed in first — `brew bundle` can't do that +for you, and those entries will fail until you do. Bootstrap treats that as a +warning rather than a fatal error, so the rest of the setup still completes. + +DaVinci Resolve, Blackmagic RAW, and Blackmagic Proxy Generator have no +Homebrew cask and must be installed by hand; they're listed in a comment block +at the end of `Brewfile.personal`. --- @@ -51,20 +92,54 @@ Each directory mirrors `$HOME` and is symlinked in by `stow`: bash macos/defaults.sh ``` +Pass `--check` to report drift without writing anything: + +```bash +bash macos/defaults.sh --check +``` + It configures, end to end: - **Appearance** — auto-switching light/dark, hidden menu bar -- **Keyboard** — Caps Lock → Left Control (HID-level, all keyboards) -- **Trackpad** — tap to click +- **Keyboard** — Caps Lock → Left Control (HID-level, all keyboards), press-and-hold off so key repeat works in nvim, fast repeat rates +- **Text** — smart quotes, dashes, capitalization, and auto-correct off +- **Trackpad** — tap to click, built-in and Magic Trackpad - **Dock** — left orientation, autohide, no recent apps, no MRU spaces; populated with a curated app list via `dockutil` +- **Finder** — extensions shown, path and status bars, POSIX path in title, list view, no `.DS_Store` on network or USB volumes +- **Screenshots** — PNG, no shadow, saved to `~/Desktop/Screenshots` - **Menu bar** — Control Center icon and Now Playing visible - **Clock** — AM/PM with day of week, no date +- **Security** — Touch ID for `sudo`, application firewall with stealth mode; FileVault status reported but never changed automatically - **Third-party apps** — sensible defaults for SizeUp and Clipy -- **Launch Clipy** — builds an Automator launcher at `~/Applications/Launch Clipy.app` and registers it as a Login Item +- **Login items** — SizeUp, Mullvad VPN, Amphetamine, Ice, and a Launch Clipy Automator applet, all registered automatically + +After running, **log out and back in** for the Caps Lock remap to take effect. + +See [`macos/README.md`](macos/README.md) for details on each script, how the +`--check` table works, and how the remap / Automator workflow is constructed. + +--- + +## Verifying + +`doctor.sh` reports drift between this repo and the live machine. It is +read-only and exits 1 if anything has moved: + +```bash +./doctor.sh +``` -After running, **log out and back in** for the Caps Lock remap to take effect. SizeUp, Mullvad VPN, and Amphetamine are installed by `brew bundle` but still need to be added as Login Items via System Settings → General → Login Items. +It checks: -See [`macos/README.md`](macos/README.md) for details on each script and how the remap / Automator workflow is constructed. +| Check | Catches | +|-------|---------| +| Packages | Brewfile entries not installed, and installed packages listed in no in-scope Brewfile | +| Stow links | Broken or unstowed packages, and `~/.ssh` folded back into a symlink | +| macOS defaults | Any setting in `macos/defaults.sh` that no longer matches | +| Repo | Submodules off their recorded commits, dirty working tree | +| Environment | Wrong default shell, missing Xcode CLT, FileVault off | + +`./bootstrap.sh --check` is a shortcut for the same thing. --- @@ -80,6 +155,17 @@ This means: The repo must stay at `~/Development/dotfiles`. Stow's symlinks resolve relative to wherever the repo lives at the time `stow` runs — moving the directory later would break every link. +### Why `ssh` is stowed differently + +When a package's directory has no counterpart in `$HOME`, stow "folds" it — +linking the whole directory rather than each file inside it. For `ssh` that +would make `~/.ssh` itself a symlink into this repo, so every key, `known_hosts` +entry, and control socket written there would land in the git working tree. + +Bootstrap therefore stows `ssh` with `--no-folding`, leaving `~/.ssh` a real +directory (mode 700) that contains a symlinked `config` and nothing else from +the repo. `doctor.sh` flags it if it ever folds back. + --- ## Submodules @@ -104,11 +190,13 @@ git submodule update --init --recursive | Feature | macOS | WSL 2 / Linux | |--------------------|--------------------------------------|---------------------| -| Package manager | Homebrew | apt | +| Package manager | Homebrew (`Brewfile`) | apt | +| Package tiers | Core + optional personal | n/a | | Clipboard in tmux | `reattach-to-user-namespace pbcopy` | `xclip` | | Homebrew init | `.zprofile` (guarded by `uname`) | Skipped | | GOROOT | `brew --prefix golang` | Omitted from PATH | | System defaults | Prompted during bootstrap | n/a | +| `doctor.sh` | All checks | Skips macOS defaults | Platform detection happens at the top of `bootstrap.sh` using `uname` and `/proc/version`. Shell configs use inline `[[ "$(uname)" == "Darwin" ]]` guards so a single set of dotfiles works on both platforms. @@ -120,15 +208,22 @@ Platform detection happens at the top of `bootstrap.sh` using `uname` and `/proc ```bash ssh-keygen -t ed25519 -C "you@example.com" ``` + `~/.ssh` is a real directory, not a symlink into this repo, so keys written + there stay out of the working tree. 2. **GitHub CLI** — authenticate: ```bash gh auth login ``` -3. **Neovim plugins** — bootstrap automatically on first launch. +3. **App Store** — sign in, then re-run `brew bundle` if the `mas` entries were + skipped during bootstrap. + +4. **Neovim plugins** — bootstrap automatically on first launch. + +5. **tmux plugins** — TPM and plugins auto-install on first tmux launch. To install new plugins after editing `tmux.conf` later, press `prefix + I`. -4. **tmux plugins** — TPM and plugins auto-install on first tmux launch. To install new plugins after editing `tmux.conf` later, press `prefix + I`. +6. **Check your work** — `./doctor.sh` should come back clean. --- @@ -163,7 +258,10 @@ stow --restow --target="$HOME" 3. Commit. -If the tool needs the bootstrap script to handle it (e.g. an additional `brew install`), update `bootstrap.sh` accordingly. +If the tool needs installing too, add it to [`Brewfile`](Brewfile) (or +[`Brewfile.personal`](Brewfile.personal)) rather than to `bootstrap.sh` — that +keeps `doctor.sh` able to detect it as missing. Add the package name to +`STOW_PACKAGES` in `bootstrap.sh` and to the loop in `doctor.sh`. --- @@ -178,7 +276,7 @@ stow --delete --target="$HOME" Remove everything stowed by the bootstrap: ```bash -stow --delete --target="$HOME" zsh git tmux ssh nvim base16 +stow --delete --target="$HOME" zsh git tmux ssh nvim base16 claude ``` Only the symlinks are removed — source files in this repo remain untouched. diff --git a/bootstrap.sh b/bootstrap.sh index 516ecff..053f610 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,7 +1,37 @@ #!/usr/bin/env bash +# Bootstrap a machine from this repo. Idempotent; safe to re-run. +# +# ./bootstrap.sh interactive +# ./bootstrap.sh --yes assume defaults, never prompt +# ./bootstrap.sh --personal include Brewfile.personal (records the choice) +# ./bootstrap.sh --no-personal core packages only (records the choice) +# ./bootstrap.sh --no-defaults skip macos/defaults.sh +# ./bootstrap.sh --check report drift via doctor.sh and exit + set -euo pipefail DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACHINE_FILE="$DOTFILES_DIR/.machine" + +trap 'echo ">>> bootstrap failed at line $LINENO" >&2' ERR + +# ---- Options ---- +ASSUME_YES=0 +APPLY_DEFAULTS=1 +PERSONAL="" # unset until resolved; "yes" or "no" once decided + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) ASSUME_YES=1 ;; + --personal) PERSONAL=yes ;; + --no-personal) PERSONAL=no ;; + --no-defaults) APPLY_DEFAULTS=0 ;; + --check) exec bash "$DOTFILES_DIR/doctor.sh" ;; + -h|--help) sed -n '2,10p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac + shift +done # ---- Platform detection ---- OS="$(uname)" @@ -15,10 +45,40 @@ fi echo ">>> Detected platform: $PLATFORM" +# ---- Resolve the machine tier ---- +# An explicit flag wins and rewrites the record; otherwise reuse a previous +# answer; otherwise ask. Unattended runs default to core-only so they never +# pull down multi-gigabyte personal apps. +if [[ -n "$PERSONAL" ]]; then + echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE" +elif [[ -f "$MACHINE_FILE" ]]; then + # shellcheck source=/dev/null + source "$MACHINE_FILE" + PERSONAL="${PERSONAL:-no}" +elif (( ASSUME_YES )) || [[ ! -t 0 ]]; then + PERSONAL=no + echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE" +else + echo "" + read -rp ">>> Is this a personal machine? Installs media, games, and creative apps. [y/N] " _personal + [[ "${_personal:-N}" =~ ^[Yy]$ ]] && PERSONAL=yes || PERSONAL=no + echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE" +fi +echo ">>> Machine tier: $([[ "$PERSONAL" == yes ]] && echo "core + personal" || echo "core only")" + # ---- Install dependencies ---- echo ">>> Installing dependencies..." +deps_failed=0 if [[ "$PLATFORM" == "macos" ]]; then + # Xcode Command Line Tools — Homebrew needs them on a fresh Mac. + if ! xcode-select -p &>/dev/null; then + echo ">>> Installing Xcode Command Line Tools..." + xcode-select --install || true + echo ">>> Waiting for the Command Line Tools install to finish..." + until xcode-select -p &>/dev/null; do sleep 10; done + fi + # Install Homebrew if missing. if ! command -v brew &>/dev/null; then echo ">>> Installing Homebrew..." @@ -29,14 +89,27 @@ if [[ "$PLATFORM" == "macos" ]]; then # the current shell; a pre-existing install may be in a non-default location). if ! command -v brew &>/dev/null; then for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do - [[ -x "$brew_bin" ]] && eval "$("$brew_bin" shellenv)" && break + if [[ -x "$brew_bin" ]]; then + eval "$("$brew_bin" shellenv)" + break + fi done + if ! command -v brew &>/dev/null; then + echo ">>> Homebrew is not on PATH and was not found in either standard location." >&2 + exit 1 + fi fi - brew bundle --file="$DOTFILES_DIR/Brewfile" + # A missing App Store sign-in makes `mas` entries fail. Warn and carry on + # rather than aborting before anything gets stowed. + brew bundle --file="$DOTFILES_DIR/Brewfile" || deps_failed=1 + if [[ "$PERSONAL" == yes ]]; then + brew bundle --file="$DOTFILES_DIR/Brewfile.personal" || deps_failed=1 + fi + (( deps_failed )) && echo ">>> Warning: some packages failed to install; continuing." >&2 elif [[ "$PLATFORM" == "wsl" || "$PLATFORM" == "linux" ]]; then sudo apt-get update -qq - sudo apt-get install -y stow zsh tmux neovim xclip + sudo apt-get install -y git curl stow zsh tmux neovim xclip ripgrep fd-find fi # ---- Init git submodules ---- @@ -48,6 +121,7 @@ git submodule update --init --recursive # Stow 2.3.x bugs out when it finds absolute symlinks in the target dir. # Remove known conflicts so stow only encounters its own relative links. echo ">>> Cleaning up pre-existing symlinks..." +shopt -s nullglob prezto_links=(.zprezto .bash_profile) for rcfile in "$DOTFILES_DIR"/zprezto/runcoms/*; do rcname="${rcfile##*/}" @@ -63,8 +137,25 @@ done # ---- Stow shared packages ---- echo ">>> Stowing shared packages..." cd "$DOTFILES_DIR" -STOW_PACKAGES=(zsh git tmux ssh nvim base16 claude) -if ! stow --restow --target="$HOME" "${STOW_PACKAGES[@]}"; then + +# ssh goes first and on its own, so an unrelated conflict elsewhere can't leave +# ~/.ssh deleted between the unfold and the restow. +# +# --no-folding keeps ~/.ssh a real directory containing a symlinked config. +# Folded, ~/.ssh would itself be a symlink into this repo, and every key or +# known_hosts file written there would land in the git working tree. +stow_failed=0 +if [[ -L "$HOME/.ssh" ]]; then + echo ">>> Unfolding ~/.ssh (currently a symlink into the repo)..." + stow --delete --target="$HOME" ssh +fi +stow --restow --no-folding --target="$HOME" ssh || stow_failed=1 +[[ -d "$HOME/.ssh" ]] && chmod 700 "$HOME/.ssh" + +STOW_PACKAGES=(zsh git tmux nvim base16 claude) +stow --restow --target="$HOME" "${STOW_PACKAGES[@]}" || stow_failed=1 + +if (( stow_failed )); then cat >&2 <>> stow refused to overwrite existing files in \$HOME. @@ -90,6 +181,7 @@ for rcfile in "$DOTFILES_DIR/zprezto/runcoms/"*; do ln -s "$rcfile" "$target" fi done +shopt -u nullglob # ---- Set zsh as default shell ---- ZSH_PATH="$(command -v zsh)" @@ -103,9 +195,13 @@ fi # ---- macOS system defaults ---- applied_defaults=0 -if [[ "$PLATFORM" == "macos" ]]; then - echo "" - read -rp ">>> Apply macOS system defaults (Dock, Appearance, Trackpad)? [y/N] " _apply_defaults +if [[ "$PLATFORM" == "macos" ]] && (( APPLY_DEFAULTS )); then + if (( ASSUME_YES )) || [[ ! -t 0 ]]; then + _apply_defaults=y + else + echo "" + read -rp ">>> Apply macOS system defaults (Dock, Appearance, Keyboard, Finder)? [y/N] " _apply_defaults + fi if [[ "${_apply_defaults:-N}" =~ ^[Yy]$ ]]; then bash "$DOTFILES_DIR/macos/defaults.sh" applied_defaults=1 @@ -114,14 +210,25 @@ if [[ "$PLATFORM" == "macos" ]]; then fi fi +# ---- Verify ---- +if [[ "$(readlink -f "$HOME/.zshrc" 2>/dev/null)" != "$DOTFILES_DIR"/* ]]; then + echo ">>> Warning: ~/.zshrc does not resolve into $DOTFILES_DIR." >&2 +fi + # ---- Done ---- echo "" echo "✓ Bootstrap complete." +if (( deps_failed )); then + echo "" + echo "⚠ Some packages failed to install. If mas entries failed, sign in to the" + echo " App Store and re-run: brew bundle --file=$DOTFILES_DIR/Brewfile" +fi echo "" echo "Next steps:" echo " 1. Generate or copy your SSH keys to ~/.ssh/" echo " 2. Re-authenticate GitHub CLI: gh auth login" echo " 3. Restart your terminal or run: exec zsh" +echo " 4. Check for drift any time: ./doctor.sh" if (( applied_defaults )); then - echo " 4. Log out and back in for the Caps Lock → Control remap to take effect." + echo " 5. Log out and back in for the Caps Lock → Control remap to take effect." fi diff --git a/claude/.claude/settings.json b/claude/.claude/settings.json index 30ab2a6..8f60b86 100644 --- a/claude/.claude/settings.json +++ b/claude/.claude/settings.json @@ -1,24 +1,26 @@ { - "enabledPlugins": { - "frontend-design@claude-plugins-official": true, - "superpowers@claude-plugins-official": false, - "code-review@claude-plugins-official": true, - "code-simplifier@claude-plugins-official": true, - "claude-md-management@claude-plugins-official": true, - "security-guidance@claude-plugins-official": true - }, - "theme": "auto", - "preferredNotifChannel": "ghostty", "hooks": { "Notification": [ { "hooks": [ { "type": "command", - "command": "if [[ \"$(uname)\" == \"Darwin\" ]]; then osascript -e 'display notification \"Needs your attention\" with title \"Claude Code\"'; elif command -v notify-send >/dev/null 2>&1; then notify-send 'Claude Code' 'Needs your attention'; fi" + "command": "afplay /System/Library/Sounds/Glass.aiff" } ] } ] - } + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true, + "superpowers@claude-plugins-official": false, + "code-review@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "swift-lsp@claude-plugins-official": true + }, + "theme": "auto", + "preferredNotifChannel": "ghostty", + "model": "opus" } diff --git a/doctor.sh b/doctor.sh new file mode 100755 index 0000000..ac4bb1d --- /dev/null +++ b/doctor.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Report drift between this repo and the live machine. Read-only — makes no +# changes. Exits 1 if anything has drifted. +# +# ./doctor.sh + +set -uo pipefail # deliberately no -e: every check runs even if one fails + +DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACHINE_FILE="$DOTFILES_DIR/.machine" + +drift=0 +section() { echo ""; echo "== $1"; } +ok() { echo " ✓ $*"; } +bad() { drift=1; echo " ✗ $*"; } +hint() { echo " → $*"; } + +PERSONAL=no +if [[ -f "$MACHINE_FILE" ]]; then + # shellcheck source=/dev/null + source "$MACHINE_FILE" + PERSONAL="${PERSONAL:-no}" +fi + +# --------------------------------------------------------------------------- +section "Packages (tier: $([[ "$PERSONAL" == yes ]] && echo "core + personal" || echo "core only"))" +# --------------------------------------------------------------------------- +if ! command -v brew &>/dev/null; then + bad "Homebrew not installed" + hint "./bootstrap.sh" +else + BREWFILES=("$DOTFILES_DIR/Brewfile") + [[ "$PERSONAL" == yes ]] && BREWFILES+=("$DOTFILES_DIR/Brewfile.personal") + + for bf in "${BREWFILES[@]}"; do + if missing="$(brew bundle check --file="$bf" --verbose 2>&1 | grep '^→' || true)"; then + if [[ -n "$missing" ]]; then + bad "${bf##*/}: missing entries" + while IFS= read -r line; do echo " $line"; done <<<"$missing" + hint "brew bundle install --file=$bf" + else + ok "${bf##*/}: all entries installed" + fi + fi + done + + # `brew bundle` accepts a single --file, so concatenate the in-scope + # Brewfiles to check the reverse direction. cleanup without --force only + # lists; it removes nothing. + combined="$(mktemp)" + cat "${BREWFILES[@]}" > "$combined" + # Keep only the "Would uninstall …" sections; the trailing `brew cleanup` + # section is about stale download caches, not package drift. + extras="$(brew bundle cleanup --file="$combined" 2>/dev/null | + awk '/^Would uninstall/ {f=1; print; next} /^Would `brew cleanup`/ {f=0} f && NF' || true)" + rm -f "$combined" + if [[ -n "$extras" ]]; then + bad "installed but not listed in any in-scope Brewfile" + while IFS= read -r line; do echo " $line"; done <<<"$extras" + hint "add them to Brewfile / Brewfile.personal, or uninstall them" + else + ok "no unlisted packages" + fi +fi + +# --------------------------------------------------------------------------- +section "Stow links" +# --------------------------------------------------------------------------- +if ! command -v stow &>/dev/null; then + bad "stow not installed" +else + # ~/.ssh must be a real directory. As a folded symlink into the repo, any + # key or known_hosts written there lands in the git working tree. + # Tildes below are display text, not paths. + # shellcheck disable=SC2088 + if [[ -L "$HOME/.ssh" ]]; then + bad "~/.ssh is a symlink into the repo (stow folded it)" + hint "./bootstrap.sh — unfolds it and restows with --no-folding" + else + ok "~/.ssh is a real directory" + perms="$(stat -f '%Lp' "$HOME/.ssh" 2>/dev/null || echo "?")" + if [[ "$perms" == "700" ]]; then + ok "~/.ssh permissions are 700" + else + bad "~/.ssh permissions are $perms, want 700" + hint "chmod 700 ~/.ssh" + fi + fi + + for pkg in zsh git tmux ssh nvim base16 claude; do + args=(--dir="$DOTFILES_DIR" --no --restow --target="$HOME" "$pkg") + [[ "$pkg" == ssh ]] && args+=(--no-folding) + # stow always emits a simulation-mode banner under --no; drop it so only + # real conflicts and pending link changes remain. + out="$(stow "${args[@]}" 2>&1 | grep -v '^WARNING: in simulation mode' || true)" + if [[ -z "$out" ]]; then + ok "$pkg" + else + bad "$pkg is not fully stowed" + while IFS= read -r line; do [[ -n "$line" ]] && echo " $line"; done <<<"$out" + hint "./bootstrap.sh, or stow --adopt --target=\"\$HOME\" $pkg" + fi + done +fi + +# --------------------------------------------------------------------------- +section "macOS defaults" +# --------------------------------------------------------------------------- +if [[ "$(uname)" != "Darwin" ]]; then + ok "skipped (not macOS)" +else + # Capture first: defaults.sh --check exits 1 on drift, and under pipefail a + # `... | grep` test would read that as "no drift found". + defaults_out="$(bash "$DOTFILES_DIR/macos/defaults.sh" --check 2>&1 | grep '✗' || true)" + if [[ -n "$defaults_out" ]]; then + drift=1 + while IFS= read -r line; do echo " ${line#"${line%%[![:space:]]*}"}"; done <<<"$defaults_out" + hint "bash macos/defaults.sh" + else + ok "all settings match" + fi +fi + +# --------------------------------------------------------------------------- +section "Repo" +# --------------------------------------------------------------------------- +sub_status="$(cd "$DOTFILES_DIR" && git submodule status 2>/dev/null)" +if grep -qE '^[+-]' <<<"$sub_status"; then + bad "submodules out of sync" + while IFS= read -r line; do + [[ "$line" =~ ^[+-] ]] && echo " $line" + done <<<"$sub_status" + hint "git submodule update --init --recursive" +else + ok "submodules at recorded commits" +fi + +if [[ -n "$(cd "$DOTFILES_DIR" && git status --porcelain 2>/dev/null)" ]]; then + bad "working tree is dirty" + hint "git -C $DOTFILES_DIR status" +else + ok "working tree clean" +fi + +# --------------------------------------------------------------------------- +section "Environment" +# --------------------------------------------------------------------------- +zsh_path="$(command -v zsh || true)" +if [[ "$SHELL" == "$zsh_path" ]]; then + ok "default shell is $SHELL" +else + bad "default shell is $SHELL, want $zsh_path" + hint "chsh -s $zsh_path" +fi + +if [[ "$(uname)" == "Darwin" ]]; then + if xcode-select -p &>/dev/null; then + ok "Xcode Command Line Tools at $(xcode-select -p)" + else + bad "Xcode Command Line Tools not installed" + hint "xcode-select --install" + fi + + if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then + ok "FileVault is on" + else + bad "FileVault is off" + hint "System Settings → Privacy & Security → FileVault" + fi +fi + +# --------------------------------------------------------------------------- +echo "" +if (( drift )); then + echo "✗ Drift detected — see above." + exit 1 +fi +echo "✓ Machine matches the repo." diff --git a/macos/README.md b/macos/README.md index 6b5f197..10a3699 100644 --- a/macos/README.md +++ b/macos/README.md @@ -6,7 +6,8 @@ One-time system setup for fresh macOS installs. Not a stow package. | File | Purpose | |-------------------------------|--------------------------------------------------------------| -| `defaults.sh` | Apply system defaults (Dock, keyboard, trackpad, etc.) | +| `defaults.sh` | Apply (or check) system defaults — Dock, keyboard, Finder, security | +| `login-items.sh` | Sourced helpers: `add_login_item`, `has_login_item` | | `build-launch-clipy.sh` | Build the Launch Clipy Automator app & register login item | | `launch-clipy/document.wflow` | Automator workflow source — runs `open -a Clipy` | | `launch-clipy/Info.plist` | App bundle metadata for the Launch Clipy app | @@ -16,28 +17,52 @@ One-time system setup for fresh macOS installs. Not a stow package. `bootstrap.sh` prompts to run `defaults.sh` automatically on macOS. To run on demand: ```bash -bash macos/defaults.sh +bash macos/defaults.sh # apply +bash macos/defaults.sh --check # report drift, write nothing, exit 1 if any ``` -Both scripts are idempotent — safe to re-run. +Both scripts are idempotent — safe to re-run. `--check` is what `doctor.sh` calls. + +## How `--check` stays honest + +Every scalar setting lives in one `SETTINGS` table at the top of `defaults.sh`: + +``` +scope|domain|key|type|value +``` + +Both modes walk that same table — apply calls `defaults write`, check calls +`defaults read` and compares — so a check can never drift out of sync with what +apply writes. Booleans are normalised before comparison, since `defaults read` +prints `0`/`1` rather than `false`/`true`. + +The Caps Lock remap, Dock contents, login items, Touch ID, and the firewall +need more than a scalar comparison, so each has a matching `process_*` function +with an explicit check branch. ## What gets configured - **Appearance** — auto-switching light/dark, hidden menu bar, no minimize on double-click -- **Keyboard** — Caps Lock → Left Control (HID modifier mapping, all keyboards) -- **Trackpad** — tap to click -- **Dock** — autohide, left orientation, no recent apps; populated via `dockutil` with Safari, Messages, Mail, Calendar, Music, iPhone Mirroring, System Settings, Ghostty, plus a Downloads stack +- **Keyboard** — Caps Lock → Left Control (HID modifier mapping, all keyboards); press-and-hold disabled so key repeat works in nvim; fast repeat rates; full keyboard access +- **Text** — smart quotes, dashes, capitalization, period substitution, and auto-correct all off (they corrupt code and commit messages) +- **Trackpad** — tap to click, built-in and Magic Trackpad +- **Dock** — autohide, left orientation, no recent apps; populated via `dockutil` with Safari, Messages, Mail, Calendar, Music, iPhone Mirroring, System Settings, Ghostty, plus a Downloads stack. Entries whose app doesn't exist on this macOS version are skipped rather than aborting the run. +- **Finder** — all extensions shown, path bar, status bar, POSIX path in title, list view by default, search scoped to the current folder, no extension-change warning, no `.DS_Store` on network or USB volumes +- **Screenshots** — PNG, no window shadow, no floating thumbnail, saved to `~/Desktop/Screenshots` +- **Screen saver** — password required immediately. *Best-effort:* since Ventura this pane is partly system-managed and the write may not stick; `--check` will show it as drift if so. - **Menu bar** — Control Center icon and Now Playing visible; clock shows AM/PM and day of week +- **Security** — Touch ID for `sudo`, application firewall with stealth mode. FileVault is **reported only**, never enabled automatically — turning it on generates a recovery key a human has to record. - **SizeUp** — menu bar icon hidden, no popup on disabled state - **Clipy** — status item hidden (hotkey-only access) -- **Launch Clipy** — built at `~/Applications/Launch Clipy.app` and registered as a Login Item +- **Login items** — SizeUp, Mullvad VPN, Amphetamine, Ice, and Launch Clipy registered automatically; apps that aren't installed are skipped -## Manual follow-up +Settings that need `sudo` (Touch ID, firewall) prompt once up front rather than +midway through. `--check` never needs sudo. -The script can't do everything itself: +## Manual follow-up - **Log out and back in** for the Caps Lock remap to apply (HID mappings only load at login). -- **Register Login Items** for SizeUp, Mullvad VPN, and Amphetamine via System Settings → General → Login Items. The apps themselves are installed by `brew bundle` (see [`Brewfile`](../Brewfile)); Launch Clipy is registered automatically. +- **Grant Automation access.** The first login-item registration raises a TCC prompt for System Events that can't be scripted. If it's denied, `defaults.sh` prints a warning naming the app and the setting to fix — it no longer fails silently. ## How the Caps Lock remap works @@ -52,12 +77,22 @@ com.apple.keyboard.modifiermapping.0-0-0 = [ The `0-0-0` suffix is `vendorID-productID-variant`. Using zeros applies the mapping to *all* keyboards rather than a specific connected device — so it survives swapping or pairing new keyboards. +## How Touch ID for sudo works + +The script writes `/etc/pam.d/sudo_local` from Apple's own +`/etc/pam.d/sudo_local.template`, uncommenting the `pam_tid.so` line. Editing +`/etc/pam.d/sudo` directly would work too, but macOS overwrites that file on +system updates; `sudo_local` is the supported override and survives them. + ## How Launch Clipy works Clipy has no built-in "launch at login" option, so the workflow is wrapped in a tiny Automator applet: 1. `build-launch-clipy.sh` constructs the `.app` bundle by copying the system's `Automator Application Stub` binary from `/System/Library/CoreServices/` and pairing it with `launch-clipy/document.wflow` + `Info.plist`. 2. The workflow runs `open -a Clipy` via the Run Shell Script action. -3. The script registers the resulting app as a Login Item via `osascript`. +3. `add_login_item` (from `login-items.sh`) registers the resulting app as a hidden Login Item. -Editing the launcher behavior is a matter of changing `launch-clipy/document.wflow` (e.g. the `COMMAND_STRING` value) and re-running the build script. +The build is skipped with a message if Clipy isn't installed or if Apple has +moved the Automator stub. Editing the launcher behavior is a matter of changing +`launch-clipy/document.wflow` (e.g. the `COMMAND_STRING` value) and re-running +the build script. diff --git a/macos/build-launch-clipy.sh b/macos/build-launch-clipy.sh index fea148d..e7a9280 100755 --- a/macos/build-launch-clipy.sh +++ b/macos/build-launch-clipy.sh @@ -1,21 +1,35 @@ #!/usr/bin/env bash # Build ~/Applications/Launch Clipy.app from the Automator workflow source # and register it as a login item. Safe to re-run. +# +# Clipy has no built-in "launch at login" option, so the workflow is wrapped in +# a tiny Automator applet that runs `open -a Clipy` and quits. set -euo pipefail -SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/launch-clipy" && pwd)" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$HERE/launch-clipy" APP="$HOME/Applications/Launch Clipy.app" STUB="/System/Library/CoreServices/Automator Application Stub.app/Contents/MacOS/Automator Application Stub" +# shellcheck source=macos/login-items.sh +source "$HERE/login-items.sh" + +if [[ ! -e "/Applications/Clipy.app" ]]; then + echo " - Clipy not installed — skipping Launch Clipy build" + exit 0 +fi + +if [[ ! -x "$STUB" ]]; then + echo " ! Automator Application Stub not found at:" >&2 + echo " $STUB" >&2 + echo " Skipping Launch Clipy build." >&2 + exit 0 +fi + mkdir -p "$APP/Contents/MacOS" cp "$SRC/Info.plist" "$APP/Contents/Info.plist" cp "$SRC/document.wflow" "$APP/Contents/document.wflow" cp "$STUB" "$APP/Contents/MacOS/Automator Application Stub" -osascript >/dev/null 2>&1 <&2 + exit 1 +fi -# Quit System Settings so it doesn't clobber writes on save. -osascript -e 'tell application "System Settings" to quit' 2>/dev/null || true +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -echo ">>> Applying macOS defaults..." +MODE=apply +case "${1:-}" in + --check) MODE=check ;; + "") ;; + *) echo "usage: ${0##*/} [--check]" >&2; exit 2 ;; +esac -# ---- Appearance ---- -defaults write NSGlobalDomain AppleInterfaceStyleSwitchesAutomatically -bool true -defaults write NSGlobalDomain _HIHideMenuBar -bool true -defaults write NSGlobalDomain AppleMiniaturizeOnDoubleClick -bool false +drift=0 +note_drift() { drift=1; echo " ✗ $*"; } +note_ok() { [[ "$MODE" == check ]] && echo " ✓ $*"; return 0; } -# ---- Keyboard: Caps Lock → Left Control ---- -# Full HID value = (page << 32) | usage; page 0x07 = Keyboard/Keypad. -# Suffix 0-0-0 applies the mapping to all keyboards (vendor 0, product 0). -HID_CAPS_LOCK=$((0x700000039)) # usage 0x39 = Caps Lock -HID_LEFT_CTRL=$((0x7000000E4)) # usage 0xE4 = Left Control -defaults -currentHost write -g com.apple.keyboard.modifiermapping.0-0-0 -array \ - " - HIDKeyboardModifierMappingSrc$HID_CAPS_LOCK - HIDKeyboardModifierMappingDst$HID_LEFT_CTRL - " - -# ---- Trackpad ---- -defaults write com.apple.AppleMultitouchTrackpad Clicking -bool true -defaults -currentHost write -g com.apple.mouse.tapBehavior -int 1 - -# ---- Dock ---- -defaults write com.apple.dock autohide -bool true -defaults write com.apple.dock orientation -string "left" -defaults write com.apple.dock mru-spaces -bool false -defaults write com.apple.dock show-recents -bool false - -# ---- Dock apps ---- -dock_apps=( +# --------------------------------------------------------------------------- +# Settings table +# +# scope|domain|key|type|value +# +# scope user → defaults write +# currenthost → defaults -currentHost write (per-machine, ByHost plist) +# domain -g is the global domain in -currentHost scope; NSGlobalDomain elsewhere +# type bool | int | string +# --------------------------------------------------------------------------- +SETTINGS=( + # ---- Appearance ---- + "user|NSGlobalDomain|AppleInterfaceStyleSwitchesAutomatically|bool|true" + "user|NSGlobalDomain|_HIHideMenuBar|bool|true" + "user|NSGlobalDomain|AppleMiniaturizeOnDoubleClick|bool|false" + + # ---- Keyboard ---- + # The press-and-hold accent popup suppresses key repeat, which breaks + # held-down motions in nvim. Disable it and speed the repeat up. + "user|NSGlobalDomain|ApplePressAndHoldEnabled|bool|false" + "user|NSGlobalDomain|KeyRepeat|int|2" + "user|NSGlobalDomain|InitialKeyRepeat|int|15" + "user|NSGlobalDomain|AppleKeyboardUIMode|int|3" # Tab moves between all controls + + # ---- Text substitution ---- + # Smart quotes and em-dashes corrupt code, paths, and commit messages. + "user|NSGlobalDomain|NSAutomaticQuoteSubstitutionEnabled|bool|false" + "user|NSGlobalDomain|NSAutomaticDashSubstitutionEnabled|bool|false" + "user|NSGlobalDomain|NSAutomaticCapitalizationEnabled|bool|false" + "user|NSGlobalDomain|NSAutomaticPeriodSubstitutionEnabled|bool|false" + "user|NSGlobalDomain|NSAutomaticSpellingCorrectionEnabled|bool|false" + + # ---- Trackpad ---- + "user|com.apple.AppleMultitouchTrackpad|Clicking|bool|true" + "user|com.apple.driver.AppleBluetoothMultitouch.trackpad|Clicking|bool|true" + "currenthost|-g|com.apple.mouse.tapBehavior|int|1" + + # ---- Dock ---- + "user|com.apple.dock|autohide|bool|true" + "user|com.apple.dock|orientation|string|left" + "user|com.apple.dock|mru-spaces|bool|false" + "user|com.apple.dock|show-recents|bool|false" + + # ---- Finder ---- + "user|NSGlobalDomain|AppleShowAllExtensions|bool|true" + "user|com.apple.finder|ShowPathbar|bool|true" + "user|com.apple.finder|ShowStatusBar|bool|true" + "user|com.apple.finder|_FXShowPosixPathInTitle|bool|true" + "user|com.apple.finder|FXPreferredViewStyle|string|Nlsv" # list view + "user|com.apple.finder|FXEnableExtensionChangeWarning|bool|false" + "user|com.apple.finder|FXDefaultSearchScope|string|SCcf" # search current folder + # System-level counterpart to the `dsclean` alias in zsh/.aliases. + "user|com.apple.desktopservices|DSDontWriteNetworkStores|bool|true" + "user|com.apple.desktopservices|DSDontWriteUSBStores|bool|true" + + # ---- Screenshots ---- + "user|com.apple.screencapture|location|string|$HOME/Desktop/Screenshots" + "user|com.apple.screencapture|type|string|png" + "user|com.apple.screencapture|disable-shadow|bool|true" + "user|com.apple.screencapture|show-thumbnail|bool|false" + + # ---- Screen saver ---- + # Best-effort: since Ventura this pane is partly system-managed and the write + # may not stick. --check surfaces that rather than letting it fail silently. + "currenthost|com.apple.screensaver|askForPassword|int|1" + "currenthost|com.apple.screensaver|askForPasswordDelay|int|0" + + # ---- Menu bar ---- + "user|com.apple.menuextra.clock|ShowAMPM|bool|true" + "user|com.apple.menuextra.clock|ShowDate|bool|false" + "user|com.apple.menuextra.clock|ShowDayOfWeek|bool|true" + "user|com.apple.controlcenter|NSStatusItem VisibleCC BentoBox-0|bool|true" + "user|com.apple.controlcenter|NSStatusItem VisibleCC NowPlaying|bool|true" + + # ---- Third-party apps ---- + "user|com.irradiatedsoftware.SizeUp|MenuEnabled|bool|false" + "user|com.irradiatedsoftware.SizeUp|suppressMenuBarDisabledPopup|bool|true" + "user|com.clipy-app.Clipy|kCPYPrefShowStatusItemKey|bool|false" +) + +# Dock contents, rebuilt in order. Paths that don't exist on this macOS +# version are skipped rather than aborting the run. +DOCK_APPS=( "/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app" "/System/Applications/Messages.app" "/System/Applications/Mail.app" "/System/Applications/Calendar.app" "/System/Applications/Music.app" - "/System/Applications/iPhone Mirroring.app" + "/System/Applications/iPhone Mirroring.app" # Sequoia and later "/System/Applications/System Settings.app" "/Applications/Ghostty.app" ) -if command -v dockutil &>/dev/null; then +# Apps to register as login items. +LOGIN_ITEM_APPS=( + "/Applications/SizeUp.app" + "/Applications/Mullvad VPN.app" + "/Applications/Amphetamine.app" + "/Applications/Ice.app" +) + +# Caps Lock → Left Control, as an HID modifier mapping. +# Full HID value = (page << 32) | usage; page 0x07 = Keyboard/Keypad. +HID_CAPS_LOCK=$((0x700000039)) # usage 0x39 = Caps Lock +HID_LEFT_CTRL=$((0x7000000E4)) # usage 0xE4 = Left Control + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# `defaults read` prints 0/1 for booleans; normalise so comparisons work. +normalize_bool() { + case "$1" in + true|1|YES) echo 1 ;; + false|0|NO) echo 0 ;; + *) echo "$1" ;; + esac +} + +# read_default +read_default() { + local scope="$1" domain="$2" key="$3" + if [[ "$scope" == currenthost ]]; then + defaults -currentHost read "$domain" "$key" 2>/dev/null + else + defaults read "$domain" "$key" 2>/dev/null + fi +} + +# write_default +write_default() { + local scope="$1" domain="$2" key="$3" type="$4" value="$5" + if [[ "$scope" == currenthost ]]; then + defaults -currentHost write "$domain" "$key" "-$type" "$value" + else + defaults write "$domain" "$key" "-$type" "$value" + fi +} + +process_settings() { + local entry scope domain key type value actual want + for entry in "${SETTINGS[@]}"; do + IFS='|' read -r scope domain key type value <<<"$entry" + + if [[ "$MODE" == apply ]]; then + write_default "$scope" "$domain" "$key" "$type" "$value" + continue + fi + + actual="$(read_default "$scope" "$domain" "$key")" || true + want="$value" + if [[ "$type" == bool ]]; then + actual="$(normalize_bool "$actual")" + want="$(normalize_bool "$want")" + fi + + if [[ -z "$actual" ]]; then + note_drift "$domain $key — unset, want $value" + elif [[ "$actual" != "$want" ]]; then + note_drift "$domain $key — want $value, got $actual" + else + note_ok "$domain $key" + fi + done +} + +process_caps_lock() { + local current + current="$(defaults -currentHost read -g com.apple.keyboard.modifiermapping.0-0-0 2>/dev/null || true)" + + if [[ "$MODE" == check ]]; then + if [[ "$current" == *"$HID_CAPS_LOCK"* && "$current" == *"$HID_LEFT_CTRL"* ]]; then + note_ok "Caps Lock → Left Control" + else + note_drift "Caps Lock → Left Control not mapped" + fi + return 0 + fi + + # The 0-0-0 suffix is vendorID-productID-variant; zeros apply the mapping to + # every keyboard rather than one specific device, so it survives swapping or + # pairing new hardware. + defaults -currentHost write -g com.apple.keyboard.modifiermapping.0-0-0 -array \ + " + HIDKeyboardModifierMappingSrc$HID_CAPS_LOCK + HIDKeyboardModifierMappingDst$HID_LEFT_CTRL + " +} + +process_dock_apps() { + local app present=() + for app in "${DOCK_APPS[@]}"; do + [[ -e "$app" ]] && present+=("$app") + done + + if ! command -v dockutil &>/dev/null; then + note_drift "dockutil missing — Dock contents unmanaged (brew bundle installs it)" + return 0 + fi + + if [[ "$MODE" == check ]]; then + local listed missing=0 + listed="$(dockutil --list 2>/dev/null || true)" + for app in "${present[@]}"; do + grep -qF "$(basename "$app" .app)" <<<"$listed" || { missing=1; break; } + done + if (( missing )); then + note_drift "Dock contents differ from DOCK_APPS" + else + note_ok "Dock contents" + fi + return 0 + fi + dockutil --remove all --no-restart >/dev/null - for app in "${dock_apps[@]}"; do + for app in "${present[@]}"; do dockutil --add "$app" --no-restart >/dev/null done dockutil --add "$HOME/Downloads" --view fan --display folder --no-restart >/dev/null +} + +process_login_items() { + # shellcheck source=macos/login-items.sh + source "$HERE/login-items.sh" + + local app name + for app in "${LOGIN_ITEM_APPS[@]}"; do + [[ -e "$app" ]] || continue + name="$(basename "$app" .app)" + + if [[ "$MODE" == check ]]; then + if has_login_item "$app"; then + note_ok "login item: $name" + else + note_drift "login item missing: $name" + fi + else + add_login_item "$app" true || true + fi + done +} + +# Touch ID for sudo. Writing /etc/pam.d/sudo_local rather than editing +# /etc/pam.d/sudo means the setting survives OS updates. +process_touch_id_sudo() { + local target=/etc/pam.d/sudo_local template=/etc/pam.d/sudo_local.template + + if [[ -f "$target" ]] && grep -qE '^auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so' "$target"; then + note_ok "Touch ID for sudo" + return 0 + fi + + if [[ "$MODE" == check ]]; then + note_drift "Touch ID for sudo not enabled" + return 0 + fi + + if [[ ! -f "$template" ]]; then + echo " ! $template not found — skipping Touch ID for sudo" >&2 + return 0 + fi + + sudo sed 's/^#auth/auth/' "$template" | sudo tee "$target" >/dev/null + sudo chmod 444 "$target" + echo " ✓ Touch ID for sudo enabled" +} + +process_firewall() { + local fw=/usr/libexec/ApplicationFirewall/socketfilterfw + + [[ -x "$fw" ]] || { echo " ! socketfilterfw not found — skipping firewall" >&2; return 0; } + + if "$fw" --getglobalstate 2>/dev/null | grep -q "enabled"; then + note_ok "Application firewall" + return 0 + fi + + if [[ "$MODE" == check ]]; then + note_drift "Application firewall is off" + return 0 + fi + + # socketfilterfw is deprecated; revisit if a future macOS drops it. + sudo "$fw" --setglobalstate on >/dev/null + sudo "$fw" --setstealthmode on >/dev/null + echo " ✓ Application firewall enabled" +} + +# Report only — enabling FileVault generates a recovery key that a human has +# to record, so it is never automated here. +report_filevault() { + if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then + note_ok "FileVault" + else + note_drift "FileVault is off — enable it in System Settings → Privacy & Security" + fi +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +if [[ "$MODE" == apply ]]; then + echo ">>> Applying macOS defaults..." + # Quit System Settings so it doesn't clobber writes on save. + osascript -e 'tell application "System Settings" to quit' 2>/dev/null || true + # Prime sudo up front so the password prompt lands here rather than midway. + sudo -v + mkdir -p "$HOME/Desktop/Screenshots" else - echo ">>> dockutil missing — skipping Dock app setup. Run 'brew bundle' from the repo root, then re-run." + echo ">>> Checking macOS defaults..." fi -# ---- Menu bar ---- -defaults write com.apple.menuextra.clock ShowAMPM -bool true -defaults write com.apple.menuextra.clock ShowDate -bool false -defaults write com.apple.menuextra.clock ShowDayOfWeek -bool true -defaults write com.apple.controlcenter "NSStatusItem VisibleCC BentoBox-0" -bool true -defaults write com.apple.controlcenter "NSStatusItem VisibleCC NowPlaying" -bool true +process_settings +process_caps_lock +process_dock_apps +process_touch_id_sudo +process_firewall +report_filevault -# ---- Third-party apps ---- -defaults write com.irradiatedsoftware.SizeUp MenuEnabled -bool false -defaults write com.irradiatedsoftware.SizeUp suppressMenuBarDisabledPopup -bool true -defaults write com.clipy-app.Clipy kCPYPrefShowStatusItemKey -bool false +if [[ "$MODE" == apply ]]; then + bash "$HERE/build-launch-clipy.sh" +fi +process_login_items -# ---- Build Launch Clipy login item ---- -bash "$DOTFILES_DIR/macos/build-launch-clipy.sh" +if [[ "$MODE" == check ]]; then + echo "" + if (( drift )); then + echo ">>> Drift detected. Apply with: bash macos/defaults.sh" + exit 1 + fi + echo ">>> macOS defaults match the repo." + exit 0 +fi # ---- Restart affected services ---- -for svc in Dock Finder SystemUIServer ControlCenter; do +for svc in cfprefsd Dock Finder SystemUIServer ControlCenter; do killall "$svc" &>/dev/null || true done @@ -85,7 +375,4 @@ cat <<'EOF' Manual follow-up: - Log out and back in for the Caps Lock → Control remap to take effect. - - Add SizeUp, Mullvad VPN, and Amphetamine as Login Items via - System Settings → General → Login Items. (The apps themselves are - installed by `brew bundle`; Launch Clipy is registered automatically.) EOF diff --git a/macos/login-items.sh b/macos/login-items.sh new file mode 100755 index 0000000..f0909f7 --- /dev/null +++ b/macos/login-items.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Login item helpers. Source this file; it defines functions and runs nothing. +# +# add_login_item [hidden] register (idempotent, delete-then-add) +# has_login_item true if already registered +# +# Registration goes through System Events, which requires the calling terminal +# to hold Automation permission for it. The first attempt raises a TCC prompt +# that cannot be scripted — if it is denied, every later call fails silently at +# the osascript level, so we surface a warning rather than swallowing it. + +# Guard against double-sourcing. +[[ -n "${_LOGIN_ITEMS_SH:-}" ]] && return 0 +_LOGIN_ITEMS_SH=1 + +# has_login_item +# Counts matches rather than asking `exists login item whose path is …`, which +# errors with -1728 because the singular form can't coerce a filtered list. +has_login_item() { + local app="$1" count + count="$(osascript 2>/dev/null < [hidden] +# Returns 0 on success, 1 if the app is missing or registration failed. +add_login_item() { + local app="$1" hidden="${2:-true}" name + name="$(basename "$app" .app)" + + if [[ ! -e "$app" ]]; then + echo " - $name not installed — skipping login item" + return 1 + fi + + if ! osascript >/dev/null 2>&1 <&2 + echo " Grant this terminal Automation access to System Events in" >&2 + echo " System Settings → Privacy & Security → Automation, then re-run." >&2 + return 1 + fi + + echo " ✓ $name registered as a login item" +}