Package Management Architecture¶
Purpose: Unified strategy for installing and managing CLI tools across all platforms
Philosophy¶
Priority: Latest versions and cross-platform consistency over system package manager convenience
Rationale: Ubuntu LTS (and other system package managers) ship conservative versions that are often 6-12 months (or more) behind upstream. This causes:
- Missing features and bug fixes
- Plugin compatibility issues (especially Neovim)
- Naming conflicts (bat/batcat, fd/fdfind)
- Platform-specific workarounds
By using universal installation methods (cargo-binstall, GitHub releases), we get:
- ✅ Same versions on macOS and Linux
- ✅ Latest features and fixes
- ✅ Consistent binary names
- ✅ User-space installation (no sudo needed)
Three-Tier Strategy¶
Tier 1: GitHub Releases (Latest Stable)¶
When to use: Core tools requiring specific versions, not available in cargo/language ecosystems
Installation target: ~/.local/bin or ~/.local/{tool-name}/
Method: Download pre-built binaries from GitHub releases
Tools:
yq- YAML processor (single binary)go- Build toolchain (extract to/usr/local/goper official docs)fzf- Fuzzy finder (pre-built binary)neovim- Editor (extract to~/.local/nvim-linux-x86_64/, symlink binary)lazygit- Git TUI (single binary)yazi- File manager (single binary + plugins)glow- Markdown renderer (single binary)duf- Disk usage utility (single binary)awscli- AWS command line tool (platform-specific installer)
Advantages:
- Latest stable releases
- No compilation required
- Universal across platforms
- Predictable versions
Tier 2: cargo-binstall (Rust Ecosystem)¶
When to use: Rust CLI tools where we want latest versions
Installation target: ~/.cargo/bin
Method: Download pre-compiled Rust binaries (much faster than cargo install). Packages with a repo field in packages.yml are compiled from source via cargo install --git instead — used for personal Rust tools not published to crates.io.
Tools:
See install/packages.yml (cargo_packages section) for the current list.
Advantages:
- Pre-compiled binaries (fast, 10-30 seconds)
- Latest versions from crates.io
- No naming conflicts
- Consistent across platforms
vs cargo install:
cargo installcompiles from source (5-10 minutes per tool)cargo-binstalldownloads pre-built binaries (10-30 seconds)- Same result, 20x faster!
Tier 3: System Package Managers (Stable)¶
When to use: System utilities where version doesn't matter, or tools with large system dependencies
Installation target: /usr/bin (lowest PATH priority)
Method: apt (Ubuntu), brew (macOS), pacman (Arch)
Tools:
Shell & Terminal:
zsh- Shell itselftmux- Version 3.4 is acceptable (3.5a is only bugfixes)
System utilities:
tree,htop,jq- Stable tools, version doesn't matter
Build tools:
build-essential,curl,wget,unzippkg-config,libssl-dev,ca-certificates
Multimedia (large dependencies):
ffmpeg- Video/audio processingimagemagick- Image manipulationpoppler-utils- PDF toolschafa- Image preview7zip- Archive extraction
Advantages:
- Fast installation (pre-compiled, cached)
- System integration (man pages, completions)
- Security updates via
apt upgrade - Shared dependencies
Disadvantages:
- Outdated versions (6-12+ months behind)
- Naming conflicts on Ubuntu (batcat, fdfind)
Core vs workstation split¶
System packages carry a second, orthogonal tier that controls which machines
install them (distinct from the Tier 1/2/3 installation-method split above).
Each entry in the single system_packages list may be tagged tier: core;
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 (the linux-lxc-server manifest) installs only the
core base. The multimedia and docker packages above are workstation-only; the
shell/build/diagnostic essentials are core. parse_packages.py --tier does the
filtering, so there is still one list, not two. See
Minimal Manifest for Servers.
Shell Plugins (Git Clone)¶
When to use: ZSH plugins that need to be sourced directly
Installation target: ~/.config/zsh/plugins/
Method: Git clone from upstream repositories
Plugins (defined in install/packages.yml):
git-open- Open repo in browser from terminalzsh-vi-mode- Better vi-mode for ZSHforgit- Interactive git commands with fzfzsh-syntax-highlighting- Fish-like syntax highlighting for ZSH
Advantages:
- Latest versions from upstream
- Easy to update with
git pull - Consistent across all platforms
- No package manager dependencies
Management:
- Install:
install/common/plugins/shell-plugins.sh(reads from packages.yml) - Update:
dotfiles update plugins
Installation Location Strategy¶
PATH Priority (highest to lowest):
~/.cargo/bin/ # Tier 2: Rust tools (bat, fd, eza, zoxide, delta)
~/.local/bin/ # Tier 1: GitHub releases (nvim, lazygit, fzf, yq, yazi)
~/go/bin/ # Go-installed binaries (toolbox, sesh)
/usr/local/go/bin/ # Go toolchain
~/.local/share/npm/bin # npm global packages
/usr/local/bin/ # Homebrew/system-wide installs
/usr/bin/ # System packages (lowest priority)
Why this order?
- User tools override system - Your latest tools take precedence
- Language ecosystems together - Each package manager in its own directory
- System packages last - Stable but outdated, lowest priority
See PATH Ordering Strategy for complete details.
Special Case: Neovim Directory Structure¶
Why neovim can't be a single binary like lazygit:
Neovim is not a self-contained binary - it's an application bundle with many support files:
~/.local/nvim-linux-x86_64/
├── bin/
│ └── nvim # The executable
├── lib/
│ └── nvim/ # Shared libraries
└── share/
├── nvim/
│ └── runtime/ # CRITICAL: syntax files, plugins, help docs
├── man/ # Man pages
└── locale/ # Translations
The Problem: The nvim binary expects runtime files at ../share/nvim/runtime/ (relative to the binary location).
What happens if we move just the binary:
# DON'T DO THIS:
mv nvim-linux-x86_64/bin/nvim ~/.local/bin/nvim
# Neovim will look for runtime at:
~/.local/share/nvim/runtime/ # Wrong location!
# Actual location:
~/.local/nvim-linux-x86_64/share/nvim/runtime/ # Correct location
# Result: Neovim fails with "runtime files not found"
The Solution: Keep directory structure intact, symlink the binary:
# Extract full structure (neovim changed filename from nvim-linux64 to nvim-linux-x86_64)
tar -C ~/.local -xzf nvim-linux-x86_64.tar.gz
# Creates: ~/.local/nvim-linux-x86_64/
# Symlink binary into PATH
ln -sf ~/.local/nvim-linux-x86_64/bin/nvim ~/.local/bin/nvim
# Now:
# - Binary is in PATH (via ~/.local/bin/nvim)
# - Binary finds runtime (../share/nvim/runtime/ from real location)
# - Everything works perfectly!
Compare to lazygit (single binary):
# lazygit is self-contained:
tar -xzf lazygit.tar.gz lazygit
mv lazygit ~/.local/bin/lazygit # Direct move works!
# Everything it needs is compiled into the single binary
Summary:
- Single binary tools (lazygit, yq, fzf) → Direct to
~/.local/bin/ - Application bundles (neovim) → Extract to
~/.local/{tool-name}/, symlink binary
Version Comparison¶
See Package Version Analysis for version comparisons and the rationale for each tool's installation method.
Implementation¶
Single Source of Truth: packages.yml¶
All package versions, repositories, and configurations are centralized in install/packages.yml. This repo previously 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. See GitHub Releases vs System Packages for the decision framework on choosing installation methods.
Every installation type is catalogued in packages.yml, including custom installers. There is no auto-detection anywhere: install.sh, update.sh, and install/offline/create-bundle.sh all drive from the corresponding packages.yml section rather than listing directories. A script with no catalog entry (or a catalog entry with no script) is a hard error — see Drift Detection below.
Package definitions in packages.yml:
runtimes:
go:
min_version: "1.23"
node:
version: "24.11.0"
python:
min_version: "3.12"
github_releases:
- name: neovim
repo: neovim/neovim
version: "0.11.0"
- name: lazygit
repo: jesseduffield/lazygit
version: "0.44.1"
# ... more tools
cargo_packages:
- name: bat
command: bat
description: cat with syntax highlighting
- name: fd-find
command: fd
description: modern find replacement
# ... more tools (bat, eza, zoxide, git-delta, oxker, broot)
uv_tools:
- name: ruff
package: ruff
# ... more tools
All installation scripts read from this single source. Change a version once, and it applies everywhere.
Drift Detection¶
The packages verify subcommand (in apps/common/packages) enforces that packages.yml, the machine manifests, and the installer script directories stay in sync. It runs on every commit via pre-commit and surfaces four classes of drift:
- Shape errors — an entry missing required fields (e.g., a
github_releasesentry with norepo), or duplicate names within a section. - Unresolved manifest names — a manifest lists a name that has no corresponding packages.yml entry (the no-op that shipped
todouiandforgeghost-installed for weeks). - Script parity breaks — a script in
install/common/github-releases/orinstall/common/custom-installers/with no packages.yml entry, or vice versa. - Deprecated manifest keys —
go: true/rust: true/nvm: true/uv: true/tenv: true; these runtime gates were removed in Phase 1.6 in favor of name-list derivation.
A fifth, softer check warns when packages.yml defines an entry that no manifest subscribes to — useful for spotting orphans without failing the commit.
Behavior is authoritative in --help and apps/common/packages verify --help. Tests live in tests/apps/test_packages_verify.py and drive verify against synthetic fixture trees (one test per check), so coverage doesn't depend on the real repo being in any particular state.
Installation Scripts¶
Located in install/common/:
Directory Structure:
github-releases/- Installers for tools shipped as prebuilt GitHub release binarieslanguage-managers/- Language runtime / version-manager bootstrappers (uv, rustup, go)language-tools/- Per-language package installers, driven by the tool lists inpackages.ymlcustom-installers/- Vendor-specific installers that don't fit the other patternsplugins/- Editor and terminal plugin installers
The specific tools in each category are defined in install/packages.yml (the single
source of truth) — this list describes what each directory is for, not its contents.
Core Library (install/common/lib/):
failure-logging.sh- Structured failure reportinggithub-release-installer.sh- Shared functions for GitHub release tools
All installer scripts support --update for the update system and use structured error reporting.
The root install.sh orchestrates the installation process, detecting the platform and running appropriate scripts from install/{platform}/ and install/common/{category}/.
Main Installation Flow¶
install.sh orchestrates installation phases:
- System packages (brew/apt/pacman)
- GitHub release tools
- Rust/cargo tools
- Language package managers
- Shell configuration
- Custom Go applications
- Symlink dotfiles
- Theme system
- Plugin installation
Taskfile Tasks¶
The Taskfile.yml provides convenience tasks for common operations but delegates complex logic to shell scripts:
See Task Reference for all available tasks.
Maintenance¶
Updating tools:
# Rust tools
cargo binstall -y <package>
# GitHub release tools — re-run installer script directly
bash install/common/github-releases/neovim.sh
bash install/common/github-releases/lazygit.sh
# System packages
sudo apt update && sudo apt upgrade
Version checking: Each install script checks current version before installing, skipping if acceptable version already present.
Related Documents¶
- PATH Ordering Strategy - How tool resolution works
- Package Version Analysis - Detailed version comparisons
- App Installation Patterns - Go apps vs shell scripts
- Idempotent Installation Patterns - Re-runnable scripts