# skeptical-updater — design A distro-agnostic, single-file bash applet for a *manual, conscious* update cycle. It wraps the native package manager (apt, dnf/yum, pacman/pman, zypper), lists upgradable packages, lets the user interrogate each one, and only then runs the native upgrade. Packages the user has explicitly acknowledged are remembered in a statefile and never questioned again. ## Goals - One file, no runtime dependencies beyond bash >= 4.3 and the distro's package manager. - Never reimplement package management: every action shells out to the native tool. - Deterministic, testable: `--dry-run` prints the commands it would run; stdin is the command channel (works piped, no TTY needed). - Statefile is plain text, hand-editable, atomic writes. ## Backend detection Order (first match wins, overridable with `--backend=`): | backend | detected by | notes | |----------|-----------------------------|--------------------------------------------| | `pacman` | `pacman` in PATH | `pman` present => label shown as `pman`, same commands; honor pacman `IgnorePkg` (`pacman -Qu` marks them `[ignored]`); the refresh syncs a throwaway `--dbpath` copy; at `` its reviewed databases are installed and applied with `pacman -Su` | | `apt` | `apt-get` in PATH | Debian/Ubuntu | | `dnf` | `dnf` in PATH (else `yum`) | dnf4 (EL9) and dnf5 (EL10) differences hidden in the backend | | `zypper` | `zypper` in PATH | `dist-upgrade` on Tumbleweed (ID=opensuse-tumbleweed), `update` on Leap; on Tumbleweed the review list is the `dist-upgrade --dry-run` plan | Each backend implements the same function set (see "Backend contract"). ## Statefile Directory: `/var/lib/skeptical-updater/` (override: `SKEPTICAL_UPDATER_STATE_DIR`). Created `0755 root:root` on first write. The tool runs as root (it must, to upgrade); when not root it re-execs itself through `sudo` if available. File: `trusted.tsv` — tab-separated, UTF-8, LF, one record per line, sorted and unique on `(backend, package)`. Lines starting with `#` are comments and are kept. ``` # skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601) apt nodejs user 2026-10-09T09:40:12Z apt libnode115 dep:nodejs 2026-10-09T09:40:12Z ``` - `reason` is `user` for a package the user confirmed with `N+`, or `dep:` for a package pulled in as part of the installed dependency closure of `` at the time of confirmation. - `backend` is recorded so the file stays correct on hosts with more than one package manager and stays readable when copied between hosts. - Writes are atomic: write to `trusted.tsv.tmp.$$` in the same directory, `mv` over. - Writes happen under a lock (`trusted.lock`: `flock(1)` when available, else an atomic `mkdir` lock: a directory created under a temporary name with the owner's pid inside and renamed into place; a lock whose owner is gone - a process of another user counts as alive - is taken over by renaming it away first and checking its pid again there, so two waiters can never remove a live lock; never busy-waited on; leftovers of crashed acquirers are removed). At save time the file is re-read and this session's additions/removals are replayed onto it, so a `trust` run in another terminal meanwhile is not lost. - Comment lines are kept, in their order, at the top of the file when it is rewritten; malformed lines are kept verbatim (with a warning) in the same block. - A package is "trusted" if a row with the current backend and that name exists. Reason does not matter for filtering. Why a stored closure instead of computing it every run: closure computation across 40+ upgradable packages on every run is slow on apt/rpm; computing it once at `N+` time and storing `dep:` rows keeps every run O(upgradable). A new dependency that appears later is *new to the system* and deserves to be questioned once; `skeptical-updater refresh` recomputes closures for all `user` roots when the user wants to re-trust them wholesale. No other state, except two crash-recovery files that only exist while an upgrade runs (or after one was killed): `holds.added`, the apt hold journal (used under `holds.lock`, which is kept until the holds are really released), and `zypper-private`, the path of the private zypper configuration (used under `zypper.lock`). Both are replayed/removed at the start of the next root run, before anything is listed - only when their lock is free, i.e. not while another run uses them. Per-run exclusions (`N-`) are intentionally not persisted: a skeptical user wants to be asked again next time. Permanent holds belong to the package manager (`apt-mark hold`, `IgnorePkg`, `zypper addlock`, dnf `exclude=`). ## Interactive session ``` $ sudo skeptical-updater Backend: apt (Debian GNU/Linux 12) Refreshing package lists... done. 12 upgradable, 9 trusted (hidden, use --all to show), 3 to review: [1] nodejs 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security [2] libnode108 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security [3] zsh 5.9-4 -> 5.9-4+deb12u1 bookworm upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever > 1? ``` Command grammar (one line, whitespace-separated tokens, each `N` is the number in brackets): | token | action | |--------|---------------------------------------------------------------------------| | empty line | proceed: run the native upgrade with the current exclusions | | `N?` | print three lines, none indented: `PKG: `, `reason: ` with `` = `explicitly installed`, `installed as a dependency`, `unknown` (the backend cannot tell) or `not installed` (also, without asking the backend, for a row whose old version is `-`), and `required by: ` - the reverse dependencies, installed packages only - or `required by: nothing` | | `N??` | print the full native info dump (`apt-cache show` / `pacman -Qi` / `dnf info` / `zypper info --requires`) | | `N-` | exclude from this run (toggle; `N-` again re-includes); shown with `-` marker | | `Nx` | purge it now, following pacman's `-Rns` principle on every backend: if any installed package still depends on it, refuse and print who (the reverse dependencies `N?` shows) - never cascade; otherwise simulate the native purge of it together with its now-unneeded dependencies, show that removal set ("This also removes: ...") and ask `y/N`. As a safety check, a simulated set containing anything that is neither the target nor in its installed dependency closure is refused too. Only a purge that ran removes rows (of every package it removed). A row whose old version is `-` (a new dependency, not installed) cannot be purged | | `N+` | trust it: write it and its installed dependency closure to the statefile; the row leaves the list (with `--all` it stays, marked `=`). Rows that this trust hides are re-included if they were excluded | | `*+` / `*-` | apply `+` / `-` to every listed package; `*-` is an all-or-nothing toggle: when every listed row is already excluded it re-includes them all | | `l` | redraw the list | | `q` | quit without upgrading (exit 0) | | `h`, `?` | help | Numbers are stable for the whole session (removed rows leave a gap, never renumber). All tokens of a line are validated first: an unknown token or a number that is not listed prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts without upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0). Line input. When stdin is a terminal every line is read with bash's readline, `IFS= read -e -r -p PROMPT`, with readline's defaults and nothing more: cursor movement and editing (Left/Right, Home/End, Ctrl-A/E, Backspace/Delete) and Up/Down over an in-memory history of this session's command lines. A line with a non-blank character is added with `history -s` (empty lines, the proceed command, are not); Down past the newest entry restores the line typed before Up (readline's saved line); Up at the oldest entry and Down on the new line ring the bell. The `Nx` `y/N` answer is read the same way and never added. Nothing persists: `HISTFILE` is unset at startup and `history -r`/`-w`/`-a` are never used (a non-interactive bash neither loads nor saves history by itself). readline draws the prompt and the line on stderr, as bash does. Any other stdin keeps the plain `IFS= read -r` with the prompt on stdout and each line echoed after it, so piped runs and their logs are unchanged. Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of `apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`). dnf excludes/versionlocks and zypper locks hide the package from the native listing instead. Notes after the rows report what the backend says beyond them (new dependencies or replacements the upgrade installs, packages a Tumbleweed `dist-upgrade` removes); with no row but such pending work the prompt is still offered ("0 to review, but ... reports pending changes"). After ``: 1. Build the exclusion set = `N-` rows. 2. Apply native per-run exclusion (`pacman --ignore a,b`, `dnf --exclude`, apt temporary `apt-mark hold` of the packages not already held, journalled first and released afterwards - also on failure, Ctrl-C, TERM, HUP, QUIT, a closed stdout - so the hold set is exactly the prior one; zypper `addlock` in a private configuration whose lock file is a copy of the effective one, so the real lock set is never written). 3. Run the native upgrade non-interactively, streaming its output, on exactly the metadata that was reviewed: dnf `--setopt=*.metadata_expire=-1`, zypper `--no-refresh`; apt never refreshes on upgrade. pacman: (a) `pacman -Sup --noconfirm --ignore ... --dbpath ` checks the reviewed transaction - if it fails (an ignored package another upgrade needs, a conflict) its output is shown and the tool exits with its status, nothing changed; (b) the throwaway `*.db` and `*.db.sig` files are installed into the system's sync directory under pacman's own lock (`db.lck`, created atomically; an existing one = another pacman: nothing changed). A system database that changed since the review and is newer than the reviewed copy is never overwritten: the tool refuses ("package databases changed since the review ()") with exit status 2 and changes nothing; identical ones are skipped. The install is all-or-nothing: everything is copied to temporary names first, then all renames happen with the signals ignored, each `.db.sig` right before its `.db`; `db.lck` is removed right before (c) `pacman -Su --noconfirm --ignore ...` applies them, no further sync. If (c) fails or is interrupted the system's sync database is newer than its installed packages: the tool (or its exit handler) says so; finish with `pacman -Su` (or run the tool again) before any `pacman -S`. With `--no-refresh` (or a failed refresh) plain `pacman -Su` applies the existing databases. Throwaway databases carry their owner's pid in the name (`/tmp/skeptical-updater-db..XXXXXX`); those of runs that are gone are removed at the start of the next root run. 4. Restore holds/locks, exit with the package manager's exit code. Trusted packages are upgraded silently along with everything else; they are only hidden from the questioning list. ## CLI ``` skeptical-updater [options] [command] commands: (none) interactive review + upgrade list print the review list and exit (no prompt) trusted print the statefile trust PKG... add PKG and its installed dependency closure to the statefile untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG) refresh recompute dependency closures for every `user` root info PKG same as N? show PKG same as N?? options: --backend=NAME force apt|dnf|pacman|zypper --all also list trusted packages (marked with `=`) --no-refresh skip the metadata refresh step --dry-run print native commands instead of running mutating ones --state-dir=DIR override /var/lib/skeptical-updater --no-color disable ANSI colours (also honours NO_COLOR, non-tty stdout) --version, --help ``` Exit codes: 0 ok (also `q`), 1 usage/backend error, 2 aborted (end of input), otherwise the native package manager's exit code from the upgrade step. When root is needed (refresh, upgrade, purge, writing a state directory the user cannot write) and the user is not root, the tool re-runs itself as `sudo -- /absolute/path/to/itself --sudo-reexec ARGS...` - the file itself, so a sudoers rule for it keeps matching; only a copy that is not executable is run as `sudo -- "$BASH" /absolute/path --sudo-reexec ARGS...`. The hidden `--sudo-reexec` (accepted only as the first argument, never forwarded) makes a re-run that is still not root fail instead of looping. Without sudo it fails with a clear message. ## Backend contract Every backend is a set of bash functions with a common prefix. All output is `LC_ALL=C`. All functions must work without a TTY. ``` _refresh # update metadata; non-fatal on failure (warn) _upgradable # stdout: nameoldnewrepoflags (flags: "" | "held"; old "-" = new package) # plus "#" lines for pending work that is not a row _describe PKG # stdout: one-line summary _full_info PKG # stdout: native full dump (candidate version if available) _rdepends PKG # stdout: installed packages requiring PKG, one per line _reason PKG # stdout: explicit | dependency | unknown | not-installed (how PKG got installed) _closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line _purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes # (simulation, no cascade); non-zero = refused _purge PKG... # non-interactive native purge/remove of exactly the planned packages _upgrade EXCLUDED... # non-interactive native upgrade skipping EXCLUDED (handles hold/lock save+restore) _label # "apt" / "pman" / ... ``` Mutating calls go through `run_cmd` which honours `--dry-run` (prints `+ cmd` to stdout instead of executing). `_reason` sources (read-only, unprivileged, verified in `discovery/*.md`): apt `apt-mark showauto PKG` (dependency) / `apt-mark showmanual PKG` (explicit), a package in neither list is explicit when dpkg has it installed; pacman the `Install Reason` of `pacman -Qi`; dnf `repoquery --installed --queryformat '%{reason}'` (`user`/`group`/dnf5 `External User` = explicit, `dependency`/`weak-dependency` = dependency, dnf4 `unknown` - plain `rpm -i` - stays unknown, no output = not installed); zypper the `Installed` line of `zypper --no-refresh info` (`Yes (automatically)` / `Yes` / `No`), and when zypper gives no answer (its lock is busy) `rpm -q` plus libzypp's `/var/lib/zypp/AutoInstalled`. ## Layout ``` skeptical-updater the applet (single bash file) makefile install / lint / test / docker targets (no LLM needed) docs/DESIGN.md this file discovery/ verified per-family command notes and fixture scripts tests/unit/*.bats bats-core tests with a fake backend (no root, no docker) tests/e2e/*.bats bats-core tests run inside each distro container against real package managers tests/docker/ images list, snapshot/cleanup scripts, per-distro Dockerfiles ```