* 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.

October 16, 20256 min read#dotfiles#macos#homebrew#automatizacion#shell

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:

  1. Idempotencia: ejecutarlo dos veces no debe romper nada.
  2. Reentrabilidad: si falla a mitad, debo poder volver a lanzarlo y reanudar.
  3. Cero interacción: ni un solo Enter, ni un solo sudo no anunciado.

Diagrama del pipeline

1. xcode-select --install2. Homebrew (instalación no interactiva)3. brew bundle --file=Brewfile4. mas install (App Store)5. defaults write (sistema y dock)6. stow ~/.config zsh git nvim wezterm7. oh-my-zsh + fuentes Nerd

Cada paso es idempotente: re-ejecutarlo no provoca efectos secundarios.

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 || true

Cabe 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ónPor qué
Bash plano sobre AnsibleCero dependencias, lectura inmediata
stow para enlaces simbólicosPermite agrupar dotfiles por aplicación
mas-cli para el App StoreSin GUI, sin clics, integrable en CI
defaults write en lugar de AjustesReproducible, versionable, auditable
Un único bootstrap.shUna 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.