feat(setup): rewrite setup-mac.sh around Brewfiles with idempotent steps

The old script stopped on a syntax error (unclosed if blocks) and had
drifted from the machine: it installed Anaconda, AeroSpace and Oh My
Posh, and linked nothing.

- Eight named steps (xcode homebrew brew links shell runtimes ai apps)
  that check what is in place first, run on their own by name, and
  preview with --dry-run; a failed step is reported while the rest run
- Brewfile holds the CLI tools, fonts and terminals, Brewfile.apps the
  GUI casks and App Store apps; installs never upgrade existing packages
  and skip apps already installed outside Homebrew
- links symlinks the configs and .local/bin scripts, backing up what it
  replaces, and fetches the nvim submodule only on a fresh clone
- Guard .zshrc's ~/.deno/env source for machines without it
- Document the steps in the README
This commit is contained in:
David Ibia
2026-10-06 23:02:43 +01:00
parent 66a3b9ee4d
commit d1aeda397a
5 changed files with 745 additions and 238 deletions
+1 -1
View File
@@ -322,7 +322,7 @@ if [ -d "$HOME/neovim/bin" ]; then
export PATH="$HOME/neovim/bin:$PATH"
fi
. "$HOME/.deno/env"
[ -f "$HOME/.deno/env" ] && . "$HOME/.deno/env"
export NVM_DIR="$HOME/.config/nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
+172
View File
@@ -0,0 +1,172 @@
# Command-line tools, fonts and terminals. Installed by `./setup-mac.sh brew`.
# GUI apps and App Store apps live in Brewfile.apps.
# To see what is installed but missing here: brew bundle dump --file=- | diff Brewfile -
tap "bufbuild/buf"
tap "gu1llaum-3/sshm"
tap "hashicorp/tap"
tap "leoafarias/fvm"
tap "mongodb/brew"
tap "oven-sh/bun"
tap "pbkit/tap"
tap "reoclo/tap"
tap "teamookla/speedtest"
# Shell, prompt and terminal tools (.zshrc and .tmux.conf rely on these)
brew "zsh-syntax-highlighting"
brew "starship"
brew "zoxide"
brew "direnv"
brew "fzf"
brew "eza"
brew "bat"
brew "grc"
brew "tmux"
brew "zellij"
brew "yazi"
brew "mosh"
brew "openssh"
brew "gu1llaum-3/sshm/sshm", trusted: true
# Everyday CLI
brew "coreutils"
brew "curl"
brew "wget"
brew "jq"
brew "ripgrep"
brew "fd"
brew "tree"
brew "watch"
brew "htop"
brew "dust"
brew "telnet"
brew "stow"
brew "sevenzip"
brew "gnupg"
brew "mas" # App Store installs in Brewfile.apps
# Git
brew "gh"
brew "tea"
brew "lazygit"
brew "git-filter-repo"
brew "gitleaks"
brew "colordiff"
brew "diffstat"
# Editors and code tools
brew "neovim"
brew "luarocks"
brew "ast-grep"
brew "silicon"
brew "just"
brew "bufbuild/buf/buf", trusted: true
brew "pbkit/tap/pbkit", trusted: true
# Languages and version managers
brew "asdf"
brew "pyenv"
brew "pyenv-virtualenv"
brew "python@3.11"
brew "python@3.12"
brew "ruby", link: false
brew "go"
brew "deno"
brew "oven-sh/bun/bun", trusted: true
brew "pnpm"
brew "uv"
brew "leoafarias/fvm/fvm", trusted: true
# Python tooling
brew "black"
brew "ruff"
brew "mypy"
brew "ipython"
brew "python-lsp-server"
brew "cookiecutter"
# Build toolchain and native libraries
brew "cmake"
brew "gcc"
brew "lld"
brew "libffi"
brew "libraw"
brew "librist"
brew "libssh"
brew "grpc"
brew "portaudio"
brew "tcl-tk"
brew "cryptography"
brew "python-setuptools"
# Containers, CI, cloud and deploy
brew "lazydocker"
brew "dive"
brew "hadolint"
brew "trivy"
brew "nixpacks"
brew "act"
brew "actionlint"
brew "woodpecker-cli"
brew "awscli"
brew "doctl"
brew "cloudflared"
brew "vercel"
brew "pscale"
brew "hashicorp/tap/terraform", trusted: true
brew "bitwarden-cli"
brew "reoclo/tap/reoclo", trusted: true
# Databases, servers and API clients
brew "mongosh"
brew "mongodb/brew/mongodb-community", trusted: true
brew "mysql"
brew "mysql-client@8.4"
brew "redis"
brew "httpd"
brew "posting"
# Media, documents and images
brew "ffmpeg"
brew "mpv"
brew "imagemagick"
brew "graphicsmagick"
brew "tesseract"
brew "ghostscript"
brew "poppler"
brew "weasyprint"
brew "pngquant"
brew "potrace"
brew "chafa"
brew "folderify"
# Apple and mobile builds
brew "xcodegen"
brew "xcbeautify"
brew "create-dmg"
# Hardware, installers and disk images
brew "arduino-cli"
brew "makensis"
brew "wimlib"
brew "cabextract"
brew "cdrtools"
# Networking
brew "tor"
brew "teamookla/speedtest/speedtest", trusted: true
# Fonts, terminals and CLI apps
cask "font-fira-code-nerd-font"
cask "font-hack-nerd-font"
cask "font-symbols-only-nerd-font"
cask "ghostty"
cask "wezterm"
cask "1password-cli"
cask "git-credential-manager"
cask "ngrok"
# Go and uv tools
go "golang.org/x/tools/gopls"
uv "headroom-ai"
uv "vectorcode"
+119
View File
@@ -0,0 +1,119 @@
# GUI apps and App Store apps. Installed by `./setup-mac.sh apps`.
# Sign in to the App Store first, or the mas entries fail.
# Apps already in /Applications that Homebrew did not install are skipped by
# setup-mac.sh, so this is safe to run on a Mac that already has them.
# Install by hand (no usable cask): Bartender 6 (the cask is Bartender 7),
# FileZilla, VMware Fusion and YubiKey Manager.
tap "glinford/tap"
tap "johannesnagl/tap"
# Development
cask "docker-desktop"
cask "zed"
cask "android-studio"
cask "bruno"
cask "beekeeper-studio"
cask "mongodb-compass"
cask "redis-insight"
cask "charles"
cask "cyberduck"
cask "chromium"
cask "johannesnagl/tap/showmd", trusted: true
# AI and agents
cask "conductor"
cask "claude"
cask "opencode-desktop"
cask "lm-studio"
cask "ollama-app"
cask "emdash"
cask "open-design"
cask "block-buzz"
cask "typewhisper"
# Browsers
cask "brave-browser"
cask "google-chrome"
cask "firefox"
cask "librewolf"
cask "zen"
# Productivity
cask "1password"
cask "raycast"
cask "linear"
cask "obsidian"
cask "ticktick"
cask "granola"
cask "mailspring"
cask "libreoffice"
cask "affinity"
cask "numi"
cask "mmex"
# Communication
cask "discord"
cask "element"
cask "telegram-desktop"
cask "whatsapp"
cask "microsoft-teams"
# Menu bar and system utilities
cask "hiddenbar"
cask "meetingbar"
cask "stats"
cask "monitorcontrol"
cask "rectangle-pro"
cask "appcleaner"
cask "latest"
cask "glinford/tap/dns-easy-switcher", trusted: true
cask "localsend"
cask "logi-options+"
cask "radio-silence"
cask "reikey"
cask "yubico-authenticator"
cask "protonvpn"
cask "teamviewer"
cask "balenaetcher"
# Media and games
cask "spotify"
cask "vlc"
cask "obs"
cask "jellyfin-media-player"
cask "transmission"
cask "steam"
cask "minecraft"
# App Store
mas "1Password for Safari", id: 1569813296
mas "AdBlock", id: 1402042596
mas "Amphetamine", id: 937984704
mas "Bitwarden", id: 1352778147
mas "Boop", id: 1518425043
mas "Color Picker", id: 1545870783
mas "DeArrow", id: 6451469297
mas "Disk Drill Media Recovery", id: 431224317
mas "DuckDuckGo Privacy for Safari", id: 1482920575
mas "Fresco", id: 1251572132
mas "GarageBand", id: 682658836
mas "Gestimer", id: 990588172
mas "Keka", id: 470158793
mas "Keynote", id: 409183694
mas "Keynote Creator Studio", id: 361285480
mas "Maccy", id: 1527619437
mas "Microsoft Excel", id: 462058435
mas "Microsoft PowerPoint", id: 462062816
mas "Microsoft Word", id: 462054704
mas "Numbers", id: 409203825
mas "Numbers Creator Studio", id: 361304891
mas "Pages", id: 409201541
mas "Pages Creator Studio", id: 361309726
mas "Pixelmator Pro", id: 1289583905
mas "Slack", id: 803453959
mas "SponsorBlock", id: 1573461917
mas "Tailscale", id: 1475387142
mas "Termius 2", id: 1176074088
mas "TextSniper", id: 1528890965
mas "Xcode", id: 497799835
+25 -28
View File
@@ -33,13 +33,10 @@ This repository contains personal dotfiles for configuring a complete developmen
Before installing these dotfiles, ensure you have the following:
- **Operating System**: macOS (Darwin platform)
- **Package Manager**: [Homebrew](https://brew.sh/) installed
- **Shell**: Zsh (default on modern macOS)
- **Git**: Version control system
- **Node.js**: Required for many Neovim plugins and LSP servers
- **Python**: Required for certain development tools and Neovim plugins
- **Rust**: (Optional) Required for some tools like ripgrep, fd, etc.
- **Operating System**: macOS (Apple Silicon or Intel)
- **Git**: To clone this repository. macOS offers to install it with the Command Line Tools the first time you run `git`
`setup-mac.sh` installs everything else: the Command Line Tools, Homebrew, Node.js, Python, Rust and the rest.
## Installation
@@ -53,17 +50,28 @@ Before installing these dotfiles, ensure you have the following:
cd ~/dotfiles
```
2. **Run the setup script**:
2. **Preview, then run the setup script**:
```bash
./setup-mac.sh --dry-run
./setup-mac.sh
```
3. **Create symbolic links** (if not handled by setup script):
```bash
ln -s ~/dotfiles/.config ~/.config
ln -s ~/dotfiles/.zshrc ~/.zshrc
ln -s ~/dotfiles/.tmux.conf ~/.tmux.conf
```
The script is safe to rerun: each step checks what is already in place and only does the missing work. A failed step is reported at the end while the other steps still run. Name steps to run only those, for example `./setup-mac.sh links` after adding a config.
| Step | What it does |
|------|--------------|
| `xcode` | Installs the Xcode Command Line Tools |
| `homebrew` | Installs Homebrew and loads it from `~/.zprofile` |
| `brew` | Installs the CLI tools, fonts and terminals in `Brewfile` |
| `links` | Fetches the Neovim submodule on a fresh clone and symlinks the configs and `.local/bin` scripts into `$HOME`, backing up anything it replaces to `~/.local/state/dotfiles/backups/` |
| `shell` | Installs oh-my-zsh and the tmux plugins |
| `runtimes` | Node with nvm and the global npm packages, Rust, Python with pyenv, poetry, and Flutter with fvm |
| `ai` | Installs omo, Claude Code, OpenCode and Codex, then runs `link-ai-configs.sh` |
| `apps` | Installs the GUI and App Store apps in `Brewfile.apps` |
`brew` and `apps` never upgrade packages that are already installed. `apps` skips apps already in `/Applications` that Homebrew did not install, and its App Store entries need you signed in to the App Store first. To list what is installed but missing from `Brewfile`, run `brew bundle dump --file=- | diff Brewfile -`.
A few things still need you afterwards, such as signing in to the App Store and the AI tools, and turning on the 1Password SSH agent. The script lists them when it finishes.
### Manual Installation
@@ -83,7 +91,7 @@ ln -s ~/dotfiles/.zshrc ~/.zshrc
### AI configuration restore
After installing Node.js, OpenCode, Claude Code, and OMC, restore the selected AI configuration files separately from the package installer:
The `ai` step of `setup-mac.sh` runs this script. To run it on its own after installing Node.js, OpenCode, Claude Code, and OMC:
```bash
bash ~/dotfiles/link-ai-configs.sh --dry-run
@@ -99,14 +107,7 @@ The script creates individual relative symlinks, skips existing correct links, a
Credentials, `settings.local.json`, plugin installations, unrelated user skills, histories, caches, and runtime state are not copied into this repository or managed by this script. The selected Impeccable, Vercel web-design-guidelines and frontend-workflow skills are managed individually; see [the GPT visual workflow](.config/opencode/README.md) for provenance, verification and rollback. Install other desired plugins and portable skills separately. OpenCode's local MCP servers are referenced through `{env:HOME}`, so the paths are portable, but the servers themselves (headroom, agcanvas, uvx, ast-grep-mcp, portfolio-mcp) must be installed at those locations on a new machine.
The OmO hooks, slash commands and workspace scripts are not linked by the script yet. Link them by hand:
```bash
ln -s ../../dotfiles/.config/omo/hooks.json ~/.omo/agent/hooks.json
ln -s ../../dotfiles/.config/omo/prompts ~/.omo/agent/prompts
ln -s ../../dotfiles/.local/bin/owt ~/.local/bin/owt
ln -s ../../dotfiles/.local/bin/omo-notify ~/.local/bin/omo-notify
```
The OmO hooks and slash commands (`~/.omo/agent/hooks.json` and `~/.omo/agent/prompts`) and the workspace scripts in `~/.local/bin` are linked by the `links` step of `setup-mac.sh` instead.
Restart OpenCode and Claude Code after applying changes. On first use, Claude may ask to approve the shared preferences import. Updates can rewrite generated configuration or replace symlinks: review the live files and repository diff before rerunning the link script, which backs up divergent live files rather than merging them. No plugin versions or model choices are changed by this restore step.
@@ -249,11 +250,7 @@ Custom scripts and binaries are stored in `.local/bin/`:
- `omo-notify`: macOS notification for OmO's Stop and Notification hooks
- `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers)
Ensure this directory is in your PATH:
```bash
export PATH="$HOME/dotfiles/.local/bin:$PATH"
```
The `links` step of `setup-mac.sh` links each script into `~/.local/bin`, which `.zshrc` puts on your PATH. `pbcopy` and `pbpaste` are OSC 52 clipboard shims for headless servers, so they are not linked on macOS.
### Local Servers
+428 -209
View File
@@ -1,239 +1,458 @@
#!/bin/bash
#!/usr/bin/env bash
# Set up a Mac from this dotfiles repo. Safe to rerun: every step checks what
# is already in place and only does the missing work.
#
# ./setup-mac.sh run every step in order
# ./setup-mac.sh links shell run only the named steps
# ./setup-mac.sh --dry-run show what would change without changing it
#
# Steps: xcode homebrew brew links shell runtimes ai apps
# Packages live in Brewfile (CLI tools, fonts, terminals) and Brewfile.apps
# (GUI and App Store apps). Written for the bash 3.2 that ships with macOS.
set -uo pipefail
echo "Setting up your Mac..."
# Check if Homebrew is installed
if command -v brew &> /dev/null; then
echo "Homebrew is installed."
else
echo "Homebrew is not installed."
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Check if Git is installed
STEPS="xcode homebrew brew links shell runtimes ai apps"
DOTFILES=$(dirname "$(realpath "${BASH_SOURCE[0]}")")
NVM_DIR=$HOME/.config/nvm
NODE_VERSION=22
PYTHON_VERSION=3.11
NPM_GLOBALS="oh-my-claude-sisyphus 9router better-sqlite3 sql.js systray2"
DRY_RUN=false
if command -v git &> /dev/null; then
echo "Git is installed."
else
echo "Git is not installed."
echo "Installing Git..."
brew install git
fi
# Repo paths linked into $HOME, as "source:target" when the target differs.
# Paths missing from the repo (gitignored local files such as .env) are skipped.
# The AI configs are linked separately by link-ai-configs.sh in the ai step.
LINKS="
.zshrc
.tmux.conf
.tmux-server.conf
.rgignore
.withcontextconfig.jsonc
.withcontextignore
.env
.envrc
.opencommit
AGENTS.md
docs
local_servers
.superpowers
.config/nvim
.config/ghostty
.config/wezterm
.config/direnv
.config/starship.toml
.config/ohmyposh/zen.toml
.config/zellij/config.kdl
.config/zed/keymap.json
.config/zed/settings.json
.config/opencode/agent
.config/opencode/command
.config/omo/hooks.json:.omo/agent/hooks.json
.config/omo/prompts:.omo/agent/prompts
"
# Check if coreutils is installed
say() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
if command -v gdate &> /dev/null; then
echo "coreutils is installed."
else
echo "coreutils is not installed."
echo "Installing coreutils..."
brew install coreutils
fi
run() {
if $DRY_RUN; then info "would run: $*"; else "$@"; fi
}
# Check if OhMyPosh is installed
run_remote() {
local url=$1 script
shift
if $DRY_RUN; then
info "would run the installer from $url $*"
return 0
fi
script=$(curl -fsSL "$url") || return 1
/bin/bash -c "$script" "$url" "$@"
}
if command -v oh-my-posh &> /dev/null; then
echo "OhMyPosh is installed."
else
echo "OhMyPosh is not installed."
echo "Installing OhMyPosh..."
brew install jandedobbeleer/oh-my-posh/oh-my-posh
fi
load_brew_env() {
local brew
for brew in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [[ -x $brew ]]; then
eval "$("$brew" shellenv)"
return 0
fi
done
return 1
}
# Check if asdf is installed
if command -v asdf &> /dev/null; then
echo "asdf is installed."
else
echo "asdf is not installed."
echo "Installing asdf..."
brew install asdf
describe() {
case $1 in
xcode) echo "Xcode Command Line Tools" ;;
homebrew) echo "Homebrew" ;;
brew) echo "CLI tools, fonts and terminals (Brewfile)" ;;
links) echo "Dotfile symlinks" ;;
shell) echo "oh-my-zsh and tmux plugins" ;;
runtimes) echo "Node, Rust, Python and Flutter" ;;
ai) echo "AI coding tools and their configs" ;;
apps) echo "GUI and App Store apps (Brewfile.apps)" ;;
esac
}
echo "Adding asdf plugins..."
asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git
fi
step_xcode() {
if xcode-select -p >/dev/null 2>&1; then
info "Command Line Tools already installed"
return 0
fi
run xcode-select --install
if ! $DRY_RUN; then
info "Finish the Command Line Tools installer window; waiting for it"
until xcode-select -p >/dev/null 2>&1; do sleep 5; done
fi
}
# Check if NodeJS is installed
if command -v node &> /dev/null; then
echo "NodeJS is installed."
else
echo "NodeJS is not installed."
echo "Installing NodeJS..."
brew install node
fi
step_homebrew() {
local prefix=/usr/local
if [[ $(uname -m) == arm64 ]]; then prefix=/opt/homebrew; fi
if [[ -x $prefix/bin/brew ]]; then
info "Homebrew already installed"
else
run_remote https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh
fi
# Login shells get brew on PATH from .zprofile; .zshrc relies on it.
if grep -qs 'brew shellenv' "$HOME/.zprofile"; then
info "Homebrew already loaded by ~/.zprofile"
elif $DRY_RUN; then
info "would add Homebrew's shellenv to ~/.zprofile"
else
# shellcheck disable=SC2016 # the $(...) is meant for .zprofile, not here
printf '\n# Homebrew\neval "$(%s/bin/brew shellenv)"\n' "$prefix" >>"$HOME/.zprofile"
info "added Homebrew's shellenv to ~/.zprofile"
fi
}
# Check if Neovim is installed
# Installs a Brewfile without upgrading what is already installed. In dry-run
# mode it lists what is missing instead.
bundle() {
if ! have brew; then
warn "Homebrew is missing; run the homebrew step first"
return 1
fi
if $DRY_RUN; then
info "missing from ${1##*/}:"
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle check --file="$1" --verbose 2>&1 |
grep -v '^The Brewfile' | sed 's/^/ /' || true
return 0
fi
HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle install --file="$1"
}
if command -v nvim &> /dev/null; then
echo "Neovim is installed."
else
echo "Neovim is not installed."
echo "Installing Neovim..."
brew install neovim
fi
step_brew() {
bundle "$DOTFILES/Brewfile"
}
# Check if WezTerm is installed
BACKUP_DIR=""
KEPT=0
# Links ~/<target> to the repo's <source>, backing up whatever was there.
link() {
local src=$DOTFILES/$1 dst=$HOME/$2 parent
if [[ ! -e $src ]]; then
info "skipped ~/$2 ($1 is not in the repo)"
return 0
fi
if [[ $src -ef $dst ]]; then
KEPT=$((KEPT + 1))
return 0
fi
if $DRY_RUN; then
if [[ -e $dst || -L $dst ]]; then
info "would back up ~/$2 and link it to $1"
else
info "would link ~/$2 to $1"
fi
return 0
fi
parent=$(dirname "$dst")
mkdir -p "$parent"
if [[ -e $dst || -L $dst ]]; then
if [[ -z $BACKUP_DIR ]]; then
BACKUP_DIR=$HOME/.local/state/dotfiles/backups/$(date +%Y-%m-%dT%H-%M-%S)
fi
mkdir -p "$(dirname "$BACKUP_DIR/$2")"
mv "$dst" "$BACKUP_DIR/$2"
info "backed up ~/$2 to $BACKUP_DIR/$2"
fi
ln -s "$(python3 -c 'import os, sys; print(os.path.relpath(sys.argv[1], os.path.realpath(sys.argv[2])))' "$src" "$parent")" "$dst"
info "linked ~/$2"
}
if command -v wezterm &> /dev/null; then
echo "WezTerm is installed."
else
echo "WezTerm is not installed."
echo "Installing WezTerm..."
brew install wezterm
fi
step_links() {
# Fetch the nvim config on a fresh clone only. An initialised submodule is
# left alone so whatever branch it has checked out is never reset.
if git -C "$DOTFILES" submodule status .config/nvim | grep -q '^-'; then
run git -C "$DOTFILES" submodule update --init --recursive .config/nvim
fi
local entry script name
for entry in $LINKS; do
link "${entry%%:*}" "${entry#*:}"
done
for script in "$DOTFILES"/.local/bin/*; do
name=${script##*/}
case $name in
pbcopy | pbpaste) continue ;; # OSC 52 clipboard shims for headless servers
esac
if [[ -x $script ]]; then link ".local/bin/$name" ".local/bin/$name"; fi
done
info "$KEPT links already in place"
}
step_shell() {
if [[ -d $HOME/.oh-my-zsh ]]; then
info "oh-my-zsh already installed"
else
# --keep-zshrc leaves the linked .zshrc alone.
run_remote https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh --unattended --keep-zshrc
fi
local tpm=$HOME/.tmux/plugins/tpm
if [[ -d $tpm ]]; then
info "tpm already installed"
else
run git clone --depth 1 https://github.com/tmux-plugins/tpm "$tpm"
fi
# Installs the .tmux.conf plugins that are not there yet.
run "$tpm/bin/install_plugins"
}
# Ensure fira font is installed
install_node() {
if [[ -s $NVM_DIR/nvm.sh ]]; then
info "nvm already installed"
else
run git clone --quiet https://github.com/nvm-sh/nvm.git "$NVM_DIR"
if $DRY_RUN; then
info "would install Node $NODE_VERSION as the nvm default, plus npm globals: $NPM_GLOBALS"
return 0
fi
git -C "$NVM_DIR" checkout --quiet "$(git -C "$NVM_DIR" describe --abbrev=0 --tags --match 'v[0-9]*' "$(git -C "$NVM_DIR" rev-list --tags --max-count=1)")"
fi
# nvm does not work under set -eu, so it gets a subshell of its own.
(
set +eu
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
if nvm which "$NODE_VERSION" >/dev/null 2>&1; then
info "Node $NODE_VERSION already installed"
else
run nvm install "$NODE_VERSION" || exit 1
fi
if [[ $(nvm version default) != "$(nvm version "$NODE_VERSION")" ]]; then
run nvm alias default "$NODE_VERSION" || exit 1
fi
nvm use --silent "$NODE_VERSION" >/dev/null 2>&1 || exit 0 # dry run before Node exists
for pkg in $NPM_GLOBALS; do
if npm ls -g --depth=0 "$pkg" >/dev/null 2>&1; then
info "npm $pkg already installed"
else
run npm install -g "$pkg" || exit 1
fi
done
)
}
brew install font-fira-code-nerd-font
install_rust() {
if [[ -x $HOME/.cargo/bin/rustup ]]; then
info "Rust already installed"
else
run_remote https://sh.rustup.rs -y --no-modify-path
fi
if [[ -x $HOME/.cargo/bin/cargo-xwin ]]; then
info "cargo-xwin already installed"
else
run "$HOME/.cargo/bin/cargo" install --locked cargo-xwin
fi
}
# Check if Zsh is installed
install_python() {
local current
current=$(pyenv global 2>/dev/null || true)
if [[ -n $current && $current != system ]]; then
info "pyenv global Python already set ($current)"
elif $DRY_RUN; then
info "would install the latest Python $PYTHON_VERSION with pyenv and make it the global default"
else
pyenv install --skip-existing "$PYTHON_VERSION"
pyenv global "$(pyenv latest "$PYTHON_VERSION")"
fi
if have poetry; then
info "poetry already installed"
else
run uv tool install poetry
fi
# .zshrc adds ~/.zfunc to fpath for these completions.
if [[ -f $HOME/.zfunc/_poetry ]]; then
info "poetry completions already installed"
elif $DRY_RUN; then
info "would write poetry's zsh completions to ~/.zfunc/_poetry"
else
mkdir -p "$HOME/.zfunc"
poetry completions zsh >"$HOME/.zfunc/_poetry"
fi
}
if command -v zsh &> /dev/null; then
echo "Zsh is installed."
else
echo "Zsh is not installed."
echo "Installing Zsh..."
brew install zsh
install_flutter() {
if [[ -e $HOME/fvm/default ]]; then
info "Flutter already installed (fvm global)"
else
run fvm install stable
run fvm global stable
fi
}
echo "Setting Zsh as default shell..."
sudo sh -c "echo $(which zsh) >> /etc/shells"
fi
step_runtimes() {
install_node
install_rust
install_python
install_flutter
}
# Check if Generic Colorizer is installed
step_ai() {
if have omo; then info "omo already installed"; else run bun add -g omo-ai; fi
if have claude; then
info "Claude Code already installed"
else
run_remote https://claude.ai/install.sh
fi
if have opencode; then
info "OpenCode already installed"
else
run_remote https://opencode.ai/install --no-modify-path
fi
if have codex; then info "Codex already installed"; else run brew install --cask codex; fi
# link-ai-configs.sh needs Node from the runtimes step.
local args=""
if $DRY_RUN; then args=--dry-run; fi
(
set +eu
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
bash "$DOTFILES/link-ai-configs.sh" $args
)
}
if command -v grc &> /dev/null; then
echo "Generic Colorizer is installed."
else
echo "Generic Colorizer is not installed."
echo "Installing Generic Colorizer..."
brew install grc
fi
# Prints the casks in a Brewfile whose app is already installed, but not by
# Homebrew. Installing those fails with "It seems there is already an App".
unmanaged_casks() {
local cask casks="" managed json name kind id
if ! have jq; then
warn "jq is missing, so apps installed outside Homebrew cannot be detected"
return 0
fi
managed=$(brew list --cask)
while read -r cask; do
if ! grep -qx "${cask##*/}" <<<"$managed"; then casks="$casks $cask"; fi
done < <(sed -nE 's/^cask "([^"]+)".*/\1/p' "$1")
if [[ -z $casks ]]; then return 0; fi
# Casks from taps that are not trusted yet cannot be looked up; on a fresh
# Mac there is nothing to skip anyway.
# shellcheck disable=SC2086 # one argument per cask
if ! json=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info --cask --json=v2 $casks 2>/dev/null); then
warn "could not look up the casks, so none are skipped"
return 0
fi
# Each cask yields the app names it installs (source and renamed target),
# or, for pkg and installer casks, its display name plus its pkg receipts.
jq -r '
.casks[] | .token as $name |
[.artifacts[] | select(has("app")) | .app | (.[0] | split("/") | last),
(.[1]? | objects | .target // empty | split("/") | last)] as $apps |
if ($apps | length) > 0 then $apps[] | "\($name)\tapp\t\(.)"
else "\($name)\tapp\t\(.name[0]).app",
(.artifacts[] | select(has("uninstall")) | .uninstall[] | .pkgutil? // empty
| (if type == "array" then .[] else . end) | "\($name)\tpkg\t\(.)")
end' <<<"$json" |
while IFS=$'\t' read -r name kind id; do
if [[ $kind == app && (-e /Applications/$id || -e $HOME/Applications/$id) ]] ||
{ [[ $kind == pkg && -n $(pkgutil --pkgs="$id" 2>/dev/null) ]]; }; then
echo "$name"
fi
done | sort -u | tr '\n' ' '
}
# Check if bat is installed
step_apps() {
local skip
if ! have brew; then
warn "Homebrew is missing; run the homebrew step first"
return 1
fi
skip=$(unmanaged_casks "$DOTFILES/Brewfile.apps")
skip=${skip% }
if [[ -n $skip ]]; then
info "skipping casks for apps installed outside Homebrew: $skip"
fi
HOMEBREW_BUNDLE_CASK_SKIP=$skip bundle "$DOTFILES/Brewfile.apps"
}
if command -v bat &> /dev/null; then
echo "bat is installed."
else
echo "bat is not installed."
echo "Installing bat..."
brew install bat
fi
usage() {
sed -n '2,/^$/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
}
# Check if eza is installed
main() {
local arg step status selected="" failed=""
for arg in "$@"; do
case $arg in
-n | --dry-run) DRY_RUN=true ;;
-h | --help)
usage
return 0
;;
-*)
warn "unknown option: $arg"
usage
return 2
;;
*)
if [[ " $STEPS " != *" $arg "* ]]; then
warn "unknown step: $arg (steps: $STEPS)"
return 2
fi
selected="$selected $arg"
;;
esac
done
if [[ $(uname -s) != Darwin ]]; then
warn "setup-mac.sh only runs on macOS"
return 1
fi
if [[ -z $selected ]]; then selected=$STEPS; fi
if command -v eza &> /dev/null; then
echo "eza is installed."
else
echo "eza is not installed."
echo "Installing eza..."
brew install eza
fi
# Tools from non-Homebrew installers live here; the steps look for them.
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.opencode/bin:$HOME/.cargo/bin:$PATH"
load_brew_env || true
if $DRY_RUN; then say "Dry run: nothing will be changed"; fi
# Install global pnpm package
if command -v pnpm &> /dev/null; then
echo "pnpm is installed."
else
echo "pnpm is not installed."
echo "Installing pnpm..."
npm install -g pnpm
fi
for step in $selected; do
say "$step: $(describe "$step")"
# Each step runs in a subshell with errexit so it stops at its first
# failure. Testing the subshell directly would switch errexit off.
(
set -e
"step_$step"
)
status=$?
if [[ $status -ne 0 ]]; then
failed="$failed $step"
warn "$step failed; fix the error above, then rerun ./setup-mac.sh $step"
fi
load_brew_env || true
done
# Check if fzf is installed
say "Done"
if [[ -n $failed ]]; then warn "failed steps:$failed"; fi
if ! $DRY_RUN && [[ $selected == "$STEPS" ]]; then
info "Steps that need you:"
info "- Sign in to the App Store, then rerun ./setup-mac.sh apps for the App Store apps"
info "- 1Password: turn on the SSH agent (Settings > Developer); .zshrc uses it for SSH"
info "- Sign in: gh auth login, then Claude Code, Codex and OpenCode"
info "- Open a new terminal to load the new shell setup"
fi
[[ -z $failed ]]
}
if command -v fzf &> /dev/null; then
echo "fzf is installed."
else
echo "fzf is not installed."
echo "Installing fzf..."
brew install fzf
fi
# Check if ripgrep is installed
if command -v rg &> /dev/null; then
echo "ripgrep is installed."
else
echo "ripgrep is not installed."
echo "Installing ripgrep..."
brew install ripgrep
fi
# Check if poetry is installed
if command -v poetry &> /dev/null; then
echo "poetry is installed."
else
echo "poetry is not installed."
echo "Installing poetry..."
curl -sSL https://install.python-poetry.org | python3 -
fi
# Check if gh is installed
if command -v gh &> /dev/null; then
echo "gh is installed"
else
echo "gh is not installed"
echo "Installing gh with brew..."
brew install gh
# Check if pyenv is installed
if command -v pyenv &> /dev/null; then
echo "pyenv is installed."
else
echo "pyenv is not installed."
echo "Installing pyenv with brew..."
brew install pyenv
# Check if pyenv-virtualenv is installed
if command -v pyenv &> /dev/null; then
echo "pyenv-virtualenv is installed."
else
echo "pyenv-virtualenv is not installed."
echo "Installing pyenv-virtualenv with brew..."
brew install pyenv-virtualenv
# Check if anaconda is installed
if command -v conda &> /dev/null; then
echo "anaconda is installed."
else
echo "anaconda is not installed."
echo "Installing anaconda..."
brew install --cask anaconda
fi
# Install Flutter version manager
if command -v fvm &> /dev/null; then
echo "fvm is installed."
else
echo "fvm is not installed."
echo "Installing fvm..."
curl -fsSL https://fvm.app/install.sh | bash
fi
# Install SDK manager
# Check if $HOME/.sdkman exists
if [ -d "$HOME/.sdkman" ]; then
echo "sdkman is installed."
else
echo "sdkman is not installed."
echo "Installing sdkman..."
curl -s "https://get.sdkman.io" | bash
fi
# Install Aerospace
if command -v aerospace &> /dev/null; then
echo "aerospace is installed."
else
echo "aerospace is not installed."
echo "Installing aerospace..."
brew install --cask nikitabobko/tap/aerospace
fi
main "$@"