* notes / dotfiles
Un solo archivo para configurar mi Mac
— Cómo reduje el ritual de estrenar un MacBook a un solo comando: Homebrew, Brewfile, defaults write, mas-cli, oh-my-zsh y stow orquestados desde un script idempotente.
Durante años cada nuevo Mac suponía dos jornadas perdidas: instalar Xcode CLT, recordar qué aplicaciones tenía en el dock, recuperar los aliases de zsh, redescubrir aquel defaults write que ocultaba el delay del Dock… El día que estrené mi último portátil, en cambio, ejecuté un único comando, fui a por un café y volví a un escritorio idéntico al anterior. La diferencia: un repositorio público (github.com/alexrqs/dotfiles) y, sobre todo, un único archivo de bootstrap que orquesta todo lo demás.
Contexto: por qué un solo script
No es la primera vez que escribo dotfiles. He pasado por configuraciones distribuidas en seis Makefiles, por Ansible playbooks sobreelaborados y por Nix, que abandoné cuando el coste de aprendizaje superó al de mantener mi modesto setup. La conclusión, tras varias iteraciones, es que el formato más sostenible para una persona sola es un archivo Bash de menos de 200 líneas que llame a herramientas declarativas. No tiene la elegancia teórica de Nix, pero sí dos virtudes capitales: cualquiera puede leerlo de un vistazo y cualquier bash reciente lo ejecuta.
El script tiene que cumplir, no obstante, tres condiciones innegociables:
- Idempotencia: ejecutarlo dos veces no debe romper nada.
- Reentrabilidad: si falla a mitad, debo poder volver a lanzarlo y reanudar.
- Cero interacción: ni un solo
Enter, ni un solosudono anunciado.
Diagrama del pipeline
El esqueleto del bootstrap.sh
A continuación reproduzco la parte vertebral del script, fielmente. Los comentarios son los que dejé yo mismo para mi yo futuro:
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
# 1. Xcode Command Line Tools ------------------------------------------------
if ! xcode-select -p &>/dev/null; then
log 'Instalando Xcode CLT (espera que termine la GUI)'
xcode-select --install || true
# Espera bloqueante a que el usuario complete el diálogo
until xcode-select -p &>/dev/null; do sleep 5; done
fi
# 2. Homebrew ----------------------------------------------------------------
if ! command -v brew &>/dev/null; then
log 'Instalando Homebrew'
NONINTERACTIVE=1 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
eval "$(/opt/homebrew/bin/brew shellenv)"
# 3. Paquetes ----------------------------------------------------------------
log 'brew bundle'
brew bundle --file="$DOTFILES_DIR/Brewfile" --no-lock
# 4. App Store ---------------------------------------------------------------
if command -v mas &>/dev/null; then
log 'Apps del App Store via mas'
mas install 497799835 # Xcode
fi
# 5. Defaults --------------------------------------------------------------
log 'Aplicando defaults write'
source "$DOTFILES_DIR/macos/defaults.sh"
# 6. Stow --------------------------------------------------------------------
log 'Enlazando dotfiles con GNU stow'
cd "$DOTFILES_DIR"
stow --target="$HOME" --restow zsh git nvim wezterm tmux
# 7. oh-my-zsh ---------------------------------------------------------------
if [[ ! -d "$HOME/.oh-my-zsh" ]]; then
RUNZSH=no CHSH=no sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
fi
log 'Listo. Reinicia el Dock: killall Dock'Algunas decisiones merecen comentario. La variable NONINTERACTIVE=1 evita que el instalador de Homebrew exija pulsar Enter. La doble negación || true tras xcode-select --install impide que el script muera si las herramientas ya están presentes (el comando retorna con código distinto de cero en ese caso, perfectamente esperable). El bucle until que sigue es deliberadamente bloqueante: prefiero esperar diez minutos a iterar manualmente más tarde.
El Brewfile: declarativo y autoexplicativo
El segundo pilar es el Brewfile, que brew bundle consume. Su gracia reside en que cualquier línea es a la vez documentación y especificación:
tap "homebrew/bundle"
tap "homebrew/services"
# CLIs esenciales
brew "git"
brew "gh"
brew "neovim"
brew "ripgrep"
brew "fd"
brew "fzf"
brew "stow"
brew "mas"
brew "bat"
brew "eza"
brew "zoxide"
brew "jq"
# Lenguajes y runtimes
brew "node"
brew "bun"
brew "rustup-init"
brew "go"
# Apps GUI (cask)
cask "wezterm"
cask "raycast"
cask "rectangle"
cask "1password"
cask "google-chrome"
cask "visual-studio-code"
cask "orbstack"
# Fuentes
cask "font-jetbrains-mono-nerd-font"
cask "font-inter"Cada vez que añado una herramienta, una línea más al Brewfile y un commit; al ejecutar brew bundle en cualquier otra máquina, la nueva línea se materializa sola.
La sutil utilidad de defaults write
Aquí está, a mi juicio, el cambio cualitativo. Cada vez que ajustas una preferencia desde Ajustes del Sistema estás escribiendo en un .plist; el atajo es declarar ese mismo cambio en un script. Un extracto representativo de macos/defaults.sh:
#!/usr/bin/env bash
# Trackpad: tap to click
defaults write com.apple.AppleMultitouchTrackpad Clicking -bool true
# Finder: mostrar extensiones y rutas
defaults write NSGlobalDomain AppleShowAllExtensions -bool true
defaults write com.apple.finder ShowPathbar -bool true
defaults write com.apple.finder ShowStatusBar -bool true
# Dock: sin animación, tamaño 36
defaults write com.apple.dock autohide-time-modifier -float 0
defaults write com.apple.dock autohide-delay -float 0
defaults write com.apple.dock tilesize -int 36
# Screenshots a ~/Screenshots en PNG
mkdir -p "$HOME/Screenshots"
defaults write com.apple.screencapture location "$HOME/Screenshots"
defaults write com.apple.screencapture type -string "png"
# Aplicar
killall Dock Finder SystemUIServer 2>/dev/null || trueCabe destacar que muchos de estos plist domains no están documentados oficialmente: los descubrí, como tanta gente, con defaults read > before.txt, cambiando algo en la GUI y haciendo diff contra defaults read > after.txt. Es trabajo arqueológico, pero el script resultante sobrevive a cada nuevo macOS sin esfuerzo.
Aprendizajes acumulados
| Decisión | Por qué |
|---|---|
| Bash plano sobre Ansible | Cero dependencias, lectura inmediata |
stow para enlaces simbólicos | Permite agrupar dotfiles por aplicación |
mas-cli para el App Store | Sin GUI, sin clics, integrable en CI |
defaults write en lugar de Ajustes | Reproducible, versionable, auditable |
Un único bootstrap.sh | Una sola entrada mental |
Closure
Lanzar ./bootstrap.sh en una máquina nueva la deja configurada sin intervención manual. No es automatización compleja: son doscientas líneas de Bash, un Brewfile curado y una regla fija: ninguna preferencia se aplica desde la GUI sin reflejarla en el repositorio. A cambio, estrenar portátil se reduce a clonar el repo y correr un script.