diff --git a/.zshrc b/.zshrc index 50373eb..8958391 100644 --- a/.zshrc +++ b/.zshrc @@ -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 diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..ab78b14 --- /dev/null +++ b/Brewfile @@ -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" diff --git a/Brewfile.apps b/Brewfile.apps new file mode 100644 index 0000000..c172f6f --- /dev/null +++ b/Brewfile.apps @@ -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 diff --git a/README.md b/README.md index acfb0ec..deb08df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/setup-mac.sh b/setup-mac.sh index 45b70ed..7d13647 100755 --- a/setup-mac.sh +++ b/setup-mac.sh @@ -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 ~/ to the repo's , 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 "$@"