Package Management Architecture¶
Purpose: Unified strategy for installing and managing CLI tools across all platforms
Latest versions and one binary name beat package-manager convenience¶
Ubuntu LTS and its peers ship conservative versions, often 6-12 months behind
upstream. Neovim plugins are the first thing that breaks against a stale editor.
Ubuntu also renames binaries that collide with existing Debian packages, so bat
arrives as batcat and fd as fdfind. Cargo-binstall and GitHub releases put
one version and one name on macOS and Linux, in user space, with no sudo.
Three install tiers, ordered by how much the version matters¶
Tier 1: GitHub Releases (Latest Stable)¶
Pre-built binaries, into ~/.local/bin or ~/.local/<tool-name>/. Read the
roster with packages list --section=github_releases.
Default to a system package. Move a tool here only for a reason. The reasons that hold up:
- Releases come faster than the package managers ship them. "Frequent" means the tool is actively evolving, not just patching.
- It is security tooling.
trivyreleases monthly, and a lagging vulnerability database means missed CVEs. That is the strongest case in the list. - The binary is self-contained, so the distro's dependency handling gains nothing.
- The same version is wanted on macOS and Linux at once.
The inverse test names the tool that should not be here. mkcert last
released in 2022, ships as 1.4.4 in every package manager, and is
feature-complete. A GitHub-release installer for it would be a hand-maintained
version pin replacing something the OS updates for free.
Tier 2: cargo-binstall (Rust Ecosystem)¶
Rust CLI tools, into ~/.cargo/bin. cargo-binstall downloads the release
binary the project already published, where cargo install compiles the same
code from source. That is the whole reason it is the default here.
An entry names its asset without asking the network, which is what lets these
tools go into an offline bundle. Online, binstall is handed the same name as its
download URL, so both channels install the same file. The fields that do it are
CargoPackage in src/dotfiles/catalog.py.
Tier 3: System Package Managers (Stable)¶
apt, brew and pacman, into /usr/bin at the lowest PATH priority. Use them for
system utilities whose version does not matter, and for anything dragging large
multimedia or compiled dependencies behind it. Read the roster with
packages list --section=system_packages.
Core vs workstation split¶
System packages carry a second tier that controls which machines install them,
orthogonal to the Tier 1/2/3 installation-method split above. An entry may be
tagged tier: core, and untagged entries default to workstation-only. A manifest
declares which set it wants via system_packages: core | workstation. A
workstation installs everything; a minimal server installs only the core base.
Subscription.wants in src/dotfiles/machine.py does the filtering, so there is
still one list rather than two. See
Minimal Manifest for Servers.
Shell plugin load order lives in .zshrc, not in the manifest¶
ZSH plugins that need sourcing directly are git-cloned into
~/.config/zsh/plugins/ by src/dotfiles/resources/plugins.py.
zsh-syntax-highlighting must be sourced last so it can wrap every widget the
other plugins define. zsh-vi-mode overwrites the whole keymap when it
initializes, which is why every other keybinding is re-applied from
zvm_after_init.
Installation Location Strategy¶
Every install method writes to its own directory, and PATH order decides which copy wins. User-installed tools outrank system ones, and each ecosystem keeps its own prefix.
A declared tool is measured in its own provider's directory, not by name on
PATH. evidence.in_provider_dir is where that happens, and the two questions
come apart wherever another manager ships the same tool: a cargo_packages entry
answering from /usr/local/bin is a brew formula somebody installed, not a
declaration this repo has ever satisfied. Such an entry reads missing and the
row names the copy that does answer, so a machine whose rg --version works is
never reported as simply lacking it.
PATH is built in two places, and they are not the same order. .zshenv sets
a base every shell gets, including non-interactive ones. Interactive shells then
run .zshrc, which rebuilds the front of PATH with add_path. add_path
prepends, so the last call wins — the list there is written deliberately
backwards, system directories first and user ones last. Add a new entry at the
wrong end and everything below it shadows it.
Read the current answer rather than a copy of it:
The bat/batcat split is a naming problem, not a version one. Nothing that
calls bat finds batcat, so config and scripts would need per-platform
branching. Installing both through cargo binstall sidesteps it entirely — one
name on every platform, which is why they are cargo_packages and not
system_packages.
Neovim ships a bundle, so the tree is what gets installed¶
The nvim binary resolves its runtime files relative to its own location, at
../share/nvim/runtime/. Moving the binary alone into ~/.local/bin/ therefore
sends it looking in ~/.local/share/nvim/runtime/, and it fails with "runtime
files not found". The whole extracted tree lands in ~/.local/nvim-linux-x86_64/
and only the binary is symlinked onto PATH, which keeps the relative path intact.
src/dotfiles/providers/releases.py declares that with tree=True.
Currency is asked only of the tools that can answer it¶
Go, cargo, release, custom-installer and git uv entries carry currency; npm and
PyPI do not. That is not an inconsistency to even up. go install @latest and
cargo binstall are the upgrade and nothing else moves them, so being behind
the repo the declaration names is drift plan reports. npm and PyPI decide what
latest means and upgrade in bulk, so asking per package is asking their own
question back.
A uv tool naming an index is measured against that index. The index is one these tools publish to, so no registry above it decides when they move. Its project page lists every file with its version, and the newest final release is the answer.
A uv tool whose receipt names another source than its row is stale. A machine still holding a git install of a tool its row now takes from an index, or from PyPI, is moved there by a reinstall. A PyPI row is held only against a git or path source, because uv also records an index a config file supplied.
A cargo package is measured against crates.io, not its repo. The registry is
what cargo binstall resolves from, and it trails any project that tags ahead of
publishing there. Comparing against the tag in that window reports drift apply
cannot close. The repo names only the release asset binstall downloads.
An offline bundle stages that same crates.io version. Offline, the bundle row is
the upstream the check compares against. A row at a tag ahead of the crate reads
as stale on every apply when the binary prints the crate's version, as sd's
v1.1.0 asset prints sd 1.0.0.
An entry that cannot be asked declares so rather than being probed and hoped for.
Which clauses have to hold, and the GUI whose probe blocked three dotfiles plan
runs on its own event loop, are has_currency in
src/dotfiles/resources/packages.py.
Implementation¶
Single Source of Truth: packages.yml¶
All package versions, repositories, and configurations are centralized in
install/packages.yml. This repo once maintained both a Brewfile and
packages.yml, which guaranteed drift — the migration found ~70 duplicate packages
and tools that existed in one list but not the other. Lesson: if two lists
describe the same things, one of them is wrong.
Every installation type is catalogd, including custom installers. There is
no auto-detection anywhere: install.sh, dotfiles apply and
src/dotfiles/create_bundle.py all drive from the corresponding packages.yml
section rather than listing directories. An entry nothing can install is a hard
error, and the reverse — an installer function nothing declares — is asserted by
tests/install/.
Read the file for the entries; the header comment on each section states what its fields mean.
Version constraints¶
Latest is the default. An entry that declares no constraint installs whatever upstream calls newest, and almost every entry declares none.
Constraint values are bare versions, never tags and never operators: 0.56.0,
not v0.56.0, cli/v0.9.0 or >=0.56.0. The same release is spelled three
different ways across this catalog, so resolving a version to a tag is the
resolver's job rather than the declaration's.
| key | means | resolver behavior |
|---|---|---|
version |
install exactly this | resolve to this release; never report OUTDATED |
min_version |
floor | a resolved version below it is an issue, not drift |
max_version |
ceiling | resolve to the newest release at or below it |
All three absent means latest. version excludes the other two, and
min_version must not exceed max_version. The case the keys cannot express,
"anything but this one broken release", is covered by holding max_version
below it.
Rejected: a constraint expression such as >=1.2,<2.0. Versions across the
catalog are neither PEP 440 nor consistent semver, so an expression parser over
them would need a second comparator. Three keys map one-to-one onto three
resolver behaviors over the one comparator in versions.py.
A machine manifest overrides one entry's constraints by listing it as a mapping,
- {name: lazygit, max_version: "0.63.0"}, under the same rules. Why that replaces
the row's constraints rather than merging with them is Subscription.constraints
in src/dotfiles/machine.py.
Why a section whose provider honors none of the keys refuses them outright is
the Entry docstring in src/dotfiles/catalog.py. Why an offline run reads the
bundle before any constraint resolves is resolve_tag in
src/dotfiles/providers/ghrelease.py.
Drift Detection¶
dotfiles machines check asks whether packages.yml, the machine manifests and
what can install them still agree. It runs on every commit via pre-commit. Which
questions it asks, and why each is a relationship no single file can answer about
itself, are the module docstring in src/dotfiles/validate.py.
dotfiles plan is the machine-side counterpart and is deliberately a separate
command. This one compares the repo against itself and runs on every commit;
plan compares this machine against what its manifest declares, and a box
part-way through a rollout is not a repo defect that should fail a commit.
dotfiles check asks the third question — whether anything is actually wrong —
and a machine merely behind on versions answers no.
Evidence that a declared item is installed is the provider's own, in
src/dotfiles/evidence.py. Two fields on the declaration are the editor's job
there. Declare command where the binary name differs from the entry name
(markdownlint-cli → markdownlint, awscli → aws). Declare installed_path
for an entry that installs no binary — bashselfupdate is a sourced library.
Without those, an installed tool reads as missing forever, which is the failure
mode that makes a checker get ignored.
Every section installs through a provider, never a script¶
eza -1 src/dotfiles/providers/ is the roster, and install/packages.yml's header
comment maps each section to its install method. A provider is a module rather
than one line in a registry because some carry a precondition the runtime does
not — cargo-binstall itself, and npm's prefix. See
GitHub Releases and
Custom Installers.
What is left in install/common/lib/ is the bash that a human still runs by hand.
Its README says why each file is there, which is the part a reader cannot recover
from the code.
Main Installation Flow¶
install.sh is a POSIX bootstrap whose only job is reaching the CLI. It stops
once the package is installed and prints the dotfiles apply that installs the
machine. What that apply does is the whole plan, sorted by plan.Stage, whose
order is a dependency chain rather than a listing.
Related Documents¶
- Shell Libraries - The libraries installers source
- Task Reference - How the Taskfile is organized
- App Installation Patterns - Go apps vs shell scripts
- Resilient Installation Patterns - Failure isolation and re-runnability