install.sh and pkg-inventory.sh carried byte-identical copies of read_manifest. That is the duplicated-code half of the problem; the worse half is untested — neither script had any test for the grammar that decides what gets installed. - scripts/lib/manifest.sh is the parser; both scripts source it - tests/manifest-parser.bats writes the contract first (comments, CRLF, first field only, unreadable file fails loudly instead of yielding "nothing desired") - an unreadable manifest now fails inside the helper rather than depending on whichever `set` options the caller happens to run under - install.sh check mode verified end to end against a stubbed pacman: a CRLF manifest with trailing comments resolves to bare names, installed names drop Refs #60
6.4 KiB
type, title, description, tags, verified, timestamp
| type | title | description | tags | verified | timestamp | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| reference | package desired state | packages/*.pkgs plus the two tools that keep a machine and its manifest agreeing — install.sh installs desired-minus-installed, pkg-inventory.sh reports installed-minus-declared. |
|
2026-10-21 | 2026-10-20 |
Package desired state
Two tools, one invariant: the manifests must describe the machine. A rebuild is only faithful if both directions are checked.
| tool | direction | writes |
|---|---|---|
scripts/install.sh |
desired → installed | installs desired − installed with --apply; never removes |
scripts/pkg-inventory.sh |
installed → declared | nothing — read-only report |
Manifests
packages/common.pkgs (every host) + packages/<host>.pkgs (this host) + packages/aur.pkgs
(AUR-only, installed through yay). One parser reads them for both tools:
scripts/lib/manifest.sh — sed 's/#.*//' then the first field, so # comments trail freely and
one token per line is the whole grammar; an unreadable manifest is a loud failure rather than an
empty desired list. tests/manifest-parser.bats owns that contract. The two scripts carried
verbatim copies of the body until 2026-10-21, which is exactly how a manifest line can come to mean
two things to the two halves of the same check. An unknown --host gets the common manifest only:
a wrong-host run is a no-op, not a hazard.
Rebuild a workstation
- Install the OS (CachyOS, niri/noctalia profile). The installer's own package set is not in the manifests and must not be — see the baseline law below.
- Clone this repo to
~/.omp, thenscripts/install.sh --apply— packages first, because the enablement step needssshdandfirewalldpresent (dotfiles-deploy). scripts/deploy.sh syncfor the config tier (dotfiles-deploy).scripts/pkg-inventory.sh --emiton a known-good machine and fold the output intopackages/<host>.pkgs/packages/aur.pkgsbefore step 2 — that is how a hand install stops being invisible to the next rebuild.
pkg-inventory report
undeclared additions (declare these): N ← exit 1 when N > 0
repo (sync dbs): <names> → belongs in packages/<host>.pkgs
aur (needs a helper): <names> → belongs in packages/aur.pkgs
installer-provided, undeclared: N + names → a reinstall supplies these; do not declare
declared, not installed: <names> → install.sh installs these
orphans ignored: <names> → pacman -Qtdq, never drift (see the law below)
flatpak: <app ids> → reported only, install.sh installs no flatpaks
Exit codes: 0 clean · 1 drift · 2 usage · 3 classification impossible. Overrides
PKGINV_REPO / PKGINV_HOST / PKGINV_LOG / PKGINV_BASELINE exist for tests.
Law: the installer's own day is the baseline
Whether a package is yours is decided by the pacman log, not by opinion: the baseline date is the
oldest [ALPM] installed entry — Calamares' pacstrap — and everything installed on or before it
is installer furniture. Without that cut, a base install reports several hundred "additions" and the
list nobody can act on is worse than no list.
- Read the rotated logs too (
pacman.log.N,.N.gz). On a machine older than the rotation window the install day exists only there; skip them and the whole base system is reported as hand-installed. - Calamares keeps installing the desktop for minutes after the base transaction, which is why the date cut is inclusive.
Law: refuse rather than guess the AUR split
aur.pkgs vs <host>.pkgs is decided by whether any sync database can serve the name — Chaotic-AUR
counts as a sync database, so only true AUR belongs in aur.pkgs. If the sync databases are
unavailable the tool exits 3 instead of assuming: guessing sends every package to yay, which is
how a manifest becomes a pile of source builds.
Classify with pacman -Sl (world-readable). pacman -Sqq requires root — a tool that only
reads the machine must not need sudo to answer a question about it.
Known gap: aur.pkgs is read for every host, so a desktop-only AUR package — the niri
module nirimod-git, say, or a GUI client — cannot be declared there without asking the
headless servers to build it. Such a name is still reported as an AUR addition and stays
undeclared (see the note in packages/blankpad.pkgs) until install.sh grows a per-host AUR
manifest.
Law: orphans never reach a manifest
Drift is pacman -Qqe — explicitly installed, therefore yours. pacman -Qtdq names packages
installed as a dependency that nothing requires any more, and a package carries exactly one
install reason, so the two sets cannot overlap (blankpad 2026-10-21: 206 explicit, 3 orphans, none
in common). That is why nothing in pkg-inventory.sh subtracts the orphan list from the additions:
the query is already the guard. Do not add such a subtraction — it cannot fire, and it would bury
the invariant that matters, an implicit install is never drift no matter when it arrived, which
tests/pkg-inventory.bats pins with a dependency installed after the baseline. What the
orphans ignored line is for is the other half: dead weight on the machine that a manifest must
never teach a fresh box to install.
Two traps that produced this tool
set -o pipefailplushead -1:headcloses the pipe,sortdies ofSIGPIPE, the substitution returns141, andset -eaborts the script. It survives a small test fixture and fails on a real 1000-line install-day log — take a minimum in one pass (awk 'END'-style), never sort-then-first. The same applies toawk 'NR==1{exit}', which closes the pipe exactly asheaddoes.tr '\n' ' 'swallows the final newline andfoldfaithfully preserves that absence, so the next report label lands on the same line as the last name.tr '\n' ' '; echoinstead.
Related
- tools/index · dotfiles-deploy (the config tier that follows) · live-sync (the heartbeat that commits machine state)
- Tests:
tests/pkg-inventory.bats(fixtures + stubbedpacman, no real package database touched) andtests/manifest-parser.bats(the grammar above, observed as names in / names out). - Refs #51.