Skip to content
· 7 MIN READ · GO LANGUAGE · COMMAND LINE · DEVELOPER EXPERIENCE · DOTFILES

A Reason I Could Say Out Loud

I built a CLI that generates my shell environment. The reason I give is an Ubuntu server that ran bash. The real one is that I wanted to build it.

loadout's bash backend was the last phase. Phase 7, shipped after credential switching, after a web interface, after git-backed config sync, after macOS support — on a project that exists because bash did not work.

The reason it exists is real, and I can point at it. I tried to use my aliases on an Ubuntu server and could not: the server ran bash, my functions were zsh, and zsh functions in bash do not fail. They run and quietly do nothing. That is a genuine problem, I have the commits that fix it, and it is not why I started.

The bash mismatch was the first thing I noticed and the least interesting. The box also had aliases for tools that were not on the box — commands standing ready to fail, in my own handwriting. And it had the binaries. The dotfiles repo carried 61MB of committed third-party executables, duckdb and ngrok among them, and they arrived on that server because they arrived everywhere. I did not need duckdb there. I might need it on the next machine. The repo had no way to hold both of those facts at once, because nobody had ever told it there was more than one machine.

That is not a configuration. That is luggage.

What was wrong was not any of those three things on its own. It was that cloning a dotfiles repo replays a machine. It re-enacts the history of the laptop it grew on — that zsh is there, that duckdb is wanted, that ngrok is on PATH — onto a machine with a different history. Every assumption travels, because the repo is a record of where I had been rather than a description of what I wanted.

So I wrote something that renders. One catalog of aliases and functions, one configuration file holding the personal parts — who I am in git, where my projects live — and a command that writes a single file the shell sources. The binaries stopped travelling: they are declarations now, fetched on the machine that wants them and checksummed before they go on PATH. Two shells, two platforms: the same catalog comes out four different ways, and the repo keeps all four on file.

A replay has one output. A render has as many as there are machines.

The server was not the first time. It was the last one. I had installed that setup on other machines before it, and the wanting had been building the whole way. The server is only where I stopped absorbing it.

Which brings back the phase order. The bash backend came last for a defensible reason: once the generator had a backend interface, a second dialect stopped being the hard problem, so it waited because it could afford to wait. That is true, and I would say it in a code review.

It is also not the reason. Bash was the excuse. I already wanted to build this, and the server handed me something I could say out loud.

There is a second one. Before I started I looked at chezmoi, stow, dotbot and the other tools that already exist, and concluded that none of them solved everything I wanted solved. I believe that too. And I really just wanted to build my own. I am not sure the research would have changed my mind if it had come out the other way.

The third is the web interface. loadout ui serves a page on localhost for composing the configuration, and it exists because I wanted to build a web interface. That one has no sayable version. I did not go looking for one.

It is also the most expensive thing in the project. A page that edits what your shell runs at login is a remote code execution API for your own account, so three checks precede every request: a per-run token, an Origin that has to be ours, and a Host that has to be loopback. The last of those is the defence against DNS rebinding, and there is a note in the repo telling me not to loosen it.

It broke in the stupidest way available. The interface served no CSS and no JavaScript at all, because the guard demanded a token on every request and a <script> tag cannot present one. A blank page — produced by the security I added to protect the feature I wanted for no reason.

None of that makes the tool fake. The problems were real, and looking at them now they are one problem wearing three coats: each of them failed without saying so.

The zsh functions on that Ubuntu box did not error. They ran and did nothing. The aliases for tools that were not installed did not announce themselves either — they waited until I typed one. And loadout env, the command that prints export lines for the active credentials, wrote them to stderr, so eval "$(loadout env)" — the exact line in my own README — did nothing at all. Nothing had ever run it end to end. Everything that had looked at it had looked at the text, and the text was right.

So the parts of loadout I am least able to claim I built for fun are the parts that make silence audible. loadout doctor walks everything the catalog asks for and reports what this machine does not have. loadout describe says what a command needs before you call it.

And doctor only reports. The alias for the missing tool still gets written into your file: the render varies by shell and by platform, not by what happens to be installed. So the second of my three failures is the one loadout does not fix — it makes it audible, which is not the same thing. That is at least consistent. loadout does not edit your shell config for you either; the line that sources the generated file is yours to add.

The four outputs from earlier have a name. They are golden files: the complete generated shell for both dialects on both platforms, pinned in the repository. Any change to a template or the catalog shows up as a diff across them, and that diff is the review — because the alternative is that a one-line edit to an alias becomes a silent change to every shell that runs apply next.

One more cost, and it is the one I pay rather than the one I shipped. Secrets in loadout live in the OS keychain and never in the generated file, because a generated file is plain text on disk. Something therefore has to fetch them at runtime — and the line that does it, if you put it in your shell config, reads the keychain every time you open a terminal and puts your tokens into the environment of every process you launch from it. The README lays both sides out and refuses to choose between them.

I chose. The line is in my .zshrc. I wrote that cost down before I took it.

It is on that server now. In bash. Working. My configuration lives in a git repository and moves between the machines, which was the founding problem and is not one any more.

One person who is not me has installed loadout. A colleague. No bugs reported, and nothing in the tool changed when they installed it, because I had already built it so that someone besides me could use it. There is a CODE_OF_CONDUCT.md in that repository, and a CONTRIBUTING.md, and a Homebrew tap in a repo of its own, and an install script served from a domain I own. The user base is one colleague. I built for an audience that has not arrived.

loadout is the first of several. makery.tools is where the rest of them go.

So "I wanted to build my own" is not something that happened to this project. It is the practice. The excuse changes every time. The sentence underneath it does not.