Skip to content

Instantly share code, notes, and snippets.

@grantvanhorn
Last active July 28, 2026 19:53
Show Gist options
  • Select an option

  • Save grantvanhorn/27197da9fd67649b903e16cb135838e9 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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