Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
72 changes: 61 additions & 11 deletions Brewfile
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions Brewfile.personal
Original file line number Diff line number Diff line change
@@ -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
132 changes: 115 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand All @@ -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`.

---

Expand All @@ -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.

---

Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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.

---

Expand Down Expand Up @@ -163,7 +258,10 @@ stow --restow --target="$HOME" <package>

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`.

---

Expand All @@ -178,7 +276,7 @@ stow --delete --target="$HOME" <package>
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.
Loading
Loading