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
120 lines
6.4 KiB
Markdown
120 lines
6.4 KiB
Markdown
---
|
||
type: reference
|
||
title: package desired state
|
||
description: 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.
|
||
tags:
|
||
- packages
|
||
- pacman
|
||
- provisioning
|
||
- fleet
|
||
- blankpad
|
||
verified: 2026-10-21
|
||
timestamp: 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
|
||
|
||
1. 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.
|
||
2. Clone this repo to `~/.omp`, then `scripts/install.sh --apply` — packages first, because the
|
||
enablement step needs `sshd` and `firewalld` present ([dotfiles-deploy](dotfiles-deploy.md)).
|
||
3. `scripts/deploy.sh sync` for the config tier ([dotfiles-deploy](dotfiles-deploy.md)).
|
||
4. `scripts/pkg-inventory.sh --emit` on a *known-good* machine and fold the output into
|
||
`packages/<host>.pkgs` / `packages/aur.pkgs` before 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 pipefail` plus `head -1`**: `head` closes the pipe, `sort` dies of `SIGPIPE`, the
|
||
substitution returns `141`, and `set -e` aborts 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 to `awk 'NR==1{exit}'`, which closes the pipe exactly as `head`
|
||
does.
|
||
- **`tr '\n' ' '` swallows the final newline** and `fold` faithfully preserves that absence, so the
|
||
next report label lands on the same line as the last name. `tr '\n' ' '; echo` instead.
|
||
|
||
## Related
|
||
|
||
- [tools/index](index.md) · [dotfiles-deploy](dotfiles-deploy.md) (the config tier that follows) ·
|
||
[live-sync](live-sync.md) (the heartbeat that commits machine state)
|
||
- Tests: `tests/pkg-inventory.bats` (fixtures + stubbed `pacman`, no real package database touched)
|
||
and `tests/manifest-parser.bats` (the grammar above, observed as names in / names out).
|
||
- Refs #51.
|