pkg-inventory.sh: detect a machine that has outgrown packages/*.pkgs, and declare blankpad's 24 missing ones #52

Merged
dank merged 2 commits from feat/51-pkg-inventory into main 2026-10-20 18:20:07 -07:00
5 changed files with 521 additions and 0 deletions
+1
View File
@@ -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
+106
View File
@@ -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/<host>.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/<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
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 (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.
+32
View File
@@ -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.
+159
View File
@@ -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): <names> → belongs in packages/<host>.pkgs
# aur (needs a helper): <names> → 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: <names> pacman -Qtdq — never emitted
# flatpak: <app ids> 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:-<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
+223
View File
@@ -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 ]
}