mirror of
https://github.com/boxpositron/absolute-dotfiles.git
synced 2026-10-24 08:23:19 +00:00
721d7fbb0a
impeccable-browser stands in for Chrome via IMPECCABLE_BROWSER. Public URLs render on Browserless with the key from the Reoclo project flowstate-tools; localhost and private origins reach it through a cloudflared quick tunnel that lives only for the scan, with the CDP traffic rewritten so reports name the local URL. Anything else, or a failed tunnel or Browserless connection, falls back to local headless Chrome with --no-sandbox. It is Linux only, so setup-mac.sh does not link it.
455 lines
15 KiB
Bash
Executable File
455 lines
15 KiB
Bash
Executable File
#!/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
|
|
|
|
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
|
|
|
|
# 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/omo/hooks.json:.omo/agent/hooks.json
|
|
.config/omo/keybindings.json:.omo/agent/keybindings.json
|
|
.config/omo/extensions/vim-quit.ts:.omo/agent/extensions/vim-quit.ts
|
|
.config/omo/prompts:.omo/agent/prompts
|
|
"
|
|
|
|
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; }
|
|
|
|
run() {
|
|
if $DRY_RUN; then info "would run: $*"; else "$@"; fi
|
|
}
|
|
|
|
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" "$@"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
step_brew() {
|
|
bundle "$DOTFILES/Brewfile"
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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
|
|
impeccable-browser) continue ;; # Linux-only browser shim for flowstate-remote
|
|
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"
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
install_flutter() {
|
|
if [[ -e $HOME/fvm/default ]]; then
|
|
info "Flutter already installed (fvm global)"
|
|
else
|
|
run fvm install stable
|
|
run fvm global stable
|
|
fi
|
|
}
|
|
|
|
step_runtimes() {
|
|
install_node
|
|
install_rust
|
|
install_python
|
|
install_flutter
|
|
}
|
|
|
|
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 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
|
|
)
|
|
}
|
|
|
|
# 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' ' '
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
usage() {
|
|
sed -n '2,/^$/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
|
|
}
|
|
|
|
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
|
|
|
|
# Tools from non-Homebrew installers live here; the steps look for them.
|
|
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$PATH"
|
|
load_brew_env || true
|
|
if $DRY_RUN; then say "Dry run: nothing will be changed"; 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
|
|
|
|
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 omo, Claude Code and Codex"
|
|
info "- Open a new terminal to load the new shell setup"
|
|
fi
|
|
[[ -z $failed ]]
|
|
}
|
|
|
|
main "$@"
|