From 6df45d709a41b52981652b74ef7d9080bd515553 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 20 Oct 2026 18:07:42 -0700 Subject: [PATCH 1/2] Add pkg-inventory.sh: report what a machine has outgrown its manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh installs desired − installed from packages/*.pkgs, which guarantees a fresh box reaches the declared state — and nothing anywhere checked that the declared state still describes the machines. Measured against the Calamares install day, blankpad carries 41 hand-installed packages and packages/blankpad.pkgs declared one line. pkg-inventory.sh walks the other direction: installed − declared, with the pacman log as the baseline so installer furniture is never reported as a hand install. It splits additions by whether a sync db can serve them (Chaotic-AUR counts, so only true AUR routes to aur.pkgs), excludes orphans from --emit, reports flatpak apps without pretending install.sh installs them, and exits 3 rather than guessing when the sync databases are unreadable. 16 black-box tests over fixtures and a stubbed pacman. Two of them exist because the first implementation was wrong in ways a small fixture cannot show: sort|head under pipefail dies of SIGPIPE on a real 1000-line install-day log (awk-exit closes the pipe exactly as head does), and tr without a trailing echo glues the next report label onto the last folded name. Docs: omp-docs/tools/packages.md now owns the package tier — install.sh was never documented. Refs #51 --- omp-docs/tools/index.md | 1 + omp-docs/tools/packages.md | 106 ++++++++++++++++++ scripts/pkg-inventory.sh | 159 ++++++++++++++++++++++++++ tests/pkg-inventory.bats | 223 +++++++++++++++++++++++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 omp-docs/tools/packages.md create mode 100755 scripts/pkg-inventory.sh create mode 100755 tests/pkg-inventory.bats diff --git a/omp-docs/tools/index.md b/omp-docs/tools/index.md index 820f9cb..f990fd2 100644 --- a/omp-docs/tools/index.md +++ b/omp-docs/tools/index.md @@ -19,6 +19,7 @@ Configuration guides and reference material for installed software. Each tool ha * [neovim](neovim.md) — Two editors in one binary: vanilla `nvim` for quick edits and the `lazyvim` NVIM_APPNAME profile, with the base16 wallpaper-palette hook and fresh-box install. * [niri](niri/index.md) — Scrollable-tiling Wayland compositor — configuration, keybindings, and architecture for the workstation. * [noctalia-shell](noctalia-shell.md) — Native desktop environment for Niri — launcher, control center, settings, lock screen, session menu, hotkey overlay, media controls, and system management. +* [packages](packages.md) — `packages/*.pkgs` desired state and the two tools that keep a machine and its manifest agreeing: `install.sh` installs the delta, `pkg-inventory.sh` reports what the machine has outgrown. * [tmux](tmux.md) — Terminal multiplexer configuration, extended-keys setup, keyboard input best practices, and reload/restart procedures. ## Adding a New Tool diff --git a/omp-docs/tools/packages.md b/omp-docs/tools/packages.md new file mode 100644 index 0000000..41d80b5 --- /dev/null +++ b/omp-docs/tools/packages.md @@ -0,0 +1,106 @@ +--- +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-20 +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/.pkgs` (this host) + `packages/aur.pkgs` +(AUR-only, installed through `yay`). The parser is `sed 's/#.*//'` then the first field, so `#` +comments trail freely and one token per line is the whole grammar. 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/.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): → belongs in packages/.pkgs + aur (needs a helper): → belongs in packages/aur.pkgs +installer-provided, undeclared: N + names → a reinstall supplies these; do not declare +declared, not installed: → install.sh installs these +orphans ignored: → pacman -Qtdq +flatpak: → 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 `.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 (a niri module, 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; leave it undeclared until `install.sh` grows a per-host AUR +manifest. + +## Law: orphans never reach a manifest + +`pacman -Qtdq` names are packages nothing depends on; emitting them would teach a fresh machine to +install the same dead weight. They are printed as `orphans ignored` and excluded from `--emit`. + +## 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). +- Refs #51. diff --git a/scripts/pkg-inventory.sh b/scripts/pkg-inventory.sh new file mode 100755 index 0000000..97f5386 --- /dev/null +++ b/scripts/pkg-inventory.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# scripts/pkg-inventory.sh — what does this machine have that packages/*.pkgs does not declare? +# +# install.sh walks desired → installed. This walks installed → declared, and the pair is what +# makes a rebuild honest: a manifest the machine has quietly outgrown reproduces a machine that +# is missing the tools you installed by hand six weeks ago, and nothing notices until the new box +# has no tmux. Refs #51. +# +# The baseline is what separates "you installed this" from "the installer installed this". It is +# read from the pacman log — the install-day transactions, dated at the oldest `[ALPM] installed` +# line — because declaring Calamares furniture in a manifest is bookkeeping for something a +# reinstall provides for free. Rotated logs (pacman.log.N, .N.gz) are read too: on a machine older +# than the rotation window the install day lives only there, and skipping them would report the +# entire base system as hand-installed. +# +# Report (stdout): +# undeclared additions (declare these): N hand-installed, absent from the manifests +# repo (sync dbs): → belongs in packages/.pkgs +# aur (needs a helper): → belongs in packages/aur.pkgs (yay) +# installer-provided, undeclared: N a reinstall supplies these; do not declare +# declared, not installed: N install.sh installs these +# orphans ignored: pacman -Qtdq — never emitted +# flatpak: reported only, install.sh installs no flatpaks +# +# --emit prints the additions as manifest lines, ready to fold into packages/. Names are bare +# tokens so install.sh's `sed 's/#.*//'` + first-field parser reads them unchanged. +# +# Exit status: 0 clean · 1 drift (undeclared additions exist) · 2 usage · 3 cannot classify +# (pacman log unreadable or sync databases unavailable — worse to guess than to say so, because +# guessing would dump every package into aur.pkgs). +# +# Environment overrides (tests): +# PKGINV_REPO repo root holding packages/ (default: git toplevel of this script) +# PKGINV_HOST host name (default: $(hostname)) +# PKGINV_LOG space-separated pacman logs (default: /var/log/pacman.log + rotated) +# PKGINV_BASELINE YYYY-MM-DD, inclusive (default: oldest install date in the logs) + +set -euo pipefail +export LC_ALL=C # comm/sort agreement: pacman's order is not this order + +MODE="report" +while [[ $# -gt 0 ]]; do + case "$1" in + --emit) MODE="emit"; shift ;; + -h | --help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "usage: pkg-inventory.sh [--emit]" >&2; exit 2 ;; + esac +done + +REPO="${PKGINV_REPO:-}" +if [[ -z "$REPO" ]]; then + REPO="$(git -C "$(dirname "$(realpath "$0")")" rev-parse --show-toplevel 2>/dev/null || true)" +fi +[[ -n "$REPO" && -d "$REPO/packages" ]] || { echo "pkg-inventory: no packages/ under ${REPO:-}" >&2; exit 2; } +HOST="${PKGINV_HOST:-$(hostname)}" + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +read_manifest() { # same parser install.sh uses, so a name means the same thing to both + sed 's/#.*//' "$1" | tr -d '\r' | awk '{if ($1) print $1}' +} +# ---- installed state --------------------------------------------------------- +# Four queries are the tool's whole interface to pacman. -Sl is the unprivileged sync listing: +# -Sqq asks for root, and a read of the machine must not need sudo. Field 2 of -Sl is the package +# name, and one name can appear in several databases (core/extra/cachyos), hence the sort -u. +pacman -Qqe > "$TMP/explicit" +pacman -Qq > "$TMP/installed" +pacman -Qtdq > "$TMP/orphans" 2>/dev/null || true # exit 1 when the list is empty: no orphans +if ! pacman -Sl > "$TMP/sync.raw" 2>/dev/null || [[ ! -s "$TMP/sync.raw" ]]; then + echo "pkg-inventory: no sync database available (run: sudo pacman -Sy) — repo vs AUR cannot be classified" >&2 + exit 3 +fi +awk '{print $2}' "$TMP/sync.raw" | sort -u > "$TMP/sync" + +# ---- declared desired state -------------------------------------------------- +: > "$TMP/declared" +for m in common.pkgs "$HOST.pkgs" aur.pkgs; do + [[ -f "$REPO/packages/$m" ]] || continue + read_manifest "$REPO/packages/$m" >> "$TMP/declared" +done + +# ---- baseline: what the installer put here ----------------------------------- +logs=() +if [[ -n "${PKGINV_LOG:-}" ]]; then + read -ra logs <<< "$PKGINV_LOG" +else + logs=(/var/log/pacman.log) + for f in /var/log/pacman.log.[0-9]*; do [[ -e "$f" ]] && logs+=("$f"); done +fi +read_logs() { + local f + for f in "${logs[@]}"; do + [[ -r "$f" ]] || continue + case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac + done +} +# "YYYY-MM-DD name" for every install event the log remembers. +read_logs | sed -nE 's/^\[([0-9-]{10})T[^]]*\] \[ALPM\] installed ([^ ]+) \(.*/\1 \2/p' > "$TMP/events" +[[ -s "$TMP/events" ]] || { echo "pkg-inventory: no install events readable in ${logs[*]} — baseline unknown" >&2; exit 3; } + +# Minimum date in a single awk pass. Sorting and then taking the first line — whether with `head` +# or with an awk that exits early — closes the pipe, and pipefail turns sort's SIGPIPE into a dead +# script; a real install-day log is long enough for that to bite (it killed the first real run). +BASELINE="${PKGINV_BASELINE:-$(awk '{ if (m == "" || $1 < m) m = $1 } END { print m }' "$TMP/events")}" +[[ "$BASELINE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "pkg-inventory: bad PKGINV_BASELINE '$BASELINE' (want YYYY-MM-DD)" >&2; exit 2; } + +# Inclusive: packages installed ON the baseline date came with the install too — Calamares keeps +# installing the desktop for minutes after the base transaction. +awk -v d="$BASELINE" '$1 <= d {print $2}' "$TMP/events" | sort -u > "$TMP/base" + +for f in explicit installed sync declared base orphans; do sort -u -o "$TMP/$f" "$TMP/$f"; done + +# ---- the sets ---------------------------------------------------------------- +# additions: explicit, not declared, and not something the installer handed us. +comm -23 "$TMP/explicit" "$TMP/declared" > "$TMP/undeclared" +comm -23 "$TMP/undeclared" "$TMP/base" > "$TMP/additions" +comm -12 "$TMP/undeclared" "$TMP/base" > "$TMP/installside" +comm -12 "$TMP/additions" "$TMP/sync" > "$TMP/add.repo" +comm -23 "$TMP/additions" "$TMP/sync" > "$TMP/add.aur" +comm -23 "$TMP/declared" "$TMP/installed" > "$TMP/missing" + +n() { wc -l < "$1" | tr -d ' '; } + +if [[ "$MODE" == emit ]]; then + if [[ -s "$TMP/add.repo" ]]; then + printf '# packages/%s.pkgs — additions measured %s (pkg-inventory.sh, baseline %s)\n' \ + "$HOST" "$(date +%F)" "$BASELINE" + cat "$TMP/add.repo" + fi + if [[ -s "$TMP/add.aur" ]]; then + printf '# packages/aur.pkgs — additions measured %s (AUR: not in any sync db, install via yay)\n' \ + "$(date +%F)" + cat "$TMP/add.aur" + fi + exit 0 +fi + +printf 'pkg-inventory host=%s baseline=%s repo=%s\n' "$HOST" "$BASELINE" "$REPO" +printf 'counts declared=%s explicit=%s baseline=%s installed=%s\n' \ + "$(n "$TMP/declared")" "$(n "$TMP/explicit")" "$(n "$TMP/base")" "$(n "$TMP/installed")" +printf 'undeclared additions (declare these): %s\n' "$(n "$TMP/additions")" +printf ' repo (sync dbs): %s\n' "$(tr '\n' ' ' < "$TMP/add.repo")" +printf ' aur (needs a helper): %s\n' "$(tr '\n' ' ' < "$TMP/add.aur")" +printf 'installer-provided, undeclared (a reinstall provides these): %s\n' "$(n "$TMP/installside")" +# The names, folded: this is the answer to "what did the installer mark explicit that I never +# declared", and it is long — a count alone would force a second run to see it. +# The echo matters: tr eats the final newline, fold preserves that absence, and the next label +# would otherwise be glued to the last folded line. +{ tr '\n' ' ' < "$TMP/installside"; echo; } | fold -s -w 88 | sed 's/^/ /' +printf 'declared, not installed (install.sh installs these): %s\n' "$(tr '\n' ' ' < "$TMP/missing")" +printf 'orphans ignored (pacman -Qtdq): %s\n' "$(tr '\n' ' ' < "$TMP/orphans")" +if command -v flatpak > /dev/null 2>&1 && flatpak list --app --columns=application > "$TMP/flatpak" 2>/dev/null; then + printf 'flatpak (reported only; install.sh installs no flatpaks): %s\n' "$(tr '\n' ' ' < "$TMP/flatpak")" +else + printf 'flatpak: not installed on this host\n' +fi + +[[ -s "$TMP/additions" ]] && exit 1 +exit 0 diff --git a/tests/pkg-inventory.bats b/tests/pkg-inventory.bats new file mode 100755 index 0000000..7446e94 --- /dev/null +++ b/tests/pkg-inventory.bats @@ -0,0 +1,223 @@ +#!/usr/bin/env bats +# tests/pkg-inventory.bats — black-box tests for scripts/pkg-inventory.sh (Refs #51) +# +# Contract: `install.sh` installs desired-minus-installed from packages/*.pkgs. This tool walks +# the other direction — installed-minus-declared — because a manifest the machine has quietly +# outgrown cannot reproduce the machine on a rebuild. Only stdout, stderr and the exit status are +# observed: 0 no drift, 1 drift (hand-installed packages undeclared), 2 usage, 3 sync databases +# unavailable so repo/AUR classification is impossible. +# +# Every pacman/flatpak call hits a stub: nothing here may query the real package database, and a +# real pacman on PATH would make the fixture answers depend on the workstation. + +setup() { + TEST_TMP="$(mktemp -d)" + SCRIPT="$BATS_TEST_DIRNAME/../scripts/pkg-inventory.sh" + REPO="$TEST_TMP/repo" + mkdir -p "$REPO/packages" "$TEST_TMP/bin" + + # --- manifests: common + host(box) + aur ------------------------------------ + cat > "$REPO/packages/common.pkgs" <<'EOF' +# common desired state +ghostty +zoxide +vlc # declared, deliberately NOT installed below +EOF + cat > "$REPO/packages/box.pkgs" <<'EOF' +neovim +EOF + cat > "$REPO/packages/aur.pkgs" <<'EOF' +typora # AUR-only desired state +EOF + + # --- pacman log: install day 2026-01-01, hand installs from 2026-03 ---------- + cat > "$TEST_TMP/pacman.log" <<'EOF' +[2026-01-01T10:00:00-0800] [PACMAN] Running 'pacman --sysroot /tmp/calamares-root-x -S base' +[2026-01-01T10:00:01-0800] [ALPM] installed filesystem (2026.01.01-1) +[2026-01-01T10:00:02-0800] [ALPM] installed base (3-1) +[2026-01-01T10:00:03-0800] [ALPM] installed libfoo (1.0-1) +[2026-03-04T09:00:00-0800] [ALPM] installed bun (1.2-1) +[2026-03-04T09:30:00-0800] [ALPM] upgraded filesystem (2026.01.01-1 -> 2026.03.01-1) +[2026-03-05T09:00:00-0800] [ALPM] installed nirimod-git (r7-1) +[2026-03-06T09:00:00-0800] [ALPM] removed base (3-1) +EOF + + # --- installed state -------------------------------------------------------- + # ghostty/zoxide/neovim/typora declared. libfoo: explicit, arrived with the installer. + # bun/nirimod-git: installed by hand afterwards, undeclared. python-hatchling: orphan. + printf '%s\n' base bun filesystem ghostty libfoo neovim nirimod-git typora zoxide > "$TEST_TMP/local-all" + printf '%s\n' bun ghostty libfoo neovim nirimod-git typora zoxide > "$TEST_TMP/local-explicit" + printf '%s\n' python-hatchling > "$TEST_TMP/orphans" + # nirimod-git is deliberately ABSENT here: no sync db can serve it, which is the fact that + # routes it to aur.pkgs in the test below. + printf '%s\n' bun ghostty neovim typora zoxide libfoo vlc python-hatchling base filesystem > "$TEST_TMP/sync-all" + + # Four flag combinations are the script's whole interface to the package manager; the stub + # answers those and refuses anything else, so an accidental new query fails loudly. + cat > "$TEST_TMP/bin/pacman" <<'STUB' +#!/usr/bin/env bash +case "$1" in + -Sl) [ "${STUB_SYNC_EMPTY:-0}" = 1 ] && exit 1 + sed 's/^/syncdb /' "$SYNC_ALL" ;; + -Qqe) cat "$LOCAL_EXPLICIT" ;; + -Qtdq) cat "$ORPHANS"; [ -s "$ORPHANS" ] ;; + -Qq) cat "$LOCAL_ALL" ;; + *) echo "stub pacman: unhandled args: $*" >&2; exit 64 ;; +esac +STUB + cat > "$TEST_TMP/bin/flatpak" <<'STUB' +#!/usr/bin/env bash +[ "${STUB_NO_FLATPAK:-0}" = 1 ] && exit 127 +printf '%s\n' com.discordapp.Discord +STUB + chmod +x "$TEST_TMP/bin/pacman" "$TEST_TMP/bin/flatpak" + + export PATH="$TEST_TMP/bin:$PATH" + export PKGINV_REPO="$REPO" PKGINV_HOST=box PKGINV_LOG="$TEST_TMP/pacman.log" + export LOCAL_ALL="$TEST_TMP/local-all" LOCAL_EXPLICIT="$TEST_TMP/local-explicit" + export ORPHANS="$TEST_TMP/orphans" SYNC_ALL="$TEST_TMP/sync-all" +} + +teardown() { rm -rf "$TEST_TMP"; } + +# section RE_START RE_END — the report from RE_START's line up to (not including) RE_END, so an +# assertion proves a name landed in a specific section instead of merely appearing somewhere in the +# report. Start inclusive (the count and the names sit on the label line), terminator exclusive (a +# plain sed range would leak the next section's first line). Anchoring matters: an unanchored start +# runs the range to end-of-output, silently turning every absence assertion into a vacuous pass — +# exactly how four of these tests were passing before this helper was fixed. +section() { printf '%s\n' "$output" | awk -v s="$1" -v e="$2" '$0 ~ e {on=0} $0 ~ s {on=1} on'; } +ADDITIONS() { section "^undeclared additions" "^installer-provided"; } + +@test "header names the host and takes the baseline from the oldest install line" { + run "$SCRIPT" + [ "$status" -eq 1 ] # the fixture has drift by design + [[ "$output" == *"pkg-inventory host=box"* ]] + [[ "$output" == *"baseline=2026-01-01"* ]] +} + +@test "hand-installed packages are reported as additions" { + run "$SCRIPT" + [[ "$output" == *"undeclared additions (declare these): 2"* ]] + s="$(ADDITIONS)" + [[ "$s" == *bun* ]] + [[ "$s" == *nirimod-git* ]] +} + +@test "a package the installer provided is not reported as an addition" { + run "$SCRIPT" + additions="$(ADDITIONS)" + [[ "$additions" != *libfoo* ]] + [[ "$(section "^installer-provided" "^declared,")" == *libfoo* ]] + # The folded block must end in a newline. Without one, the following label is glued onto the + # last folded line and every section after it vanishes from a sliced report. + [[ "$(section "^installer-provided" "^declared,")" != *declared,* ]] +} + +@test "AUR-only packages are separated from sync-repo packages" { + run "$SCRIPT" + # nirimod-git is missing from the sync-DB fixture, so only a helper can install it: it must be + # routed to aur.pkgs, not to a pacman line that would fail on a fresh box. Bash `*` crosses + # newlines, so the absence check runs on one line rather than on the whole section. + repo_line="$(section '^ repo ' '^ aur ')" + [[ "$repo_line" == *"repo (sync dbs): bun"* ]] + [[ "$repo_line" != *nirimod-git* ]] + [[ "$(section '^ aur ' '^installer-provided')" == *"aur (needs a helper): nirimod-git"* ]] +} + +@test "declared-but-missing packages are listed for install.sh" { + run "$SCRIPT" + [[ "$output" == *"declared, not installed"* ]] + [[ "$(section "^declared," "^orphans")" == *vlc* ]] +} + +@test "orphans are reported as ignored and never emitted" { + run "$SCRIPT" + [[ "$output" == *orphans*python-hatchling* ]] + run "$SCRIPT" --emit + [[ "$output" != *python-hatchling* ]] +} + +@test "--baseline moves packages installed on that date into the installer set" { + # Inclusive date semantics: pushing the baseline to bun's install date makes bun installer + # furniture, while nirimod-git (the next day) is still a hand install. + run env PKGINV_BASELINE=2026-03-04 "$SCRIPT" + [[ "$output" == *"baseline=2026-03-04"* ]] + s="$(ADDITIONS)" + [[ "$s" != *bun* ]] + [[ "$s" == *nirimod-git* ]] +} + +@test "--emit prints lines that survive the manifest parser" { + run "$SCRIPT" --emit + [ "$status" -eq 0 ] + # install.sh reads manifests as `sed 's/#.*//'` then the first field, so an emitted name must + # be a bare token: a stray path or trailing note would be handed to pacman as a package name. + bad="$(printf '%s\n' "$output" | grep -v '^#' | grep -vE '^[A-Za-z0-9@._+-]+$' || true)" + [ -z "$bad" ] + printf '%s\n' "$output" > "$REPO/packages/emitted.pkgs" + names="$(sed 's/#.*//' "$REPO/packages/emitted.pkgs" | awk '{if ($1) print $1}' | sort | tr '\n' ' ')" + [[ "$names" == *bun* ]] + [[ "$names" == *nirimod-git* ]] + [[ "$names" != *ghostty* ]] + [[ "$names" != *libfoo* ]] + [[ "$names" != *vlc* ]] +} + +@test "an unknown host gets the common manifest only, so host packages look undeclared" { + # Mirrors install.sh: wrong-host runs are a no-op, not a hazard. Here that means neovim, + # declared only in box.pkgs, counts as an addition when the host is not box. + run env PKGINV_HOST=otherbox "$SCRIPT" + [[ "$(ADDITIONS)" == *neovim* ]] +} + +@test "no drift exits 0" { + printf '%s\n' ghostty libfoo zoxide > "$TEST_TMP/local-explicit" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"undeclared additions (declare these): 0"* ]] +} + +@test "missing sync databases fail loudly rather than misclassifying everything as AUR" { + run env STUB_SYNC_EMPTY=1 "$SCRIPT" + [ "$status" -eq 3 ] + [[ "$output" == *"sync"* ]] +} + +@test "flatpak apps are reported separately from pacman packages" { + run "$SCRIPT" + [[ "$output" == *"flatpak"*com.discordapp.Discord* ]] +} + +@test "a box without flatpak still reports the pacman inventory" { + run env STUB_NO_FLATPAK=1 "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"flatpak"* ]] + [[ "$(ADDITIONS)" == *bun* ]] +} + +@test "the baseline is found in a rotated log when the install day has aged out of the live one" { + # pacman rotates its log; without reading the rotated file the whole base system would be + # reported as hand-installed additions on any machine older than the rotation window. + mv "$TEST_TMP/pacman.log" "$TEST_TMP/pacman.log.1" + printf '[2026-10-01T09:00:00-0700] [ALPM] upgraded zoxide (0.9-1 -> 0.10-1)\n' > "$TEST_TMP/pacman.log" + run env PKGINV_LOG="$TEST_TMP/pacman.log $TEST_TMP/pacman.log.1" "$SCRIPT" + [[ "$output" == *"baseline=2026-01-01"* ]] + [[ "$(ADDITIONS)" != *libfoo* ]] +} + +@test "a log larger than the pipe buffer still yields the baseline" { + # A real install day writes thousands of install lines. `sort | head -1` under pipefail dies of + # SIGPIPE once that output exceeds the pipe buffer, and no small fixture can show it — the first + # run against a real log is where it surfaced. seq, not yes: yes would SIGPIPE in the fixture. + seq 1 20000 | awk '{print "[2025-05-05T10:00:00-0700] [ALPM] installed pkgfiller (1-1)"}' > "$TEST_TMP/big.log" + cat "$TEST_TMP/big.log" "$TEST_TMP/pacman.log" > "$TEST_TMP/combined.log" + run env PKGINV_LOG="$TEST_TMP/combined.log" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"baseline=2025-05-05"* ]] +} + +@test "usage is a distinct exit status from drift" { + run "$SCRIPT" --nope + [ "$status" -eq 2 ] +} -- 2.54.0 From b4fc8811649ba201fc28997c52e063d1a2e5fb07 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 20 Oct 2026 18:08:23 -0700 Subject: [PATCH 2/2] Declare the 24 hand-installed blankpad packages the manifest was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point of the new inventory tool is this commit: packages/blankpad.pkgs declared one package while the machine had 24 more that no rebuild would reproduce. Declaring them changes nothing on the running box (install.sh installs only what is missing — its delta is now empty) and makes a fresh install faithful to this one. nirimod-git is left undeclared on purpose: it is AUR-only and aur.pkgs is fleet-wide, so declaring it there would make the headless servers build a niri module. Refs #51 --- packages/blankpad.pkgs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/blankpad.pkgs b/packages/blankpad.pkgs index 50e0efe..c008f4b 100644 --- a/packages/blankpad.pkgs +++ b/packages/blankpad.pkgs @@ -3,3 +3,35 @@ # pkgbase amd-ucode (split pkgname amd-ucode). No module options needed. amd-ucode + +# Hand-installed additions, measured by scripts/pkg-inventory.sh on 2026-10-20 against the +# Calamares install day (baseline 2026-09-07). These stay out of common.pkgs: none of them belong +# on the headless servers. install.sh installs them only where they are missing, so this list +# changes nothing on the running box — it is what makes the next rebuild faithful. +bun +cachyos-gaming-applications +curlftpfs +flatpak +fuse2 +fuseiso +ghostty-nautilus +glow +gnome-calculator +gnome-disk-utility +nodejs +npm +obsidian +partitionmanager +plezy +proton-mail-bin +python-yaml +quickshell +shelly-flatpak-backend +sshfs +tmux +ttf-proggyclean-nerd +ttf-terminus-nerd +wine-cachyos + +# nirimod-git is an AUR addition deliberately left undeclared: aur.pkgs is read by every host, and +# a niri module must not be built on the servers. See the known gap in omp-docs/tools/packages.md. -- 2.54.0