DotCLI

Un gestor de dotfiles con interfaz de terminal escrito en Go que trata cada herramienta como un módulo autocontenido, resuelve las dependencias entre módulos en orden topológico e instala la selección — paquetes, scripts de configuración y symlinks de dotfiles — con una sola tecla.

Repositorio

¿Qué es?

Un binario único con interfaz de terminal que convierte un directorio ~/dotfiles/modules/ en un instalador consciente de dependencias. Cada módulo es una carpeta autocontenida — un config.yaml que declara sus paquetes, comandos personalizados, mapeos de dotfile→home y sus dependencias de otros módulos, más un install.sh opcional y un subárbol dotfiles/. La herramienta escanea ese directorio, te deja explorar y seleccionar módulos en una lista de Bubble Tea, resuelve el grafo de dependencias en un orden topológico de instalación y luego instala cada módulo por turno: los paquetes del sistema mediante el gestor detectado (brew/apt), el install.sh del módulo, los comandos personalizados y, por último, los symlinks de los dotfiles hacia $HOME. No hay base de datos ni lockfile — el sistema de archivos es la única fuente de verdad, así que un módulo es portable con un cp.

scan

resolve deps

~/dotfiles/modules

config.yaml · install.sh · dotfiles/

Interactive TUI

browse · select · create/edit

Install order

(topological)

Packages

brew / apt

install.sh

+ custom commands

Dotfile symlinks

→ $HOME

Contexto y Reto

La mayoría de los “gestores” de dotfiles son un montón de symlinks más un script de arranque. Eso aguanta hasta que pasan dos cosas: los módulos empiezan a depender unos de otros (el módulo del editor necesita el del shell ya instalado, porque su configuración hace source de una función del shell) y vuelves a correr el script en una máquina que ya está medio configurada. Un script plano no tiene noción de orden ni de idempotencia — reinstala paquetes que ya están presentes, y un orden de instalación mantenido a mano se pudre en silencio la primera vez que agregas un módulo en el lugar equivocado. La restricción aquí era instalar un subconjunto arbitrario de módulos en un orden que siempre respete las dependencias declaradas, rechazar un ciclo de dependencias antes de tocar el sistema de archivos y saltarse el trabajo ya hecho — todo sin un manifiesto central, para que cada módulo siga siendo una carpeta copiable en lugar de una entrada en algún registro global.

Decisiones

La resolución de dependencias es un ordenamiento topológico en profundidad (DFS) con seguimiento de tres estados. Cada módulo se marca como no visitado, visitando (en la pila de recursión actual) o visitado; recurrir hacia un nodo que sigue en estado visitando es una arista de retroceso, que es exactamente un ciclo, reportado como circular dependency detected: X antes de instalar ningún paquete. El conjunto visitando es lo que separa un ciclo real de un diamante inofensivo — un módulo alcanzado dos veces por dos caminos distintos está bien, un módulo alcanzado mientras sigue en la pila no lo está. Elegí DFS recursivo en lugar del algoritmo de Kahn (por grado de entrada) porque se traduce directamente en la frase natural “resuelve las dependencias de este módulo, luego el módulo mismo”, y le da a la detección del ciclo un único lugar obvio.

El sistema de archivos es la fuente de verdad — sin base de datos, sin lockfile, sin registro. El scanner solo lista modules/*/, deserializa cada config.yaml y descarta (con una advertencia, nunca de forma fatal) cualquier módulo que no parsee. El tradeoff es deliberado: al no haber estado global de “qué está instalado”, la idempotencia se delega hacia abajo — al propio chequeo de “ya instalado” del gestor de paquetes y a que la creación de symlinks es repetible por naturaleza — a cambio de módulos que llevan todo lo que necesitan y se mueven entre máquinas sin tocarlos.

El motor de instalación está desacoplado de la UI mediante un canal de estado. El modelo de Bubble Tea solo registra la intención — qué módulos, y si el modo export está activo — y luego termina; main.go maneja una goroutine que instala cada módulo y transmite valores InstallationStatus (cada uno con una fracción de progreso) sobre un canal que el primer plano recorre e imprime. Esto mantiene el runtime del TUI en pantalla alterna fuera de la ruta de instalación, así que ves la salida real de brew/apt-get en lugar de pelear con el renderizador, y el primer error rompe el bucle con un código de salida distinto de cero.

cycle

missing dep

yes

no

yes

no

all done

User presses Enter

GetInstallationOrder

add missing deps

ResolveDependencies

DFS topological sort

circular dependency detected

module not found

for each module

export mode?

InstallDotfilesOnly

(symlinks only)

packages → install.sh

→ commands → symlinks

emit InstallationStatus

error?

print error · exit 1

completed

La capa del gestor de paquetes detecta brew y luego apt y filtra los paquetes “específicos” por gestor de cada módulo hasta el que esté presente, de modo que un módulo que lleva tanto un nombre de brew como uno de apt instala el correcto y descarta el otro sin error. El modo export es una segunda ruta de instalación (InstallDotfilesOnly) que aún resuelve el orden completo de dependencias pero solo ejecuta el paso de symlinks — para una máquina donde el software ya está y solo quieres aplicar tu configuración.

Rendimiento

No hay benchmarks formales aquí, e inventarlos sería deshonesto: el conjunto de trabajo es pequeño por naturaleza — una colección personal de dotfiles son decenas de módulos, no miles — así que el costo interesante es algorítmico, no de throughput. La resolución de dependencias es O(V + E) sobre los módulos y sus aristas declaradas, con una profundidad de recursión acotada por la cadena de dependencias más larga; en cualquier árbol realista es despreciable. La instalación es serial por necesidad y no por descuido: una dependencia debe terminar antes de que arranque su dependiente, así que los módulos se instalan de a uno en una sola goroutine. La única concurrencia es entre esa goroutine y el hilo de primer plano que drena el canal de estado. El costo dominante de tiempo de pared es enteramente externo — brew/apt-get ejecutándose y golpeando la red — que la herramienta no intenta paralelizar precisamente porque el orden de dependencias lo prohíbe. Volver a correr una selección es barato, ya que los paquetes ya instalados se saltan y volver a crear un symlink que ya existe no produce ningún cambio.

Lecciones

La lección más filosa es que importar un archivo existente es destructivo de una forma que no es segura ante fallos. ImportDotfileWithDestination copia el archivo dentro del módulo, borra el original con os.RemoveAll y solo entonces crea el symlink de vuelta. Entre el borrado y el symlink hay una ventana en la que un fallo — o un error al crear el symlink cuyo restore de mejor esfuerzo también falla — deja el archivo viviendo únicamente dentro del módulo, sin nada en su ruta original. El intento de restore existe, pero no es atómico. La forma correcta es crear-el-symlink-primero-y-luego-borrar, o preparar en una ruta temporal y hacer rename en su lugar; copiar/borrar/symlink optimiza para el invariante equivocado.

ok

fail

i — import a path

copy into module

dotfiles/<dest>

os.RemoveAll original ⚠️ danger window

create symlink:

original → module

register in config.yaml

best-effort restore copy

return error

Dos lecciones menores siguieron el mismo tema de estado que se desincroniza de la realidad. El README anunciaba cinco gestores de paquetes (brew/apt/pacman/yum/snap) mientras que el instalador solo llegó a cablear dos — la documentación se pudre hasta volverse una mentira a menos que algo la obligue a seguir al código. Y los formularios de creación/edición limitan los “paquetes específicos” y los “comandos específicos” a dos casillas cada uno, así que editar un módulo cuyo config.yaml declara más descarta los extras en silencio al guardar, porque el formulario reescribe la configuración desde sus propios campos. Un formulario es un editor con pérdida sobre un formato de archivo más rico; el arreglo honesto es renderizar cada entrada dinámicamente, o negarse a guardar un módulo que el formulario no puede representar por completo.

Quick Start

# Compilar el binario
go build -o bin/dotcli .

# Ejecutarlo (gestiona ~/dotfiles/modules por defecto; se crea en el primer arranque)
./bin/dotcli

# Apuntarlo a un árbol descartable para experimentar sin riesgo
DOTFILES_PATH=/tmp/my-dotfiles ./bin/dotcli

Dentro del TUI: Space selecciona un módulo (autoseleccionando sus dependencias), / filtra, x alterna el modo export, Enter instala la selección y ? muestra todos los atajos de teclado.