Compare commits

...
Author SHA1 Message Date
Frank b4fc881164 Declare the 24 hand-installed blankpad packages the manifest was missing
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
2026-10-20 18:08:23 -07:00
Frank 6df45d709a Add pkg-inventory.sh: report what a machine has outgrown its manifests
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
2026-10-20 18:07:42 -07:00
dank 5ab0a49ea4 Merge pull request 'Correct the lock-watcher claims: session restart was the trigger; suspend was covered by noctalia' (#49) from fix/47-attribution-and-inhibitor-correction into main 2026-10-20 17:55:08 -07:00
Frank 4666d0dce0 Correct the lock-watcher law: the trigger is the session restart, and suspend was covered
Two statements in the law added by #48 need measurement rather than inference, and one of them was
wrong.

**Attributed.** `niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026` (`ps -o lstart=`);
swayidle's last state change was `16:22:23`. The compositor session restarted and the watcher died
with it — eight seconds of agreement, which is what `PartOf=graphical-session.target` is for. The
fix that #48 justified by reasoning is now justified by the event itself. The user journal holds no
entries for that window, so the evidence is process ages, not logs.

**Corrected.** #48 said closing the lid suspended blankpad unlocked, because `before-sleep` lived in
the dead unit. `systemd-inhibit --list` says otherwise: `noctalia … sleep "Lock before sleep" delay`
— the shell holds its own sleep inhibitor and locks on suspend independently of swayidle, so the
suspend path held all along. What was dead for eight days is the **idle** lock and `after-resume`.
The method error is worth keeping: `pgrep` for lockers came back empty and I read that as "no lock
path", when a locker can be a hook inside a running shell. The inhibitor table is the instrument.

Added with them, because it answers a question people will ask: while an `omp` session runs, swayidle
logs `Not enabling timeouts: idle inhibitor found` and arms no idle timeout (`omp … idle "omp agent
session" block`). A box with an agent working does not lock on inactivity, by harness design.

Docs only; the unit file from #48 is unchanged and the live watcher is `active` with `NRestarts=1`
after the SIGTERM proof. `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0 issues, `bats tests/`
71/71. Refs #47.
2026-10-20 17:52:59 -07:00
dank d013795922 Merge pull request 'blankpad has had no screen lock for 8 days; the lid suspends it unlocked' (#48) from fix/47-lock-watcher-resilience into main 2026-10-20 17:25:11 -07:00
Frank 2b7b0bc3cd Make the idle-lock watcher survive a clean exit, and outlive a session restart
`swayidle-lock.service` was `enabled`, in `default.target.wants/`, and **dead for eight days**:

    Active: inactive (dead) since Mon 2026-10-12 16:22:23 PDT; 1 week 1 day ago
    Duration: 2d 21h 57min   Main PID: 3181 (code=exited, status=0/SUCCESS)
    Result=success  NRestarts=0

`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. So the
watcher that owns both the 5-minute idle lock and the `before-sleep` lock stopped existing, and
nothing on the box noticed. The consequence is not cosmetic: with `HandleLidSwitch=suspend` and
`HandleLidSwitchExternalPower=""` (which falls back to it), **closing the lid suspended the
machine unlocked**, because the hook that locks on sleep lived inside the unit that had died. No
alternative was running: `pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock'` = 0, and
neither `autostart.kdl` launches a locker.

Three settings, each with a job:

- `Restart=always` — a supervisor must not assume it can only die badly.
- `StartLimitIntervalSec=0` — with `always`, a fast loop must never wedge the unit into `failed`;
  a lock that keeps trying beats a lock that stopped trying.
- `PartOf=graphical-session.target` — it is wanted by `default.target`, so a session restart
  brought the target back without it; now the watcher is restarted with the session.

What triggered the 16:22 exit is **unattributed**: the user journal has no entries in that window,
so all that can be said is that swayidle exited with status 0 — precisely what the old policy was
blind to. The fix does not depend on knowing the trigger, which is the point.

Verification is behavioural, per the law recorded with it: start the unit, `kill -TERM` the main
PID, and watch `NRestarts` increment with a new `MainPID` — the October event, survived. Not
performed here: starting it arms a 5-minute idle lock on a machine that has gone a week without
one, and that is Frank's call. `systemd-analyze --user verify` clean, `bats tests/` 71/71,
`kb-lint.sh omp-docs` 0, `kb-coverage.py` 0.

Refs #47
2026-10-20 17:24:32 -07:00
dank 3809e1ed22 Merge pull request 'deploy.sh declared this repo's own heartbeat unit foreign, on every box' (#46) from fix/44-unit-specifier-resolution into main 2026-10-20 17:22:52 -07:00
7 changed files with 580 additions and 1 deletions
@@ -2,6 +2,13 @@
Description=swayidle idle lock (locks via noctalia-shell IPC, suspends on lid)
Documentation=https://github.com/swaywm/swayidle
After=graphical-session.target
# Restart together with the session, not merely at boot: this unit is wanted by
# default.target, so a compositor restart used to leave it orphaned while the target came
# back without it. Refs #47.
PartOf=graphical-session.target
# With Restart=always below, a fast failure loop must never wedge the watcher into `failed`:
# a lock that keeps trying beats a lock that stopped trying. Refs #47.
StartLimitIntervalSec=0
[Service]
# Locks via the noctalia v5 shell; `noctalia msg` resolves the running instance
@@ -11,7 +18,10 @@ ExecStart=/usr/bin/swayidle -w \
timeout 300 '/usr/bin/noctalia msg session lock' \
before-sleep '/usr/bin/noctalia msg session lock' \
after-resume '/usr/bin/noctalia msg session lock'
Restart=on-failure
# `swayidle -w` exits 0 on SIGTERM, and that is how this watcher died on 2026-10-12: a clean
# exit that `Restart=on-failure` cannot see, leaving the box unlocked for a week. A supervisor
# restarts always. Refs #47.
Restart=always
RestartSec=5s
[Install]
+48
View File
@@ -205,3 +205,51 @@ it in the other. Re-dump on the box whose settings are canonical (`dconf dump /
dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's
*Never track: app state* class applies inside a tracked file, not just to whole directories.
Refs #44.
## Law: a watcher must restart on a clean exit too
`swayidle-lock.service` was `enabled`, present in `default.target.wants/`, and **dead for eight
days**: `Result=success`, `NRestarts=0`, gone since 2026-10-12 16:22 after 2d 21h of uptime.
`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. The trigger is
attributable to eight seconds: **`niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026`**
(`ps -o lstart=`) while swayidle's last state change was `16:22:23` — the compositor session
restarted, the watcher went with it, and because it was wanted by `default.target` rather than tied
to the session target, nothing brought it back. The user journal keeps no entries for that window,
so the attribution rests on process ages rather than logs, which is its own lesson: diagnose a
lapse within the retention you actually have.
**The severity claim in this page's first draft was wrong, and is corrected here.** It said closing
the lid suspended the machine unlocked, because the `before-sleep` hook lived in the dead unit. But
`systemd-inhibit --list` shows `noctalia … sleep "Lock before sleep" delay` — the shell holds its
own sleep inhibitor and locks on suspend independently of swayidle, so the suspend path was covered
the whole time. What was actually dead for eight days is the **idle** lock: five minutes of
inactivity locked nothing, and `after-resume` never fired. `pgrep -c -u blankpad
'swayidle|swaylock|hyprlock|gtklock'` = 0 remains true; it simply did not mean *no lock path at
all*, because a locker can be a hook inside a running shell rather than a name on the process list.
Read the inhibitor table, not only `pgrep`.
Three settings, each with a job: `Restart=always`, because a supervisor must not assume it can
only die badly; `StartLimitIntervalSec=0`, because a fast loop must never wedge it into `failed` —
a lock that keeps trying beats one that stopped trying; and `PartOf=graphical-session.target`,
because the unit is wanted by `default.target`, so the session coming back did not bring *it*
back.
Verify by behaviour rather than by reading the file — the point is surviving the event that killed
it in October:
```bash
systemctl --user start swayidle-lock.service
kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)"
sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState
```
An idle inhibitor also disarms the timeout outright, which surprises people: while an `omp` agent
session is running, swayidle logs `Not enabling timeouts: idle inhibitor found` and arms no idle
timeout at all — `systemd-inhibit --list` shows `omp … idle "omp agent session" block`. A box with
an agent working therefore does not lock on inactivity, by harness design, while `before-sleep` and
`after-resume` keep working. Anyone asking "why did the screen never lock" should read the inhibitor
table before suspecting the unit.
The general shape: `deploy.sh` only ever *enables*. Nothing on any box re-checks that a unit the
system depends on is still running, so a security posture can lapse silently and stay lapsed for a
week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.
+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 ]
}