N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:
# lines starting with > come from the upstream candidate: a differing
value, or a field the installed record lacks
After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").
Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
than apt-cache show PKG=<installed>, which gives the archive record
when that version is still listed) vs apt-cache show --no-all-versions;
Description-xx pairs with Description and a one-line candidate
Description equal to the installed first line counts as identical
(the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
architectures first and nothing for an up-to-date package); not
installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.
Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
19 KiB
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-runprints 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=<name>):
| 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 <enter> 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
reasonisuserfor a package the user confirmed withN+, ordep:<root>for a package pulled in as part of the installed dependency closure of<root>at the time of confirmation.backendis 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,mvover. - Writes happen under a lock (
trusted.lock:flock(1)when available, else an atomicmkdirlock: 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 atrustrun 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:<root> 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
<enter> 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: <one-line description>, reason: <r> with <r> = 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: <a, b, ...> - the reverse dependencies, installed packages only - or required by: nothing |
N?? |
print the full native info: the installed record merged with the upstream candidate (see "Full info" below); zypper: zypper info --requires as it is |
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.
Full info (N??, show PKG). The first line is # lines starting with > come from the upstream candidate: a differing value, or a field the installed record lacks; then the installed record in
its native key/value layout, as the base. After each field whose candidate value differs, the
candidate's line follows, prefixed with > (same key formatting); fields only the candidate has
are appended at the end the same way; fields only the installed record has stay; identical fields
print once. A field starts on a line that begins with a non-blank character and holds a : (the
key is the text before it); lines beginning with a blank continue it, so multi-line values (pacman
Optional Deps, dnf Description, RFC-822 continuation lines) are compared as blocks, every line
trimmed, and a differing candidate block gets > on every line. Description-xx (apt Translation
files) pairs with Description; on apt a one-line candidate Description equal to the first
installed line counts as identical (the archives carry only the short description, the long text
is in the Translation files). When both records align their : in one column but in different
ones (dnf5: 16 installed, 15 available), the candidate lines are re-padded to the installed one's.
| backend | installed record | upstream candidate |
|---|---|---|
| pacman | pacman -Qi PKG |
pacman -Si REPO/PKG, REPO = the first repository pacman -Sl lists PKG in (as for the review list) |
| apt | dpkg-query -s PKG (Status, Conffiles, always the long description; apt-cache show PKG=<installed> gives the archive's record when that version is still listed: no Status/Conffiles, short description only); a Status of not-installed/config-files = not installed |
apt-cache show --no-all-versions PKG |
| dnf | dnf -q info --installed PKG |
dnf -q info --upgrades PKG (installed architecture only; --available also lists other architectures and, for an up-to-date package, other builds); not installed: --available |
| zypper | - | - (rpm -qi and zypper info share almost no keys - Version vs version-release, Arch, byte sizes, source names, description layout - and zypper cannot print the installed record by itself: zypper info -r @System is refused) |
A package that is not installed (a row whose old version is - is not asked) shows the candidate
alone under # not installed: this is the upstream candidate. Anything else - no candidate (an
up-to-date dnf package, a local-only pacman package), not exactly one record on each side (two
installed architectures), zypper - prints <be>_full_info unchanged. dnf info headings
("Installed Packages", ...) are dropped. Verified formats: discovery/{pacman,apt,rpm}.md.
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 <enter>:
- Build the exclusion set =
N-rows. - Apply native per-run exclusion (
pacman --ignore a,b,dnf --exclude, apt temporaryapt-mark holdof 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; zypperaddlockin a private configuration whose lock file is a copy of the effective one, so the real lock set is never written). - 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 <throwaway>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*.dband*.db.sigfiles 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.sigright before its.db;db.lckis 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 withpacman -Su(or run the tool again) before anypacman -S. With--no-refresh(or a failed refresh) plainpacman -Suapplies the existing databases. Throwaway databases carry their owner's pid in the name (/tmp/skeptical-updater-db.<pid>.XXXXXX); those of runs that are gone are removed at the start of the next root run. - 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.
<be>_refresh # update metadata; non-fatal on failure (warn)
<be>_upgradable # stdout: name<TAB>old<TAB>new<TAB>repo<TAB>flags (flags: "" | "held"; old "-" = new package)
# plus "#<note>" lines for pending work that is not a row
<be>_describe PKG # stdout: one-line summary
<be>_full_info PKG # stdout: native full dump (candidate version if available)
<be>_installed_record PKG # optional (pacman, apt, dnf): stdout: ONE native record of the installed package
<be>_candidate_record PKG installed|not-installed # optional, with the above: ONE native record of the candidate
<be>_rdepends PKG # stdout: installed packages requiring PKG, one per line
<be>_reason PKG # stdout: explicit | dependency | unknown | not-installed (how PKG got installed)
<be>_closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line
<be>_purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes
# (simulation, no cascade); non-zero = refused
<be>_purge PKG... # non-interactive native purge/remove of exactly the planned packages
<be>_upgrade EXCLUDED... # non-interactive native upgrade skipping EXCLUDED (handles hold/lock save+restore)
<be>_label # "apt" / "pman" / ...
Mutating calls go through run_cmd which honours --dry-run (prints + cmd to
stdout instead of executing).
<be>_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