# skeptical-updater 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. It is one file with no runtime dependencies beyond bash >= 4.3 and the distro's package manager, and it never reimplements package management: every action shells out to the native tool. The specification is [docs/DESIGN.md](docs/DESIGN.md); the per-family command research it is built on lives in [discovery/](discovery/). ## Install ```sh sudo make install # /usr/local/bin/skeptical-updater sudo make install PREFIX=/usr # /usr/bin/skeptical-updater make install DESTDIR=/tmp/pkg PREFIX=/usr # staged install, e.g. for packaging sudo make uninstall ``` Or just copy the `skeptical-updater` file somewhere in `PATH` and make it executable. The statefile directory (`/var/lib/skeptical-updater/`) is created by the applet on first use, not by `make install`; `make uninstall` leaves it in place. Runtime needs: bash >= 4.3, the package manager, coreutils, sed and grep (plus awk, which the apt and pacman backends use and which those distros always ship). `sudo` is optional: when the tool needs root and you are not root, it re-runs itself through `sudo` with the same arguments, or fails with a clear message. ## Usage ``` 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 description, install reason and installed dependents (as N?) show PKG full native package information (as N??) options: --backend=NAME force apt|dnf|pacman|zypper (default: detect pacman > apt > dnf/yum > 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 (env: SKEPTICAL_UPDATER_STATE_DIR) --no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout) -h, --help this help -V, --version print the version ``` Exit status: `0` ok, `1` usage or backend error, `2` aborted (end of input), otherwise the exit status of the package manager's upgrade step. What needs root: the metadata refresh, the upgrade, a purge, and writing a state directory you cannot write. `--dry-run`, `list --no-refresh`, `trusted`, `info` and `show` work unprivileged (as long as the state directory is readable). ### Example session A Debian 12 box with three pending upgrades; `su-dummy-c` is on hold (`apt-mark hold`) and `su-dummy-a` depends on `su-dummy-b`: ``` $ sudo skeptical-updater Backend: apt (Debian GNU/Linux 12 (bookworm)) Refreshing package lists... done. 3 upgradable, 0 trusted (hidden, use --all to show), 3 to review: [1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo [3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held] upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever > 2? su-dummy-b: dummy package su-dummy-b for skeptical-updater tests reason: installed as a dependency required by: su-dummy-a > 1+ trusted su-dummy-a (+1 dependency: su-dummy-b) > 3- excluded from this run: su-dummy-c > l 3 upgradable, 2 trusted (hidden, use --all to show), 1 to review: -[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held] > Excluded from this run: su-dummy-c + apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade ... The following packages have been kept back: su-dummy-c The following packages will be upgraded: su-dummy-a su-dummy-b ... Upgrade finished. $ skeptical-updater trusted # skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601) apt su-dummy-a user 2026-10-09T19:24:13Z apt su-dummy-b dep:su-dummy-a 2026-10-09T19:24:13Z ``` Next time `su-dummy-a` and `su-dummy-b` are upgraded silently; only new packages are questioned. The prompt reads stdin, so the same session can be scripted: `printf '3-\n\n' | sudo skeptical-updater` (an empty line is what triggers the upgrade; end of input without one aborts with exit status 2). `[held]` marks packages the package manager will not upgrade on its own: apt holds and "kept back" packages, pacman `IgnorePkg` (`[ignored]` in `pacman -Qu`). dnf `exclude=`/versionlock and zypper locks hide packages from the native listing, so they do not appear at all. ### Command grammar One line, whitespace-separated tokens; `N` is the number in brackets. Numbers are stable for the whole session (rows that leave the list leave a gap). Every token of a line is validated first: a line with an unknown token or a number that is not listed prints an error, runs nothing, and the prompt comes back. | token | action | |-------|--------| | empty line | proceed: run the native upgrade with the current exclusions | | `N?` | what is it: the one-line description, `reason:` how it got installed (`explicitly installed`, `installed as a dependency`, `unknown` when the package manager cannot tell, `not installed` for a new dependency) and `required by:` the packages that depend on it, or `nothing`. That list covers installed packages only | | `N??` | full native info (`apt-cache show`, `pacman -Qi` + `-Si`, `dnf info`, `zypper info --requires`) | | `N-` | exclude from this run (toggle; `N-` again re-includes); shown with a `-` marker | | `Nx` | purge now, with its now-unneeded dependencies: the purge is simulated first and the prompt lists everything it removes (`Purge su-dummy-a now? This also removes: su-dummy-b [y/N]`); the answer is read from the same stdin; the rows of every removed package leave the list. `x` refuses when other installed packages depend on the target, like `pacman -Rns`; purge the dependents first (`not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first`). Rows with old version `-` (new dependencies, not installed) cannot be purged | | `N+` | trust forever: write it and its installed dependency closure to the statefile; the row leaves the list. Rows this hides are re-included if they were excluded (a trusted package is upgraded) | | `*+` / `*-` | `+` / `-` for every listed package (`*-` is an all-or-nothing toggle: when all are excluded it re-includes them) | | `l` | redraw the list | | `q` | quit without upgrading (exit 0) | | `h`, `?` | help | | end of input | abort without upgrading (exit 2) | Several tokens may share a line (`1? 3- 4+`); they run left to right. With `--all` trusted packages are listed too, marked with `=`. When purges leave nothing to upgrade the session ends by itself (exit 0). Under the rows, `Note:` lines report pending work that is not a row: new dependencies or replacements only the pacman transaction (`-Sup`) shows, packages dnf installs to obsolete others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list says "0 to review, but ... reports pending changes" and `` still runs the upgrade. ### Line editing and history When stdin is a terminal, the prompt is read with bash's readline and its defaults: Left/Right move the cursor, Home/End or Ctrl-A/Ctrl-E jump to the ends, Backspace/Delete edit, and Up/Down walk the lines entered in this session. Down past the newest entry brings back what you had typed before pressing Up; Up at the oldest entry, and Down when already back on the new line, ring the terminal bell. Empty lines and the `y/N` answer of `Nx` are not recorded. The history lives in memory only and is never read from or written to a file (`HISTFILE` is ignored), so every run starts with an empty one. As in bash, readline draws the prompt and the line on stderr and honours your `~/.inputrc`. Piped or redirected stdin keeps the plain line reader: every line is echoed after the prompt, and end of input aborts (exit 2). ### Dry run `--dry-run` prints every mutating command as `+ ` instead of running it: the upgrade, purges, apt holds/unholds, zypper lock files, the installation of the reviewed pacman databases and statefile writes. The metadata refresh still runs when you are root (it is printed when you are not), and read-only queries run normally, so the list is real: ``` $ skeptical-updater --dry-run <<<$'2-\n' ... Excluded from this run: su-dummy-b + apt-mark hold su-dummy-b + apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade + apt-mark unhold su-dummy-b Dry run: nothing was changed. ``` ## Statefile `/var/lib/skeptical-updater/trusted.tsv` (directory override: `--state-dir=DIR` or `SKEPTICAL_UPDATER_STATE_DIR`). The directory is created `0755` on the first write. ``` # 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 ``` - Tab-separated, one record per line, sorted and unique on (backend, package); lines starting with `#` are comments and are preserved (kept at the top). Malformed lines are kept verbatim with a warning. - `reason` is `user` for a package you confirmed (`N+` / `trust`), or `dep:` for a package that was part of the installed dependency closure of `` when you trusted it. A `user` row is never downgraded to `dep:`. - A package is trusted when a row with the current backend and its name exists; the reason does not matter. Rows of other backends are kept untouched. - Writes are atomic: `trusted.tsv.tmp.` in the same directory, renamed over the old file. They run under `trusted.lock` (`flock`, or an atomic `mkdir` lock with stale detection where `flock` is missing): the file is re-read and this session's changes are replayed onto it, so a `trust` in another terminal during a session is kept. - A state directory you cannot enter, or a statefile you cannot read, is an error (never silently "nothing trusted"). - `untrust PKG` removes the rows for `PKG` and every `dep:PKG` row; `refresh` recomputes the closure of every `user` root (adds new dependencies, drops rows that are no longer needed, keeps the dates of the others). The other files are recovery aids that only exist while an upgrade runs (or after one was killed with `kill -9` or a power loss): `holds.added`, the apt hold journal (used under `holds.lock`), and `zypper-private`, the path of the private zypper configuration. At the start of every root run, before anything is listed, a left-over journal is replayed (the holds it recorded are released, a message says which) and a left-over private configuration is removed. ## How exclusions work `N-` exclusions are per run and never persisted: you are asked again next time. Permanent holds belong to the package manager (`apt-mark hold`, `IgnorePkg`, `zypper addlock`, dnf `exclude=`), and skeptical-updater never changes them. | backend | refresh | upgrade | per-run exclusion | purge | |---------|---------|---------|-------------------|-------| | apt | `apt-get update -qq -o APT::Update::Error-Mode=any` | `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade` | temporary `apt-mark hold` of the excluded packages that are *not already held*, journalled in `holds.added` before holding; afterwards exactly those are released (also on failure, Ctrl-C, TERM, HUP, QUIT, a second signal during the restore, or a closed stdout; a journal left by `kill -9` is replayed at the start of the next root run). Holds you placed are never touched. `apt-mark` ignores `DPkg::Lock::Timeout`, so it is retried for up to 60 s while dpkg's lock is busy. | plan: `apt-get -s purge --auto-remove PKG` minus what `apt-get -s autoremove` already listed (`--auto-remove` is system-wide), then `apt-get -y purge` of exactly that set: PKG and the dependencies this purge orphans; no `autoremove` | | pacman / pman | `pacman -Sy --dbpath ` (with `local` linked to the real local database, seeded with the system's `*.db` and `*.db.sig`): listing, `q`, end of input and `--dry-run` never touch `/var/lib/pacman/sync`; errors pacman prints about it are shown | check `pacman -Sup --noconfirm --ignore ... --dbpath ` (on failure: nothing changed, its exit status), install the reviewed `*.db`/`*.db.sig` into `/var/lib/pacman/sync` under `db.lck`, all-or-nothing, never over a newer system database (exit 2), then `pacman -Su --noconfirm` - exactly the reviewed metadata, no second sync; with `--no-refresh` plain `pacman -Su` | `--ignore a,b` | plan: `pacman -Rs --print`; then `pacman -Rns --noconfirm -- ...` | | dnf / yum | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache` | `dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade` (the reviewed metadata; the repo glob is needed because a repo's own `metadata_expire` overrides the main one) | `--exclude=a,b` (adds to `exclude=` from the configuration) | plan: `dnf --assumeno remove PKG`; then `dnf -y remove ...` | | zypper | `zypper --non-interactive --quiet refresh` | `zypper --non-interactive --no-refresh update` (Leap/SLE) or `dist-upgrade` (Tumbleweed/Slowroll, by `ID=` in os-release) | locks in a *private* `ZYPP_CONF`: the effective configuration (main zypp.conf plus the `zypp.conf.d` drop-ins of `/usr/etc` and `/etc`) with `locksfile.path` pointing at a copy of the effective lock file (also when a drop-in sets it), plus `addlock` of the exclusions; deleted afterwards: the real lock file is never written | plan: `zypper --xmlout remove --dry-run --clean-deps PKG`; then `zypper --non-interactive --no-refresh remove --clean-deps ...` | Every package-manager call runs with `LC_ALL=C` (and `DEBIAN_FRONTEND=noninteractive`; `COLUMNS=0` for pacman), with stdin from `/dev/null` so that it can never eat the commands you pipe in. The upgradable list comes from `apt-get -s --with-new-pkgs upgrade` (Inst lines + the "kept back" block), `pacman -Qu` joined with `pacman -Sl`, `dnf -q check-update` (+ `rpm -qa` for the installed versions) and `zypper --xmlout list-updates`, or on Tumbleweed (as root) the real plan `zypper --xmlout --no-refresh dist-upgrade --dry-run`. Reverse dependencies and dependency closures use `apt-cache rdepends` / the dpkg database, `pacman -Qi`, and plain `rpm` queries on dnf and zypper systems (see the discovery reports for why). The install reason `N?` shows comes from `apt-mark showauto` / `showmanual`, the `Install Reason` of `pacman -Qi`, `dnf repoquery --installed --queryformat '%{reason}'` (packages installed with plain `rpm` are `unknown` to dnf4, user-installed to dnf5) and the `Installed` line of `zypper info` (`Yes (automatically)`; libzypp's `/var/lib/zypp/AutoInstalled` when zypper is locked). ## Testing Everything runs in Docker; the host only needs GNU make, bash, Docker and (once, for `make vendor`) curl. Run `make help` for the full list of targets. | target | what it does | |--------|--------------| | `make vendor` | download the pinned bats-core, bats-support and bats-assert into `tests/vendor/` (once; no network afterwards) | | `make lint` | shellcheck the applet, the test scripts, fixtures and fakes (`koalaman/shellcheck` image; `discovery/` is reference material and is not linted) | | `make test-unit` | `tests/unit/*.bats` in a `debian:12` container, against fake package managers | | `make test-e2e` | `tests/e2e/` inside every distro listed in `tests/docker/images.txt`, against the real package managers; logs go to `tests/docker/logs/