N? and `info PKG` now print three lines, none indented:
PKG: <description>
reason: explicitly installed | installed as a dependency | unknown | not installed
required by: <installed dependents, comma separated> | nothing
The reason comes from a new <be>_reason backend function: apt-mark
showauto / showmanual (dpkg decides for a package in neither list), the
Install Reason of pacman -Qi, dnf repoquery --installed %{reason} (dnf4
and dnf5 words) and the Installed line of zypper info (rpm plus libzypp's
AutoInstalled when zypper is locked). A row whose old version is "-" is
not installed and the backend is not asked. The "(installed)" qualifier
moves to --help, README and DESIGN.
The rpm E2E fixture and e2e_reset now also mark su-dummy-a and su-dummy-c
as user-installed on dnf, which records plain rpm installs as "unknown",
so 1? and 2? show both reasons on all 12 distros. The verified commands
are recorded in discovery/{apt,pacman,rpm}.md.
237 lines
15 KiB
Markdown
237 lines
15 KiB
Markdown
# 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=<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
|
|
```
|
|
|
|
- `reason` is `user` for a package the user confirmed with `N+`, or `dep:<root>`
|
|
for a package pulled in as part of the installed dependency closure of `<root>`
|
|
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:<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 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).
|
|
|
|
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>`:
|
|
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 <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 `*.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
|
|
(<repos>)") 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.<pid>.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.
|
|
|
|
```
|
|
<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>_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
|
|
```
|