Modular, idempotent macOS setup: Homebrew packages, dotfiles (zsh/git/ssh), and system defaults. Apple Silicon only.
Fresh machine, no git yet:
curl -fsSL https://raw.githubusercontent.com/perrwa/dotfiles/main/install.sh | bashinstall.sh is the only script meant to be piped. It installs the Xcode command line tools (for git), clones this repo to ~/git/dotfiles, then hands off to bootstrap.sh with a real terminal attached — the actual setup never runs piped, since sudo prompts and the package picker need a TTY to read from.
Already have the repo cloned:
cd ~/git/dotfiles
./bootstrap.shor make (prints available targets), make all, make dotfiles, etc.
- macOS on Apple Silicon (arm64). Intel is not supported.
- Admin access.
| Module | Does |
|---|---|
preflight |
TTY/root/arch guards, sudo priming + keepalive, Xcode CLT, Homebrew, Rosetta |
packages |
Installs Brewfile (always) and offers Brewfile.optional via an interactive picker |
dotfiles |
Symlinks home/ into $HOME and config/ into ~/.config, links ssh/config, seeds untracked *.local files |
macos |
Applies defaults write settings from modules/defaults/*.sh, only restarting Dock/Finder/SystemUIServer if something actually changed |
Everything is safe to re-run: linking is a no-op when already correct, package installs go through brew bundle (idempotent by design), and macOS defaults are compared before writing.
./bootstrap.sh [options]
--only a,b Run only these modules (preflight,packages,dotfiles,macos)
--skip a,b Run everything except these
--list List modules and exit
--upgrade Also run brew update && brew upgrade && brew cleanup
--non-interactive No prompts; Brewfile only, no picker
--dry-run Linking only: print every link/backup without performing it
--keep-going Continue past a failing module (default is fail-fast)
--force-dock-reset Re-arm the first-run-only Dock wipe
--unlink Undo the dotfiles module's symlinks, restoring backups
--help
Bring a machine up to date later:
./bootstrap.sh --upgradeThis runs plain brew upgrade: no --greedy on casks, since that force-reinstalls self-updating apps like Docker/Slack/Zoom and can prompt for admin. Run brew upgrade --cask --greedy by hand if you want that.
- Always want it, every machine → add a
brew "..."/cask "..."line toBrewfile. - Optional, offered in the picker → add it to
Brewfile.optional. If it needs a tap, add thetap "..."line there too; taps are always carried into the install even if the picked list is empty.
To catch drift (something installed by hand that isn't tracked):
brew bundle dump --describe --force --file=Brewfile.new
diff Brewfile Brewfile.newMove anything worth keeping into Brewfile or Brewfile.optional, then delete Brewfile.new.
- zsh:
~/.zshenv,~/.zprofile,~/.zshrc..zshrcsources~/.zsh/*.zshin order —10-fpath.zsh,20-completion.zsh,30-history.zsh,40-aliases.zsh,50-tools.zsh— then~/.zshrc.localif present. Each~/.zsh/*.zshfile is linked individually, so a real~/.zsh/completions/directory (hand-installed completion scripts) is left alone. Work-specific env vars (corporate CA overrides, PATs, private completion sources) don't belong in this public repo — they go in~/.zshenv.local,~/.zprofile.local, and~/.zshrc.local, each untracked and seeded from a*.local.exampletemplate on first run, same pattern as the git/ssh config below. - git:
~/.config/git/configand~/.config/git/ignore. Identity lives in~/.config/git/config.local, which is not tracked and gets seeded fromconfig/git/config.local.exampleon first run. Note:~/.gitconfig, if it exists, makes git ignore the XDG config entirely; the dotfiles module removes it (backing it up to~/.gitconfig.bakfirst). - ssh:
~/.ssh/configtracks only the personalgithub.comhost, since this repo is public. Work-specific hosts (internal aliases, non-public hostnames) belong in~/.ssh/config.local, which is untracked and seeded from a placeholder template. Fill in real values there, never in the repo.
See modules/defaults/*.sh, one file per domain (Dock, Finder, screenshots, global, storage, input). The Dock's persistent-apps wipe is first-run-only (guarded by a sentinel at ~/.local/state/dotfiles/dock-reset), so re-running the script never clears a Dock you've since arranged by hand.
- Restart your terminal.
- Sign in to installed apps (1Password, Slack, etc).
- Fill in
~/.ssh/config.localwith any work-specific host aliases.