nixup: one CLI for every machine I touch
I got tired of applying a different mental model every time I sat down at a machine. Mac Mini, another Mac, a Linux box. Same tools, three slightly different stories of how they got there.
So I built nixup: a small Rust CLI that owns day-0 and day-2 for a multi-host Nix flake. The flake is nix-darwin + home-manager on Apple Silicon, home-manager only on Linux. App configs stay in .dotfiles and land via GNU stow. This repo owns packages, shell hooks, and agent installers.
What I was trying to learn
How far you can push "config is the source of truth" when Nix wants files on disk, pure flakes ignore gitignored paths, and half your tools still come from curl installers and casks.
Also: can a thin Rust CLI make that feel boring in a good way?
The model
nixup.toml is personal and gitignored. You copy from nixup.toml.example, list hosts, smoke checks, and defaults.
[[hosts]]
id = "my-mac"
match_hostnames = ["my-mac"]
os = "darwin"
flake_attr = "my-mac"
[[hosts]]
id = "my-linux"
match_hostnames = []
os = "linux"
flake_attr = "you@linux"
apply = "home-manager"
Then:
nixup hosts sync --yes # writes hosts/<id>/ + hosts/inventory.nix
nixup bootstrap --yes # Nix if needed, apply, smoke
nixup apply --yes
nixup smoke --strict
nixup doctor
Host modules and the inventory are generated. I do not hand-edit them as the long-term source of truth. Edit the TOML, re-sync, apply.
That collides with pure evaluation. Flakes only see tracked files. Personal hosts/inventory.nix and hosts/<id>/ stay local and gitignored, so pure eval falls back to committed example hosts (my-mac, my-linux) unless you force-stage your private inventory. CI always uses the example. My real machines never need to be in the public tree.
Layout
Four crates, virtual workspace, resolver 3:
| Crate | Job |
|---|---|
nixup-core | Config, host resolution, scaffold (render host default.nix + inventory) |
nixup-ops | Process runners: nix apply, stow, Determinate installer |
nixup-smoke | Required / optional binary checks |
nixup | Clap CLI, console, exit codes |
Shared modules under modules/: common (modern CLIs), shell (nushell + starship binaries only), agents (fail-soft activation for rustup, headroom, claude, codex, grok, beads, radicle), plus darwin/linux splits. Ghostty and the GUI messaging apps go through zerobrew with Homebrew as fallback. Not through pkgs.ghostty. I have dual-install scars.
Day-to-day surface
nixup bootstrap # ensure Nix → apply → smoke → remind about stow
nixup apply # switch for resolved host (switch is an alias)
nixup smoke # --strict exits 2 if a required tool is missing
nixup doctor # flake root, config, host map, light smoke
nixup hosts # list; hosts sync materializes modules
nixup status # OS, hostname, flake, nix version, resolved host
nixup update # nix flake update
nixup stow # optional --clone of defaults.dotfiles_url
Hostname matching picks the host when you omit --host. Unknown host exits 4. Missing Nix on the apply path exits 3 after host resolution, so exit codes stay stable when you fat-finger an id on a machine without Nix.
The open question I'm still chewing on: how much "activation script that curl | bash installs AI tools" I want inside a declarative setup. Fail-soft is honest. Pure is prettier. Right now honest is winning.