DotCLI
A terminal-UI dotfiles manager in Go that treats each tool as a self-contained module, resolves inter-module dependencies in topological order, and installs the selection — packages, setup scripts, and dotfile symlinks — from a single keystroke.
What is it?
A single-binary terminal UI that turns a ~/dotfiles/modules/ directory into a dependency-aware installer. Each module is a self-contained folder — a config.yaml declaring its packages, custom commands, dotfile→home mappings, and its dependencies on other modules, plus an optional install.sh and a dotfiles/ subtree. The tool scans that directory, lets you browse and select modules in a Bubble Tea list, resolves the dependency graph into a topological install order, and then installs each module in turn: host packages through the detected package manager (brew/apt), the module’s install.sh, custom commands, and finally symlinks the dotfiles into $HOME. There is no database and no lockfile — the filesystem is the only source of truth, so a module is portable by cp.
Context & Challenge
Most dotfiles “managers” are a pile of symlinks plus a bootstrap shell script. That holds up until two things happen: modules start depending on each other (the editor module needs the shell module in place first, because its config sources a shell function), and you re-run the script on a machine that is already half-configured. A flat script has no notion of ordering or idempotency — it reinstalls packages that are already present, and a hand-maintained install order silently rots the first time you add a module in the wrong place. The constraint here was to install an arbitrary subset of modules in an order that always respects declared dependencies, reject a dependency cycle before touching the filesystem, and skip work that is already done — all without a central manifest, so each module stays a copy-pasteable folder rather than an entry in some global registry.
Decisions
Dependency resolution is a depth-first topological sort with three-state tracking. Each module is marked unvisited, visiting (on the current recursion stack), or visited; recursing into a node that is still visiting is a back-edge, which is exactly a cycle, reported as circular dependency detected: X before any package is installed. The visiting set is what separates a real cycle from a harmless diamond — a module reached twice through two different paths is fine, a module reached while still on the stack is not. I chose recursive DFS over Kahn’s in-degree algorithm because it maps directly onto the natural sentence “resolve this module’s dependencies, then the module itself,” and gives the cycle check a single obvious home.
The filesystem is the source of truth — no database, no lockfile, no registry. The scanner just lists modules/*/, unmarshals each config.yaml, and skips (with a warning, never fatally) any module that fails to parse. The tradeoff is deliberate: with no global “what is installed” state, idempotency is delegated downward — to the package manager’s own “already installed” check and to symlink creation being naturally repeatable — in exchange for modules that carry everything they need and move between machines untouched.
The install engine is decoupled from the UI through a status channel. The Bubble Tea model only records intent — which modules, and whether export mode is on — then quits; main.go drives a goroutine that installs each module and streams InstallationStatus values (each with a progress fraction) over a channel the foreground ranges over and prints. This keeps the alt-screen TUI runtime out of the install path, so you watch real brew/apt-get output instead of fighting the renderer, and the first error breaks the loop with a non-zero exit.
The package-manager layer detects brew then apt and filters each module’s per-manager “specific” packages down to the one that is present, so a module carrying both a brew name and an apt name installs the right one and silently no-ops the other. Export mode is a second install path (InstallDotfilesOnly) that still resolves the full dependency order but runs only the symlink step — for a machine where the software is already there and you only want your configuration applied.
Performance
There are no formal benchmarks here, and inventing them would be dishonest: the working set is small by nature — a personal dotfiles collection is dozens of modules, not thousands — so the interesting cost is algorithmic, not throughput. Dependency resolution is O(V + E) over modules and their declared edges, with recursion depth bounded by the longest dependency chain; on any realistic tree it is negligible. Installation is serial by necessity rather than by oversight: a dependency must finish before its dependent starts, so modules install one at a time in a single goroutine. The only concurrency is between that goroutine and the foreground thread draining the status channel. The dominant wall-clock cost is entirely external — brew/apt-get shelling out and hitting the network — which the tool does not try to parallelize precisely because the dependency order forbids it. Re-running a selection is cheap, since already-installed packages are skipped and symlink creation is idempotent.
Lessons
The sharpest lesson is that importing an existing file is destructive in a way that is not crash-safe. ImportDotfileWithDestination copies the file into the module, deletes the original with os.RemoveAll, and only then creates the symlink back. Between the delete and the symlink there is a window where a crash — or a symlink failure whose best-effort restore also fails — leaves the file living only inside the module, with nothing at its original path. The restore attempt exists, but it is not atomic. The correct shape is symlink-first-then-remove, or stage into a temp path and rename into place; copy/remove/symlink optimizes for the wrong invariant.
Two smaller lessons followed the same theme of state drifting out of sync with reality. The README advertised five package managers (brew/apt/pacman/yum/snap) while the installer only ever wired up two — documentation rots into a lie unless something forces it to track the code. And the create/edit forms cap “specific packages” and “specific commands” at two slots each, so editing a module whose config.yaml declares more silently drops the extras on save, because the form rewrites the config from its own fields. A form is a lossy editor over a richer file format; the honest fix is to render every entry dynamically, or to refuse to save a module the form cannot fully represent.
Quick Start
# Build the binary
go build -o bin/dotcli .
# Run it (manages ~/dotfiles/modules by default; created on first run)
./bin/dotcli
# Point it at a throwaway tree to experiment safely
DOTFILES_PATH=/tmp/my-dotfiles ./bin/dotcli
Inside the TUI: Space selects a module (auto-selecting its dependencies), / filters, x toggles export mode, Enter installs the selection, and ? shows every keybinding.