Last active
July 28, 2026 19:53
-
-
Save grantvanhorn/27197da9fd67649b903e16cb135838e9 to your computer and use it in GitHub Desktop.
[macOS] Terminal setup installer — installs Ghostty, tmux, Neovim (LazyVim), Starship from gists via Homebrew
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # setup-macos.sh — provision a macOS machine with my terminal setup: | |
| # Ghostty, tmux, Neovim (LazyVim + my overrides), Starship, and zsh (.zshrc). | |
| # | |
| # This is the ONE orchestrator script — it installs everything and pulls each | |
| # config from its public gist, so no repo checkout is needed. | |
| # | |
| # It is IDEMPOTENT: run it as many times as you like. Each run converges to the | |
| # same state — configs are only rewritten when their content actually changed, | |
| # and the very first pre-existing version of any file is preserved once as | |
| # "<file>.orig". Nothing accumulates, nothing gets clobbered. | |
| # | |
| # Usage: | |
| # bash setup-macos.sh | |
| # # or straight from the web (this is the "first script to pull"): | |
| # curl -fsSL https://gist.githubusercontent.com/grantvanhorn/27197da9fd67649b903e16cb135838e9/raw/setup-macos.sh | bash | |
| # | |
| set -euo pipefail | |
| # ── gist raw URLs (always serve the latest version) ───────────────────────── | |
| GH_USER="grantvanhorn" | |
| GIST_GHOSTTY="fb4a27ee453ebc84b0ab9bf2b6cebace" | |
| GIST_TMUX="e883ed4e6f00a7ddf1148c250e358ec7" | |
| GIST_NVIM="e2d0457a31ca52f4ea1140c765fe1f52" | |
| GIST_STARSHIP="4cc9e2eb92bb13edb39c02d9fb3ce537" | |
| GIST_ZSHRC="3e7da9d25bb10f3736d863dad43ba7ef" | |
| # Append a unique query per call so GitHub's CDN can't serve a stale cached copy | |
| # (raw gist URLs are cached for a few minutes; without this, re-runs keep fetching | |
| # an old config even after the gist was updated). | |
| raw() { echo "https://gist.githubusercontent.com/${GH_USER}/$1/raw/$2?cb=$(date +%s)-$RANDOM"; } | |
| # ── pretty logging ────────────────────────────────────────────────────────── | |
| bold=$(tput bold 2>/dev/null || true); reset=$(tput sgr0 2>/dev/null || true) | |
| log() { echo "${bold}==>${reset} $*"; } | |
| warn() { echo "${bold}!! ${reset} $*" >&2; } | |
| # ── idempotent download: only writes when content changed; backs up the very | |
| # first pre-existing version once as <dest>.orig, then never again. ──────── | |
| fetch() { # fetch <url> <dest> | |
| local url="$1" dest="$2" tmp | |
| tmp="$(mktemp)" | |
| if ! curl -fsSL "$url" -o "$tmp"; then | |
| warn "download failed: $url"; rm -f "$tmp"; return 1 | |
| fi | |
| if [[ -e "$dest" ]] && cmp -s "$tmp" "$dest"; then | |
| echo " unchanged $dest"; rm -f "$tmp"; return 0 | |
| fi | |
| mkdir -p "$(dirname "$dest")" | |
| if [[ -e "$dest" && ! -e "$dest.orig" ]]; then | |
| cp "$dest" "$dest.orig" | |
| warn "kept your original as $dest.orig" | |
| fi | |
| mv "$tmp" "$dest" | |
| echo " wrote $dest" | |
| } | |
| # ── ensure a line exists in a file exactly once (idempotent) ──────────────── | |
| ensure_line() { # ensure_line <file> <line> | |
| local file="$1" line="$2" | |
| touch "$file" | |
| grep -qxF "$line" "$file" || printf '%s\n' "$line" >> "$file" | |
| } | |
| # ── 0. sanity ─────────────────────────────────────────────────────────────── | |
| [[ "$(uname)" == "Darwin" ]] || { warn "This script is macOS-only."; exit 1; } | |
| # ── 1. Homebrew (PREREQUISITE — already installed; this flow uses NO sudo) ─── | |
| # Homebrew must already be present; this script does not install it. Locate it | |
| # from any common prefix and load it into this shell. | |
| if ! command -v brew >/dev/null 2>&1; then | |
| for p in "$HOME/homebrew/bin/brew" /opt/homebrew/bin/brew /usr/local/bin/brew; do | |
| [[ -x "$p" ]] && eval "$("$p" shellenv)" && break | |
| done | |
| fi | |
| if ! command -v brew >/dev/null 2>&1; then | |
| warn "Homebrew not found on PATH. Install it first, then re-run. Nothing in" | |
| warn "this script needs sudo." | |
| exit 1 | |
| fi | |
| log "Using Homebrew at $(command -v brew)" | |
| # Install casks into the user's ~/Applications and skip Gatekeeper prompts, so no | |
| # elevated privileges are ever required for the app/font casks below. | |
| export HOMEBREW_CASK_OPTS="${HOMEBREW_CASK_OPTS:---appdir=$HOME/Applications --no-quarantine}" | |
| # ── 2. packages (brew install is itself idempotent) ───────────────────────── | |
| log "Installing formulae" | |
| brew install tmux neovim starship node stylua eza git | |
| # cask helper: install if missing; if the cask errors only because files already | |
| # exist (e.g. a font placed outside Homebrew), retry with --force. Never aborts | |
| # the run — a missing app/font shouldn't stop the config from being installed. | |
| install_cask() { # install_cask <cask> | |
| local cask="$1" | |
| if brew list --cask "$cask" >/dev/null 2>&1; then | |
| echo " already installed $cask"; return 0 | |
| fi | |
| brew install --cask "$cask" 2>/dev/null \ | |
| || brew install --cask --force "$cask" \ | |
| || warn "cask '$cask' could not be installed — install/replace it manually" | |
| } | |
| log "Installing casks (Ghostty + RobotoMono Nerd Font)" | |
| install_cask ghostty | |
| install_cask font-roboto-mono-nerd-font | |
| # JS/TS formatters (eslint_d, prettier) come from npm, whose registry is blocked | |
| # on some corporate networks. SKIPPED by default so re-runs stay fast. Opt in: | |
| # INSTALL_NPM_FORMATTERS=1 bash setup-macos.sh | |
| if [[ "${INSTALL_NPM_FORMATTERS:-0}" == 1 ]]; then | |
| log "Installing JS/TS formatters (eslint_d, prettier)" | |
| fmt_ok=0 | |
| for attempt in 1 2 3; do | |
| if npm install -g eslint_d prettier; then fmt_ok=1; break; fi | |
| warn "npm install failed (attempt $attempt/3, likely transient) — retrying in 5s…" | |
| sleep 5 | |
| done | |
| [[ "$fmt_ok" == 1 ]] || warn "Could not install eslint_d/prettier (npm unavailable)." | |
| else | |
| log "Skipping JS/TS formatters (npm). Enable with INSTALL_NPM_FORMATTERS=1" | |
| fi | |
| # Xcode CLT provide a C compiler + make for LuaSnip / treesitter builds. | |
| if ! xcode-select -p >/dev/null 2>&1; then | |
| log "Requesting Xcode Command Line Tools (needed to build LuaSnip/treesitter)" | |
| xcode-select --install || true | |
| fi | |
| # ── 3. Ghostty ────────────────────────────────────────────────────────────── | |
| log "Configuring Ghostty" | |
| fetch "$(raw "$GIST_GHOSTTY" ghostty-config)" "$HOME/.config/ghostty/config" | |
| # ── 4. zsh (.zshrc — enables starship, zinit plugins, eza aliases) ────────── | |
| log "Configuring zsh (.zshrc)" | |
| fetch "$(raw "$GIST_ZSHRC" .zshrc)" "$HOME/.zshrc" | |
| # ── 5. Starship ───────────────────────────────────────────────────────────── | |
| log "Configuring Starship" | |
| fetch "$(raw "$GIST_STARSHIP" starship.toml)" "$HOME/.config/starship.toml" | |
| # ── 6. tmux ───────────────────────────────────────────────────────────────── | |
| log "Configuring tmux" | |
| fetch "$(raw "$GIST_TMUX" tmux.conf)" "$HOME/.config/tmux/tmux.conf" | |
| if [[ ! -d "$HOME/.tmux/plugins/tpm" ]]; then | |
| log "Installing TPM (tmux plugin manager)" | |
| git clone --depth 1 https://github.com/tmux-plugins/tpm "$HOME/.tmux/plugins/tpm" | |
| fi | |
| log "Installing/updating tmux plugins (resurrect, continuum)" | |
| "$HOME/.tmux/plugins/tpm/bin/install_plugins" >/dev/null 2>&1 \ | |
| || warn "run prefix+I inside tmux to finish plugin install" | |
| # reload the config in any running tmux server so live sessions pick it up now | |
| tmux source-file "$HOME/.config/tmux/tmux.conf" 2>/dev/null \ | |
| && echo " reloaded running tmux" || true | |
| # ── 7. Neovim (LazyVim base + my overrides) ───────────────────────────────── | |
| log "Configuring Neovim" | |
| NVIM_DIR="$HOME/.config/nvim" | |
| if [[ ! -e "$NVIM_DIR/init.lua" ]]; then | |
| log "No LazyVim config found — installing the LazyVim starter" | |
| [[ -e "$NVIM_DIR" ]] && mv "$NVIM_DIR" "${NVIM_DIR}.bak.$(date +%Y%m%d%H%M%S)" | |
| git clone --depth 1 https://github.com/LazyVim/starter "$NVIM_DIR" | |
| rm -rf "$NVIM_DIR/.git" | |
| else | |
| log "Existing Neovim config detected — layering overrides on top" | |
| fi | |
| # plugin spec overrides -> lua/plugins/ | |
| for f in conform editor-options eslint-lsp neo-tree-show-hidden nvim-cmp onedarkpro typescript-tools; do | |
| fetch "$(raw "$GIST_NVIM" "$f.lua")" "$NVIM_DIR/lua/plugins/$f.lua" | |
| done | |
| # search highlights -> plugin/after/ | |
| fetch "$(raw "$GIST_NVIM" search-highlights.lua)" "$NVIM_DIR/plugin/after/search-highlights.lua" | |
| # ── done ──────────────────────────────────────────────────────────────────── | |
| cat <<EOF | |
| ${bold}All set.${reset} This script is safe to re-run any time. Next steps: | |
| 1. Open a new terminal (or: exec zsh) to load .zshrc + starship. | |
| 2. Launch nvim once and let LazyVim + Mason finish installing plugins/LSPs | |
| (run :Lazy sync and :Mason if needed). | |
| 3. tmux plugins were installed via TPM (prefix+I to re-run if missing). | |
| EOF |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment