Todos los artículos

// Construir con IA sin perder el control

Dotfiles cross-platform: un instalador con cinco presets

De un install.sh de mil líneas a un instalador modular: cinco presets según la máquina, checksums que abortan la instalación y un smoke test real en Debian.

11 de agosto de 202616 min de lectura

La primera parte de esta serie defendía una idea modesta y con la que sigo de acuerdo: ten dotfiles. Y daba el consejo estándar, el mismo que da todo el mundo: empieza por tu .zshrc, tu .gitconfig y un install.sh que cree los symlinks.

Ese consejo aguanta exactamente hasta la primera máquina que no es tu laptop.

Cinco meses y medio y 350 commits después, el repositorio se parece bastante poco a un puñado de configuraciones. Tiene un instalador partido en fases, presets que deciden qué se instala según dónde se está instalando, verificación de integridad de los binarios que descarga y un CI que monta el entorno entero en un contenedor limpio para comprobar que la instalación no miente. Nada de eso estaba planificado: cada pieza apareció el día que algo se rompió en una máquina distinta a la mía.

De ahí sale la tesis de este post, y la digo entera antes de justificarla: en cuanto tus dotfiles tocan más de una máquina, dejan de ser configuración y pasan a ser software — con las obligaciones que eso trae.

El script que crece hasta que nadie lo lee

Manos de artesano desenredando y organizando un manojo de cables en un banco de trabajo, representando la modularización de un script complejo.

El instalador llegó a 1.012 líneas en un solo archivo. No es un defecto moral ni un descuido, y el diagnóstico fácil («haberlo modularizado antes») no explica nada. Un instalador crece de la única forma en que puede crecer: una herramienta cada vez, ocho líneas cada vez. Ningún día concreto parece el día de partirlo, porque ningún cambio individual lo justifica. Cuando por fin lo parece, ya llevas mil líneas.

El síntoma real, además, no es la longitud: un archivo largo pero legible es un inconveniente, no un problema. El problema es que no se puede probar nada por separado. Para comprobar que la creación de symlinks funcionaba había que ejecutar el instalador entero: detectar el sistema, instalar paquetes, descargar binarios, configurar editores. Diez minutos y una máquina sacrificada para verificar una función de veinte líneas. Con ese coste, la respuesta racional es no verificarla, y esa es la decisión que un instalador de mil líneas te empuja a tomar todos los días.

El corte que acabé haciendo es de una sola frase: install.sh decide qué se hace y en qué orden; lib/ sabe cómo se hace. Casi un archivo por fase. El instalador dejó de ser un procedimiento y pasó a ser un índice de contenidos.

bash
# --- Fases, en orden ------------------------------------------------------------
# El preset --agent cambia tres cosas de esta lista y ninguna más: phase_packages
# puede degradar si no hay root, phase_editors no corre, y de los symlinks va
# solo el bloque de ~/.claude. El porqué está en la sección --agent de CLAUDE.md.
phase_detect      # SO, arquitectura, dependencias críticas

if [[ $INSTALL_AGENT -eq 1 ]]; then
    phase_packages_if_possible   # apt/brew; se salta si no hay root ni sudo -n
else
    phase_packages               # brew bundle (macOS) / apt (Debian-Ubuntu)
fi

phase_runtimes    # fnm+Node, fzf, starship, zoxide, uv
phase_binaries    # binarios SRE desde GitHub Releases (solo Linux)

if [[ $INSTALL_AGENT -eq 0 ]]; then
    phase_editors # tmux/TPM, Neovim/lazy.nvim, Claude Code
fi

if [[ $INSTALL_AGENT -eq 1 ]]; then
    phase_symlinks_agent   # SOLO ~/.claude (settings, statusline, CLAUDE.md)
else
    phase_symlinks         # symlinks de dotfiles
fi

phase_repo        # hooks de git (core.hooksPath)
phase_verify      # limpieza de caché zsh + resumen final

El refactor dejó install.sh en 123 líneas (hoy son 150, porque los presets vinieron después), con ocho fases distribuidas en diez archivos de lib/. Ninguno de los dos dieces que puedes contar cuadra con ese ocho, y cada uno tiene su explicación. Los diez archivos son las ocho fases más common.sh y menu.sh, que no son fases sino los helpers y los menús que las demás usan. Las diez líneas de invocación de arriba son ocho fases de las que dos tienen dos ramas: phase_packages y phase_packages_if_possible son la misma fase con y sin permisos, y lo mismo pasa con phase_symlinks y phase_symlinks_agent.

text
~/dotfiles/
├── install.sh          # orquestador: flags, presets, orden de las fases
└── lib/
    ├── common.sh       # logging, checksums, banner
    ├── menu.sh         # ayuda, menús, detección de update
    ├── detect.sh       # SO, arquitectura, dependencias
    ├── packages.sh     # brew bundle / apt
    ├── runtimes.sh     # fnm+Node, fzf, starship, uv
    ├── binaries.sh     # GitHub Releases + checksums, zoxide (Linux)
    ├── editors.sh      # tmux/TPM, Neovim/lazy.nvim, Claude Code
    ├── symlinks.sh     # symlinks, un grupo por destino
    ├── repo.sh         # hooks de git (core.hooksPath)
    └── verify.sh       # caché zsh + resumen final

La prueba de que el corte estaba en el sitio correcto no es estética, es que habilitó algo que antes era imposible: lib/symlinks.test.sh corre 27 tests sin tocar el $HOME de nadie, en una décima de segundo, en cualquier máquina. Eso solo puede hacerse cuando la fase es una función aislada cuyas entradas son explícitas. Mientras la lógica vivía incrustada en el flujo del script, sus entradas eran «el estado del sistema entero», y eso no se puede fingir en un test.

Cinco presets: decide la máquina, no tú

Vista aérea de un banco de trabajo con cinco bandejas distintas, cada una con un conjunto diferente de herramientas, ilustrando los presets.

Unos dotfiles nacen con un supuesto tan implícito que ni siquiera se formula: hay un destino, y ese destino es mi laptop. Todo lo demás se deriva de ahí. Hay entorno gráfico, hay sudo sin restricciones, hay pantalla, hay tiempo para instalar cosas grandes.

Ese supuesto se rompe el día que clonas el repo en otro sitio. Un VPS no tiene entorno gráfico, así que WezTerm y las fuentes Nerd son bytes descargados para nada. Un contenedor no tiene sudo, y un instalador que lo asume no falla con elegancia: falla a mitad, dejando la máquina en un estado que nadie sabe describir. Un nodo de Kubernetes al que entras a diagnosticar algo a las tres de la mañana no necesita Spotify ni Postman; necesita kubectl, jq y una shell que arranque rápido.

La respuesta a esto no puede ser un if suelto por herramienta, porque los if sueltos no componen: acabas con quince condiciones que nadie puede razonar en conjunto. Lo que compone son módulos. Cuatro, en este caso: base, cloud, k8s y gui. Y entonces los presets dejan de ser listas de herramientas y pasan a ser algo mucho más pequeño: un preset no elige herramientas, enciende módulos.

Presetbasecloudk8sgui
--minimalONOFFOFFOFF
--containerONOFFOFFOFF
--vpsONONOFFOFF
--k8s-nodeONONONOFF
--agentONONONOFF

Una fila de esa tabla es idéntica a otra y no es un error: --minimal y --container encienden exactamente los mismos módulos. Se diferencian en la intención de quien los escribe y en el mensaje que emiten, no en lo que instalan. Lo digo aquí porque un lector atento buscaría la diferencia hasta encontrarla, y no la hay.

Para el caso que ningún preset cubre —siempre lo hay— están los flags compositivos: --no-cloud, --no-k8s y --no-gui se combinan entre sí y encima de un preset. Un --k8s-node --no-cloud es una frase perfectamente legible, y esa legibilidad es la razón de haber elegido módulos en vez de condiciones.

Quedan dos decisiones que parecen detalles de ergonomía y no lo son. La primera es --dry-run, que no está ahí como cortesía sino como contrato: te deja ver la lista completa de lo que se va a descargar y enlazar antes de que nada entre en la máquina. En un script que corre curl contra internet y escribe en tu $HOME, poder auditar la intención antes del efecto es lo mínimo.

La segunda es safe_link, que respalda cualquier archivo existente a <archivo>.bak.<timestamp> antes de pisarlo. Sin eso, el instalador no es reejecutable sin miedo. Y un instalador que da miedo ejecutar simplemente no se ejecuta: lo pospones, y en el intervalo tus dotfiles y tu máquina se van separando en silencio. Esa es, con diferencia, la forma más común de que un repo de configuración se quede viejo — no un fallo, sino un instalador que nadie se atreve a volver a correr.

El preset --agent: tu entorno tiene dos usuarios

Hay un preset que no salió de una máquina, sino de una observación. La herramienta Bash de Claude Code es una zsh no interactiva que no sourcea el zshrc. Lo comprobé el día que un comando se comportó distinto ahí y en mi terminal, y la consecuencia, una vez la miras de frente, es más amplia de lo que parece.

En esa caja no se renderiza nunca el prompt de starship, porque no hay prompt. No existen los aliases, porque nadie leyó el archivo que los define. Los keybindings de fzf no están ligados a nada, porque no hay línea de edición. Y no hay terminal en absoluto, así que tmux no tiene qué multiplexar, Neovim no tiene a quién dibujar y la configuración de WezTerm es un archivo que ningún proceso abrirá jamás.

De ahí se sigue una conclusión incómoda: instalar el entorno completo en una máquina donde va a trabajar un agente es gastar la instalación —minutos, ancho de banda, disco— en configuración que nadie va a leer. El agente no es un usuario degradado. Es un usuario distinto, con otra superficie de contacto con el sistema.

Así que --agent no toca lo que ese usuario no puede usar, y el recorte es más quirúrgico de lo que suena: sigue instalando las herramientas de línea de comandos —fnm y Node, fzf, starship, zoxide, uv, los binarios de SRE—, porque un agente las invoca por su nombre y no le hace falta un prompt para eso. Lo que se salta son exactamente tres cosas: phase_editors entera —tmux, Neovim, sus gestores de plugins y, con una ironía que no se me escapa, la propia instalación de Claude Code, que en esa caja ya está—, todos los symlinks salvo el bloque de ~/.claude, y la instalación de paquetes degrada en lugar de fallar cuando no hay root. No es una versión recortada por ahorrar, es la lista correcta para ese destino.

💡Nota

Tu entorno de desarrollo ya tiene dos usuarios. Solo uno de los dos ve los colores.

El giro está en qué queda cuando quitas la decoración. Lo que sobrevive son las reglas, repartidas en dos capas: los hooks locales, que rechazan un mensaje de commit inservible al escribirlo, lintean lo que hay en el índice, barren secretos y bloquean el push a main en pre-push; y el CI, que vuelve a pasar esas comprobaciones sobre el PR y añade las que no caben en una máquina, incluido el job que falla cuando el CHANGELOG deja de seguir el paso del código. Un agente que trabaja en tu repositorio necesita precisamente eso, y por las mismas razones por las que la disciplina de desarrollo con Claude Code importa más que el prompt: las restricciones que se ejecutan valen más que las instrucciones que se leen. De eso trata la tercera parte de esta serie.

"No coincide" no es lo mismo que "no pude comprobar"

Una balanza antigua con un lado sobrecargado y otro con una sola esfera pulida, representando la diferencia entre verificar o no un checksum.

Los binarios de SRE del repo vienen de GitHub Releases. La versión anterior los instalaba con el idioma habitual del ecosistema, curl … | tar -xz, que tiene una propiedad que no suele nombrarse: no deja nada en disco que se pueda comprobar. Los bytes van del socket al sistema de archivos sin pasar por ningún punto donde alguien pueda preguntarse si son los correctos. Ahora se descargan a disco y se comparan contra el checksums.txt del propio release antes de instalar nada.

Lo interesante del bloque no es que verifique, sino que tiene tres desenlaces y no dos.

bash
if sums=$(gh_checksums "$repo" "$(basename "$file")"); then
    verify_sha256 "$file" "$sums" && rc=0 || rc=$?
    case $rc in
        0) ok "checksum verificado: $(basename "$file")" ;;
        1) rm -rf "$tmp"
           err "CHECKSUM NO COINCIDE en $(basename "$file") ($repo). Descarga corrupta o manipulada — abortando." ;;
        *) warn "$repo publica checksums pero $(basename "$file") no aparece en la lista; instalado sin verificar" ;;
    esac
else
    warn "$repo no publica checksums en su release; $(basename "$file") instalado sin verificar"
fi

Coincide, y sigue. No coincide, y aborta la instalación entera. O el proyecto no publica checksums, y entonces instala, pero emitiendo un warning visible por cada herramienta afectada.

La frase que sostiene todo el diseño es esta: distinguir «no coincide» de «no pude comprobar» es lo único que separa una descarga corrupta de una manipulada. Son estados epistémicos opuestos. En el primero sabes que algo está mal; en el segundo sabes que no sabes. Colapsarlos en «fallo» convierte cada release sin checksums en ruido, y el ruido se aprende a ignorar en menos de una semana. Colapsarlos en «bien» es peor: esconde el hueco justo donde importa.

Por eso el hueco queda auditable en la salida y no enterrado en el código. Hoy hay proyectos que no publican checksums en sus releases, y se ven uno a uno al instalar, con nombre y apellido. Es una lista que puedo revisar y que encoge cuando upstream mejora.

⚠️Atención

Honestidad completa: los instaladores oficiales de fnm, starship, zoxide, uv y compañía siguen siendo curl … | bash. Es la vía documentada por cada proyecto y es un trade-off aceptado por ergonomía, no un descuido. Está escrito en el README, junto a la recomendación de correr --dry-run antes de instalar en una máquina nueva.

El test que ninguno de los otros iba a encontrar

Esta es la parte que de verdad quería contar, y la cuento como categoría de fallo, que es lo que la hace útil a alguien que no tiene mi repo.

La API de GitHub no garantiza que el JSON venga indentado. La mayoría de los repos lo devuelven con un campo por línea; algunos lo devuelven minificado, todo el documento en una sola línea. Nunca es un error: son dos representaciones válidas de la misma respuesta.

El filtro que extraía la URL del asset asumía la primera forma. Sobre una respuesta minificada, el patrón casaba el documento entero y el sed —que es greedy por definición— se quedaba con el último browser_download_url del release. Pedías el tarball de Linux x86_64 y te instalabas otra cosa: arquitectura equivocada, sin un solo mensaje de error. La instalación terminaba en verde.

Lo que hace la anécdota valiosa no es el bug, que es pequeño. Es que ese código pasó bash -n, pasó shellcheck y pasó los 114 tests que había en el repo entonces. No es que las herramientas fueran malas ni estuvieran mal configuradas: es que las tres miran el código sin ejecutarlo. Ninguna sabe qué forma tiene el JSON que devuelve api.github.com un martes cualquiera, porque esa información no está en el código. Un fallo de integración les pasa por debajo entero y sin rozarlas.

La respuesta no fue añadir un test más de la misma clase. Fue añadir un job que instala de verdad: levanta debian:stable-slim en Docker, corre el instalador con el preset --vps, y después comprueba herramienta a herramienta que existe y es ejecutable, symlink a symlink que apunta donde debe, y que el zshrc instalado parsea con el zsh de la imagen.

Dos decisiones del job son las que más he tenido que explicar. Por qué debian:stable-slim y no el runner de Ubuntu, que ya está ahí y sale gratis: porque el runner trae curl, git, unzip y medio SDK preinstalados, así que da por buenas dependencias que un VPS recién creado no tiene. Un CI que corre en un entorno más rico que el destino real no prueba la instalación, prueba una versión afortunada de ella. Y por qué --vps y no --container: porque cubre lo mismo más el bloque cloud, que es donde vive el camino de código más nuevo y por tanto el menos probado.

bash
for t in zsh tmux git curl jq rg fd direnv btop \
         fzf starship zoxide uv fnm \
         lazygit delta trivy sops \
         tflint tofu nvim; do
  p=$(command -v "$t" 2>/dev/null || true)
  if [ -n "$p" ] && [ -x "$p" ]; then
    printf "  ✅ %-12s %s\n" "$t" "$p"
  else
    printf "  ❌ %-12s NO ENCONTRADO\n" "$t"
    fail=1
  fi
done

Ese fragmento está recortado: la lista real de herramientas es bastante más larga, y la dejo corta aquí porque el bucle importa más que su contenido. Lo aviso porque recortar sin decirlo sería la misma clase de mentira que denuncia el resto del post.

La lección generalizable, que es lo único que se lleva quien no vaya a clonar este repo nunca: los tests unitarios prueban que tu código hace lo que crees que hace; solo la ejecución real prueba que el mundo se comporta como crees que se comporta. Y un instalador es, por definición, código cuyo trabajo entero consiste en hablar con el mundo — con la API de otro, con el gestor de paquetes de una distribución, con un sistema de archivos que no es el tuyo. Ahí el segundo tipo de prueba no es un extra: es el que corresponde al problema.

Lo que un instalador correcto no arregla

Todo lo anterior compra una cosa concreta y acotada: que la instalación sea repetible y verificable. Puedo entrar en una máquina que no he visto nunca, correr una línea y saber qué va a pasar antes de que pase.

Y no impide absolutamente nada de lo que de verdad pudre un repositorio con el tiempo. No impide mensajes de commit que no explican nada. No impide que la documentación se desincronice del código hasta volverse activamente engañosa. No impide un cambio directo a main un viernes. No impide que el catálogo de herramientas afirme una cosa mientras el instalador hace otra.

Eso es materia de la tercera parte: los hooks de git, el CI, el changelog generado y el catálogo — la mitad del problema que ningún instalador, por bien partido que esté, va a resolver por ti.

El repo está en github.com/kr0nicas/dotfiles, y hay un escaparate con el catálogo de herramientas si prefieres mirar antes de clonar.

Referencias rápidas

Vista general

Más en esta serie

Serie: Dotfiles como software de producción

// newsletter

¿Te sirvió este artículo?

Recibe los siguientes en tu inbox. Sin spam, cancela cuando quieras.

Discusión

Escrito por Jorge Ochoa. ¿Encontraste un error?

Abrir en GitHub