Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

1794243


Add stock units for new daemons and a rolling recipe upgrade tool.

Date
Commit17942433a5e4439142023afff593f2fe871aa0b4
GitHubmirror

Parents

Stat

 .gitignore                                |    2 +
 Makefile                                  |    1 +
 PACKAGE-MAINTAINER.md                     |   76 ++
 README.md                                 |    4 +
 lib/setup/services                        |   27 +
 lib/setup/sv/dinit/avahi-daemon           |    5 +
 lib/setup/sv/dinit/conntrackd             |    4 +
 lib/setup/sv/dinit/distccd                |    4 +
 lib/setup/sv/dinit/dnsmasq                |    4 +
 lib/setup/sv/dinit/dropbear               |    4 +
 lib/setup/sv/dinit/fail2ban               |    4 +
 lib/setup/sv/dinit/haproxy                |    4 +
 lib/setup/sv/dinit/libvirtd               |    5 +
 lib/setup/sv/dinit/lighttpd               |    4 +
 lib/setup/sv/dinit/lxc-net                |    4 +
 lib/setup/sv/dinit/mariadbd               |    4 +
 lib/setup/sv/dinit/memcached              |    4 +
 lib/setup/sv/dinit/nfs-server             |    4 +
 lib/setup/sv/dinit/openvpn                |    4 +
 lib/setup/sv/dinit/postgresql             |    4 +
 lib/setup/sv/dinit/tinyproxy              |    4 +
 lib/setup/sv/dinit/tor                    |    4 +
 lib/setup/sv/dinit/unbound                |    4 +
 lib/setup/sv/dinit/usbmuxd                |    4 +
 lib/setup/sv/dinit/valkey                 |    4 +
 lib/setup/sv/openrc/avahi-daemon          |   11 +
 lib/setup/sv/openrc/conntrackd            |   11 +
 lib/setup/sv/openrc/distccd               |   11 +
 lib/setup/sv/openrc/dnsmasq               |   11 +
 lib/setup/sv/openrc/dropbear              |   18 +
 lib/setup/sv/openrc/fail2ban              |   11 +
 lib/setup/sv/openrc/haproxy               |   11 +
 lib/setup/sv/openrc/libvirtd              |   11 +
 lib/setup/sv/openrc/lighttpd              |   11 +
 lib/setup/sv/openrc/lxc-net               |   10 +
 lib/setup/sv/openrc/mariadbd              |   16 +
 lib/setup/sv/openrc/memcached             |   11 +
 lib/setup/sv/openrc/nfs-server            |   17 +
 lib/setup/sv/openrc/openvpn               |   11 +
 lib/setup/sv/openrc/postgresql            |   16 +
 lib/setup/sv/openrc/tinyproxy             |   11 +
 lib/setup/sv/openrc/tor                   |   11 +
 lib/setup/sv/openrc/unbound               |   11 +
 lib/setup/sv/openrc/usbmuxd               |   11 +
 lib/setup/sv/openrc/valkey                |   11 +
 lib/setup/sv/runit/avahi-daemon/run       |    3 +
 lib/setup/sv/runit/conntrackd/run         |    3 +
 lib/setup/sv/runit/distccd/run            |    3 +
 lib/setup/sv/runit/dnsmasq/run            |    3 +
 lib/setup/sv/runit/dropbear/run           |    7 +
 lib/setup/sv/runit/fail2ban/run           |    3 +
 lib/setup/sv/runit/haproxy/run            |    3 +
 lib/setup/sv/runit/libvirtd/run           |    3 +
 lib/setup/sv/runit/lighttpd/run           |    3 +
 lib/setup/sv/runit/lxc-net/run            |    3 +
 lib/setup/sv/runit/mariadbd/run           |    4 +
 lib/setup/sv/runit/memcached/run          |    3 +
 lib/setup/sv/runit/nfs-server/run         |    6 +
 lib/setup/sv/runit/openvpn/run            |    3 +
 lib/setup/sv/runit/postgresql/run         |    4 +
 lib/setup/sv/runit/tinyproxy/run          |    3 +
 lib/setup/sv/runit/tor/run                |    3 +
 lib/setup/sv/runit/unbound/run            |    3 +
 lib/setup/sv/runit/usbmuxd/run            |    3 +
 lib/setup/sv/runit/valkey/run             |    3 +
 lib/setup/sv/s6/avahi-daemon/run          |    3 +
 lib/setup/sv/s6/conntrackd/run            |    3 +
 lib/setup/sv/s6/distccd/run               |    3 +
 lib/setup/sv/s6/dnsmasq/run               |    3 +
 lib/setup/sv/s6/dropbear/run              |    7 +
 lib/setup/sv/s6/fail2ban/run              |    3 +
 lib/setup/sv/s6/haproxy/run               |    3 +
 lib/setup/sv/s6/libvirtd/run              |    3 +
 lib/setup/sv/s6/lighttpd/run              |    3 +
 lib/setup/sv/s6/lxc-net/run               |    3 +
 lib/setup/sv/s6/mariadbd/run              |    4 +
 lib/setup/sv/s6/memcached/run             |    3 +
 lib/setup/sv/s6/nfs-server/run            |    6 +
 lib/setup/sv/s6/openvpn/run               |    3 +
 lib/setup/sv/s6/postgresql/run            |    4 +
 lib/setup/sv/s6/tinyproxy/run             |    3 +
 lib/setup/sv/s6/tor/run                   |    3 +
 lib/setup/sv/s6/unbound/run               |    3 +
 lib/setup/sv/s6/usbmuxd/run               |    3 +
 lib/setup/sv/s6/valkey/run                |    3 +
 lib/setup/sv/shepherd/avahi-daemon.scm    |    9 +
 lib/setup/sv/shepherd/conntrackd.scm      |    9 +
 lib/setup/sv/shepherd/distccd.scm         |    9 +
 lib/setup/sv/shepherd/dnsmasq.scm         |    9 +
 lib/setup/sv/shepherd/dropbear.scm        |    9 +
 lib/setup/sv/shepherd/fail2ban.scm        |    9 +
 lib/setup/sv/shepherd/haproxy.scm         |    9 +
 lib/setup/sv/shepherd/libvirtd.scm        |    9 +
 lib/setup/sv/shepherd/lighttpd.scm        |    9 +
 lib/setup/sv/shepherd/lxc-net.scm         |    9 +
 lib/setup/sv/shepherd/mariadbd.scm        |    9 +
 lib/setup/sv/shepherd/memcached.scm       |    9 +
 lib/setup/sv/shepherd/nfs-server.scm      |    9 +
 lib/setup/sv/shepherd/openvpn.scm         |    9 +
 lib/setup/sv/shepherd/postgresql.scm      |    9 +
 lib/setup/sv/shepherd/tinyproxy.scm       |    9 +
 lib/setup/sv/shepherd/tor.scm             |    9 +
 lib/setup/sv/shepherd/unbound.scm         |    9 +
 lib/setup/sv/shepherd/usbmuxd.scm         |    9 +
 lib/setup/sv/shepherd/valkey.scm          |    9 +
 lib/setup/sv/systemd/avahi-daemon.service |   10 +
 lib/setup/sv/systemd/conntrackd.service   |   10 +
 lib/setup/sv/systemd/distccd.service      |   10 +
 lib/setup/sv/systemd/dnsmasq.service      |   10 +
 lib/setup/sv/systemd/dropbear.service     |   10 +
 lib/setup/sv/systemd/fail2ban.service     |   10 +
 lib/setup/sv/systemd/haproxy.service      |   10 +
 lib/setup/sv/systemd/libvirtd.service     |   10 +
 lib/setup/sv/systemd/lighttpd.service     |   10 +
 lib/setup/sv/systemd/lxc-net.service      |   11 +
 lib/setup/sv/systemd/mariadb.service      |   11 +
 lib/setup/sv/systemd/memcached.service    |   10 +
 lib/setup/sv/systemd/nfs-server.service   |   10 +
 lib/setup/sv/systemd/openvpn.service      |   10 +
 lib/setup/sv/systemd/postgresql.service   |   11 +
 lib/setup/sv/systemd/tinyproxy.service    |   10 +
 lib/setup/sv/systemd/tor.service          |   10 +
 lib/setup/sv/systemd/unbound.service      |   10 +
 lib/setup/sv/systemd/usbmuxd.service      |   10 +
 lib/setup/sv/systemd/valkey.service       |   10 +
 tests/test_services.sh                    |   12 +
 tests/test_stock_services.sh              |   30 +
 tests/test_upgrade_recipes.sh             |   37 +
 tools/upgrade-recipes                     | 1154 +++++++++++++++++++++++++++++
 tools/write-stock-services.py             |  343 +++++++++
 130 files changed, 2531 insertions(+)

Diff

diff --git a/.gitignore b/.gitignore
index 3f084a0..45e4ae9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@
 *.tmp
 .sps-*
 /dist/
+__pycache__/
+*.pyc
diff --git a/Makefile b/Makefile
index 3380561..a52bb83 100644
--- a/Makefile
+++ b/Makefile
@@ -34,6 +34,7 @@ lint:
 		[ -f $$script ] || continue; \
 		$(POSIX_SHELL) -n $$script; \
 	done
+	@python3 -m py_compile tools/upgrade-recipes tools/write-stock-services.py
 	@set -e; for script in lib/mkiso/initrd-init lib/mkiso/live-rc \
 		lib/mkiso/live-plasma lib/mkiso/live-plasma-session \
 		lib/mkiso/live-init lib/mkiso/udhcpc-script; do \
diff --git a/PACKAGE-MAINTAINER.md b/PACKAGE-MAINTAINER.md
index b2907c1..e451def 100644
--- a/PACKAGE-MAINTAINER.md
+++ b/PACKAGE-MAINTAINER.md
@@ -311,3 +311,79 @@ dependencies and runtime linkage.
 relationships. Also inspect modes, ownership, symlink targets, installed paths,
 and the resulting archive manually. After removal, account for intentionally
 retained modified protected files and any state created by lifecycle hooks.
+
+## Init scripts for daemons
+
+Mapped daemons live in `lib/setup/services`. After `pkin`, SPS enables the
+service for the init named in `/etc/sps/init`. Supported inits are `systemd`,
+`openrc`, `s6`, `runit`, `dinit`, and `shepherd`.
+
+A package should ship a systemd unit under `$PKG/usr/lib/systemd/system` and
+an OpenRC script under `$PKG/etc/init.d` (see `seatd`, `openssh`, and the
+database/network daemons in extra). When the chosen init has no file in the
+package, `pkin` copies a stock script from `lib/setup/sv/<init>/` and then
+enables it. Add a stock file for every supported init whenever you introduce a
+new daemon:
+
+```text
+lib/setup/sv/systemd/NAME.service
+lib/setup/sv/openrc/NAME
+lib/setup/sv/runit/NAME/run
+lib/setup/sv/s6/NAME/run
+lib/setup/sv/dinit/NAME
+lib/setup/sv/shepherd/NAME.scm
+```
+
+`tools/write-stock-services.py` regenerates those stock files from its service
+table and installs the systemd plus OpenRC copies next to extra recipes.
+
+## Rolling upgrades (`tools/upgrade-recipes`)
+
+Splux is a rolling distro. Maintainers bump every recipe with a real upstream
+tarball by running one command against the core and extra trees:
+
+```sh
+python3 tools/upgrade-recipes \
+    --trees /usr/src/sps/core:/usr/src/sps/extra
+```
+
+That prints a plan. It does not write files. To apply it:
+
+```sh
+python3 tools/upgrade-recipes \
+    --trees /usr/src/sps/core:/usr/src/sps/extra \
+    --apply
+```
+
+`--apply` downloads each newer tarball, writes a real SHA-256, sets `release`
+to 1, and rewrites `source` URLs. It then unpacks the new source and scans
+Meson, CMake, Autoconf, and pkg-config files for libraries the recipe does not
+declare. Names that already exist in the trees are appended as `depend`. Names
+that do not exist are listed as `missing`. Pass `--scaffold-missing` to draft a
+conservative extra recipe when a source URL can be resolved.
+
+Other useful switches:
+
+```text
+--only gtk4,qemu,llvm     limit the run
+--jobs 12                 parallel upstream probes
+--scan-deps               download and scan on a dry-run
+--no-scan-deps            skip tarball work (version plan only)
+--include-kernel          allow linux and firmware recipes
+--json                    machine-readable plan
+--selftest                offline checks
+```
+
+Qt, KDE Frameworks, Plasma, and GStreamer modules are pinned together so one
+sibling cannot race ahead of the rest. GNOME even-minor libraries (gtk, glib,
+libadwaita, ...) skip odd development series. Tags containing alpha, beta, rc,
+or git are ignored. Kernel and firmware recipes stay pinned unless you pass
+`--include-kernel`.
+
+Set `SPS_RECIPE_TREES` instead of `--trees` if you prefer. Set `GITHUB_TOKEN`
+if GitHub rate-limits the probe. Downloaded tarballs are cached under
+`~/.cache/sps-upgrade` (override with `SPS_UPGRADE_CACHE`).
+
+Run `src update` in the recipe trees after a successful apply, then build the
+packages that moved.
+
diff --git a/README.md b/README.md
index f66b1e6..bdb2751 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,10 @@ SPS is a set of tools rather than one large front end:
   style). Optional; other projects using SPS do not need it.
 - `mkiso` builds an SPS Linux live ISO (systemd by default; busybox init if
   systemd is not on the build host) you can flash and boot into `setup`.
+- `tools/upgrade-recipes` is a maintainer command, not a runtime package tool.
+  It walks core and extra recipes, finds the newest stable upstream, rewrites
+  version/source/hash, and records newly required libraries. See
+  [PACKAGE-MAINTAINER.md](PACKAGE-MAINTAINER.md).
 
 The split matters. `pkin` does not search repositories. `pkdel` does not solve
 dependencies. `src` does not install anything. `sget` ties the lower-level tools
diff --git a/lib/setup/services b/lib/setup/services
index 333d263..019e8c1 100644
--- a/lib/setup/services
+++ b/lib/setup/services
@@ -17,3 +17,30 @@ cupsd	cups.service	multi-user.target	cupsd	default
 NetworkManager	NetworkManager.service	multi-user.target	NetworkManager	default
 pipewire	pipewire.service	default.target	pipewire	default
 wireplumber	wireplumber.service	default.target	wireplumber	default
+fail2ban	fail2ban.service	multi-user.target	fail2ban	default
+unbound	unbound.service	multi-user.target	unbound	default
+dnsmasq	dnsmasq.service	multi-user.target	dnsmasq	default
+openvpn	openvpn.service	multi-user.target	openvpn	default
+tor	tor.service	multi-user.target	tor	default
+dropbear	dropbear.service	multi-user.target	dropbear	default
+tinyproxy	tinyproxy.service	multi-user.target	tinyproxy	default
+haproxy	haproxy.service	multi-user.target	haproxy	default
+lighttpd	lighttpd.service	multi-user.target	lighttpd	default
+avahi	avahi-daemon.service	multi-user.target	avahi-daemon	default
+avahi-daemon	avahi-daemon.service	multi-user.target	avahi-daemon	default
+nfs-utils	nfs-server.service	multi-user.target	nfs-server	default
+nfs-server	nfs-server.service	multi-user.target	nfs-server	default
+libvirt	libvirtd.service	multi-user.target	libvirtd	default
+libvirtd	libvirtd.service	multi-user.target	libvirtd	default
+postgresql	postgresql.service	multi-user.target	postgresql	default
+mariadb	mariadb.service	multi-user.target	mariadbd	default
+mariadbd	mariadb.service	multi-user.target	mariadbd	default
+memcached	memcached.service	multi-user.target	memcached	default
+valkey	valkey.service	multi-user.target	valkey	default
+conntrack-tools	conntrackd.service	multi-user.target	conntrackd	default
+conntrackd	conntrackd.service	multi-user.target	conntrackd	default
+usbmuxd	usbmuxd.service	multi-user.target	usbmuxd	default
+distcc	distccd.service	multi-user.target	distccd	default
+distccd	distccd.service	multi-user.target	distccd	default
+lxc	lxc-net.service	multi-user.target	lxc-net	default
+lxc-net	lxc-net.service	multi-user.target	lxc-net	default
diff --git a/lib/setup/sv/dinit/avahi-daemon b/lib/setup/sv/dinit/avahi-daemon
new file mode 100644
index 0000000..9df7772
--- /dev/null
+++ b/lib/setup/sv/dinit/avahi-daemon
@@ -0,0 +1,5 @@
+type = process
+command = /usr/sbin/avahi-daemon --no-chroot
+restart = true
+smooth-recovery = true
+depends-on = dbus
diff --git a/lib/setup/sv/dinit/conntrackd b/lib/setup/sv/dinit/conntrackd
new file mode 100644
index 0000000..02d9a0f
--- /dev/null
+++ b/lib/setup/sv/dinit/conntrackd
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/conntrackd -C /etc/conntrackd/conntrackd.conf
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/distccd b/lib/setup/sv/dinit/distccd
new file mode 100644
index 0000000..4ca8f86
--- /dev/null
+++ b/lib/setup/sv/dinit/distccd
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/distccd --no-detach --allow 127.0.0.1 --log-stderr
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/dnsmasq b/lib/setup/sv/dinit/dnsmasq
new file mode 100644
index 0000000..aaadb9b
--- /dev/null
+++ b/lib/setup/sv/dinit/dnsmasq
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/dnsmasq -k
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/dropbear b/lib/setup/sv/dinit/dropbear
new file mode 100644
index 0000000..54291af
--- /dev/null
+++ b/lib/setup/sv/dinit/dropbear
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/dropbear -F -E
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/fail2ban b/lib/setup/sv/dinit/fail2ban
new file mode 100644
index 0000000..8757a25
--- /dev/null
+++ b/lib/setup/sv/dinit/fail2ban
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/fail2ban-server -xf --logtarget=stdout
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/haproxy b/lib/setup/sv/dinit/haproxy
new file mode 100644
index 0000000..76ba874
--- /dev/null
+++ b/lib/setup/sv/dinit/haproxy
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -db
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/libvirtd b/lib/setup/sv/dinit/libvirtd
new file mode 100644
index 0000000..36a96af
--- /dev/null
+++ b/lib/setup/sv/dinit/libvirtd
@@ -0,0 +1,5 @@
+type = process
+command = /usr/sbin/libvirtd
+restart = true
+smooth-recovery = true
+depends-on = dbus
diff --git a/lib/setup/sv/dinit/lighttpd b/lib/setup/sv/dinit/lighttpd
new file mode 100644
index 0000000..8908b81
--- /dev/null
+++ b/lib/setup/sv/dinit/lighttpd
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/lighttpd -D -f /etc/lighttpd/lighttpd.conf
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/lxc-net b/lib/setup/sv/dinit/lxc-net
new file mode 100644
index 0000000..828b4bb
--- /dev/null
+++ b/lib/setup/sv/dinit/lxc-net
@@ -0,0 +1,4 @@
+type = process
+command = /usr/libexec/lxc/lxc-net start
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/mariadbd b/lib/setup/sv/dinit/mariadbd
new file mode 100644
index 0000000..baeb6f3
--- /dev/null
+++ b/lib/setup/sv/dinit/mariadbd
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/mariadbd --datadir=/var/lib/mysql
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/memcached b/lib/setup/sv/dinit/memcached
new file mode 100644
index 0000000..5980ecb
--- /dev/null
+++ b/lib/setup/sv/dinit/memcached
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/memcached -u nobody
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/nfs-server b/lib/setup/sv/dinit/nfs-server
new file mode 100644
index 0000000..302f7e0
--- /dev/null
+++ b/lib/setup/sv/dinit/nfs-server
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/rpc.mountd -F
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/openvpn b/lib/setup/sv/dinit/openvpn
new file mode 100644
index 0000000..7f78bbb
--- /dev/null
+++ b/lib/setup/sv/dinit/openvpn
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/openvpn --config /etc/openvpn/server.conf
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/postgresql b/lib/setup/sv/dinit/postgresql
new file mode 100644
index 0000000..21dce21
--- /dev/null
+++ b/lib/setup/sv/dinit/postgresql
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/postgres -D /var/lib/postgres/data
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/tinyproxy b/lib/setup/sv/dinit/tinyproxy
new file mode 100644
index 0000000..b3ab674
--- /dev/null
+++ b/lib/setup/sv/dinit/tinyproxy
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/tinyproxy -d
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/tor b/lib/setup/sv/dinit/tor
new file mode 100644
index 0000000..47f7a85
--- /dev/null
+++ b/lib/setup/sv/dinit/tor
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/tor --RunAsDaemon 0
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/unbound b/lib/setup/sv/dinit/unbound
new file mode 100644
index 0000000..6168a99
--- /dev/null
+++ b/lib/setup/sv/dinit/unbound
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/unbound -d
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/usbmuxd b/lib/setup/sv/dinit/usbmuxd
new file mode 100644
index 0000000..1d0fdd8
--- /dev/null
+++ b/lib/setup/sv/dinit/usbmuxd
@@ -0,0 +1,4 @@
+type = process
+command = /usr/sbin/usbmuxd -f
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/dinit/valkey b/lib/setup/sv/dinit/valkey
new file mode 100644
index 0000000..968cf02
--- /dev/null
+++ b/lib/setup/sv/dinit/valkey
@@ -0,0 +1,4 @@
+type = process
+command = /usr/bin/valkey-server --daemonize no --bind 127.0.0.1
+restart = true
+smooth-recovery = true
diff --git a/lib/setup/sv/openrc/avahi-daemon b/lib/setup/sv/openrc/avahi-daemon
new file mode 100755
index 0000000..5148c09
--- /dev/null
+++ b/lib/setup/sv/openrc/avahi-daemon
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="Avahi mDNS/DNS-SD daemon"
+command="/usr/sbin/avahi-daemon"
+command_args="--no-chroot"
+command_background="yes"
+pidfile="/run/avahi-daemon.pid"
+
+depend() {
+    need dbus
+    after dbus
+}
diff --git a/lib/setup/sv/openrc/conntrackd b/lib/setup/sv/openrc/conntrackd
new file mode 100755
index 0000000..e7f0a93
--- /dev/null
+++ b/lib/setup/sv/openrc/conntrackd
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="connection tracking daemon"
+command="/usr/sbin/conntrackd"
+command_args="-C /etc/conntrackd/conntrackd.conf"
+command_background="yes"
+pidfile="/run/conntrackd.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/distccd b/lib/setup/sv/openrc/distccd
new file mode 100755
index 0000000..b33f847
--- /dev/null
+++ b/lib/setup/sv/openrc/distccd
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="distributed C/C++ compilation daemon"
+command="/usr/bin/distccd"
+command_args="--no-detach --allow 127.0.0.1 --log-stderr"
+command_background="yes"
+pidfile="/run/distccd.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/dnsmasq b/lib/setup/sv/openrc/dnsmasq
new file mode 100755
index 0000000..9ae93ec
--- /dev/null
+++ b/lib/setup/sv/openrc/dnsmasq
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="DNS and DHCP server"
+command="/usr/sbin/dnsmasq"
+command_args="-k"
+command_background="yes"
+pidfile="/run/dnsmasq.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/dropbear b/lib/setup/sv/openrc/dropbear
new file mode 100755
index 0000000..9844850
--- /dev/null
+++ b/lib/setup/sv/openrc/dropbear
@@ -0,0 +1,18 @@
+#!/sbin/openrc-run
+description="Dropbear SSH server"
+command="/usr/sbin/dropbear"
+command_args="-F -E"
+command_background="yes"
+pidfile="/run/dropbear.pid"
+
+depend() {
+    use net
+    after net
+}
+
+start_pre() {
+	if command -v dropbearkey >/dev/null 2>&1; then
+	  mkdir -p /etc/dropbear
+	  [ -f /etc/dropbear/dropbear_rsa_host_key ] || dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key >/dev/null
+	fi
+}
diff --git a/lib/setup/sv/openrc/fail2ban b/lib/setup/sv/openrc/fail2ban
new file mode 100755
index 0000000..96ef6b5
--- /dev/null
+++ b/lib/setup/sv/openrc/fail2ban
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="fail2ban authentication ban daemon"
+command="/usr/bin/fail2ban-server"
+command_args="-xf --logtarget=stdout"
+command_background="yes"
+pidfile="/run/fail2ban.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/haproxy b/lib/setup/sv/openrc/haproxy
new file mode 100755
index 0000000..bb47359
--- /dev/null
+++ b/lib/setup/sv/openrc/haproxy
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="HAProxy load balancer"
+command="/usr/sbin/haproxy"
+command_args="-f /etc/haproxy/haproxy.cfg -db"
+command_background="yes"
+pidfile="/run/haproxy.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/libvirtd b/lib/setup/sv/openrc/libvirtd
new file mode 100755
index 0000000..a362121
--- /dev/null
+++ b/lib/setup/sv/openrc/libvirtd
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="libvirt virtualization daemon"
+command="/usr/sbin/libvirtd"
+command_args=""
+command_background="yes"
+pidfile="/run/libvirtd.pid"
+
+depend() {
+    need dbus
+    after dbus
+}
diff --git a/lib/setup/sv/openrc/lighttpd b/lib/setup/sv/openrc/lighttpd
new file mode 100755
index 0000000..3eeefde
--- /dev/null
+++ b/lib/setup/sv/openrc/lighttpd
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="lighttpd web server"
+command="/usr/sbin/lighttpd"
+command_args="-D -f /etc/lighttpd/lighttpd.conf"
+command_background="yes"
+pidfile="/run/lighttpd.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/lxc-net b/lib/setup/sv/openrc/lxc-net
new file mode 100755
index 0000000..f3688fe
--- /dev/null
+++ b/lib/setup/sv/openrc/lxc-net
@@ -0,0 +1,10 @@
+#!/sbin/openrc-run
+description="LXC bridge networking"
+command="/usr/libexec/lxc/lxc-net"
+command_args="start"
+command_background="no"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/mariadbd b/lib/setup/sv/openrc/mariadbd
new file mode 100755
index 0000000..9947ea1
--- /dev/null
+++ b/lib/setup/sv/openrc/mariadbd
@@ -0,0 +1,16 @@
+#!/sbin/openrc-run
+description="MariaDB database server"
+command="/usr/sbin/mariadbd"
+command_args="--datadir=/var/lib/mysql"
+command_background="yes"
+pidfile="/run/mariadbd.pid"
+command_user="mysql"
+
+depend() {
+    use net
+    after net
+}
+
+start_pre() {
+	install -d -m 0750 /run/mysqld /var/lib/mysql || :
+}
diff --git a/lib/setup/sv/openrc/memcached b/lib/setup/sv/openrc/memcached
new file mode 100755
index 0000000..0854cf7
--- /dev/null
+++ b/lib/setup/sv/openrc/memcached
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="memcached object cache"
+command="/usr/bin/memcached"
+command_args="-u nobody"
+command_background="yes"
+pidfile="/run/memcached.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/nfs-server b/lib/setup/sv/openrc/nfs-server
new file mode 100755
index 0000000..4427f36
--- /dev/null
+++ b/lib/setup/sv/openrc/nfs-server
@@ -0,0 +1,17 @@
+#!/sbin/openrc-run
+description="NFS server"
+command="/usr/sbin/rpc.mountd"
+command_args="-F"
+command_background="yes"
+pidfile="/run/nfs-server.pid"
+
+depend() {
+    use net
+    after net
+}
+
+start_pre() {
+	if command -v exportfs >/dev/null 2>&1; then exportfs -ra || :; fi
+	if command -v rpc.statd >/dev/null 2>&1; then rpc.statd --no-notify || :; fi
+	if command -v rpc.nfsd >/dev/null 2>&1; then rpc.nfsd 8 || :; fi
+}
diff --git a/lib/setup/sv/openrc/openvpn b/lib/setup/sv/openrc/openvpn
new file mode 100755
index 0000000..f9f0267
--- /dev/null
+++ b/lib/setup/sv/openrc/openvpn
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="OpenVPN server"
+command="/usr/sbin/openvpn"
+command_args="--config /etc/openvpn/server.conf"
+command_background="yes"
+pidfile="/run/openvpn.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/postgresql b/lib/setup/sv/openrc/postgresql
new file mode 100755
index 0000000..cfa8a33
--- /dev/null
+++ b/lib/setup/sv/openrc/postgresql
@@ -0,0 +1,16 @@
+#!/sbin/openrc-run
+description="PostgreSQL database server"
+command="/usr/bin/postgres"
+command_args="-D /var/lib/postgres/data"
+command_background="yes"
+pidfile="/run/postgresql.pid"
+command_user="postgres"
+
+depend() {
+    use net
+    after net
+}
+
+start_pre() {
+	install -d -m 0755 /run/postgresql /var/lib/postgres/data || :
+}
diff --git a/lib/setup/sv/openrc/tinyproxy b/lib/setup/sv/openrc/tinyproxy
new file mode 100755
index 0000000..ac4a18f
--- /dev/null
+++ b/lib/setup/sv/openrc/tinyproxy
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="Tinyproxy HTTP proxy"
+command="/usr/bin/tinyproxy"
+command_args="-d"
+command_background="yes"
+pidfile="/run/tinyproxy.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/tor b/lib/setup/sv/openrc/tor
new file mode 100755
index 0000000..7e4f3af
--- /dev/null
+++ b/lib/setup/sv/openrc/tor
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="Tor anonymity daemon"
+command="/usr/bin/tor"
+command_args="--RunAsDaemon 0"
+command_background="yes"
+pidfile="/run/tor.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/unbound b/lib/setup/sv/openrc/unbound
new file mode 100755
index 0000000..da3cd2d
--- /dev/null
+++ b/lib/setup/sv/openrc/unbound
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="validating recursive DNS resolver"
+command="/usr/sbin/unbound"
+command_args="-d"
+command_background="yes"
+pidfile="/run/unbound.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/usbmuxd b/lib/setup/sv/openrc/usbmuxd
new file mode 100755
index 0000000..3837993
--- /dev/null
+++ b/lib/setup/sv/openrc/usbmuxd
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="iOS USB multiplexing daemon"
+command="/usr/sbin/usbmuxd"
+command_args="-f"
+command_background="yes"
+pidfile="/run/usbmuxd.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/openrc/valkey b/lib/setup/sv/openrc/valkey
new file mode 100755
index 0000000..2b11464
--- /dev/null
+++ b/lib/setup/sv/openrc/valkey
@@ -0,0 +1,11 @@
+#!/sbin/openrc-run
+description="Valkey data store"
+command="/usr/bin/valkey-server"
+command_args="--daemonize no --bind 127.0.0.1"
+command_background="yes"
+pidfile="/run/valkey.pid"
+
+depend() {
+    use net
+    after net
+}
diff --git a/lib/setup/sv/runit/avahi-daemon/run b/lib/setup/sv/runit/avahi-daemon/run
new file mode 100755
index 0000000..2d0d88e
--- /dev/null
+++ b/lib/setup/sv/runit/avahi-daemon/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/avahi-daemon --no-chroot
diff --git a/lib/setup/sv/runit/conntrackd/run b/lib/setup/sv/runit/conntrackd/run
new file mode 100755
index 0000000..9e1a1fb
--- /dev/null
+++ b/lib/setup/sv/runit/conntrackd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/conntrackd -C /etc/conntrackd/conntrackd.conf
diff --git a/lib/setup/sv/runit/distccd/run b/lib/setup/sv/runit/distccd/run
new file mode 100755
index 0000000..a40a418
--- /dev/null
+++ b/lib/setup/sv/runit/distccd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/distccd --no-detach --allow 127.0.0.1 --log-stderr
diff --git a/lib/setup/sv/runit/dnsmasq/run b/lib/setup/sv/runit/dnsmasq/run
new file mode 100755
index 0000000..c605f8b
--- /dev/null
+++ b/lib/setup/sv/runit/dnsmasq/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/dnsmasq -k
diff --git a/lib/setup/sv/runit/dropbear/run b/lib/setup/sv/runit/dropbear/run
new file mode 100755
index 0000000..f2b653f
--- /dev/null
+++ b/lib/setup/sv/runit/dropbear/run
@@ -0,0 +1,7 @@
+#!/bin/sh
+exec 2>&1
+if command -v dropbearkey >/dev/null 2>&1; then
+  mkdir -p /etc/dropbear
+  [ -f /etc/dropbear/dropbear_rsa_host_key ] || dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key >/dev/null
+fi
+exec /usr/sbin/dropbear -F -E
diff --git a/lib/setup/sv/runit/fail2ban/run b/lib/setup/sv/runit/fail2ban/run
new file mode 100755
index 0000000..7bc8461
--- /dev/null
+++ b/lib/setup/sv/runit/fail2ban/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/fail2ban-server -xf --logtarget=stdout
diff --git a/lib/setup/sv/runit/haproxy/run b/lib/setup/sv/runit/haproxy/run
new file mode 100755
index 0000000..4c215a4
--- /dev/null
+++ b/lib/setup/sv/runit/haproxy/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -db
diff --git a/lib/setup/sv/runit/libvirtd/run b/lib/setup/sv/runit/libvirtd/run
new file mode 100755
index 0000000..47c5776
--- /dev/null
+++ b/lib/setup/sv/runit/libvirtd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/libvirtd
diff --git a/lib/setup/sv/runit/lighttpd/run b/lib/setup/sv/runit/lighttpd/run
new file mode 100755
index 0000000..bf94dcc
--- /dev/null
+++ b/lib/setup/sv/runit/lighttpd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/lighttpd -D -f /etc/lighttpd/lighttpd.conf
diff --git a/lib/setup/sv/runit/lxc-net/run b/lib/setup/sv/runit/lxc-net/run
new file mode 100755
index 0000000..74db834
--- /dev/null
+++ b/lib/setup/sv/runit/lxc-net/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/libexec/lxc/lxc-net start
diff --git a/lib/setup/sv/runit/mariadbd/run b/lib/setup/sv/runit/mariadbd/run
new file mode 100755
index 0000000..93d30aa
--- /dev/null
+++ b/lib/setup/sv/runit/mariadbd/run
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec 2>&1
+install -d -m 0750 /run/mysqld /var/lib/mysql || :
+exec /usr/sbin/mariadbd --datadir=/var/lib/mysql
diff --git a/lib/setup/sv/runit/memcached/run b/lib/setup/sv/runit/memcached/run
new file mode 100755
index 0000000..9191b7b
--- /dev/null
+++ b/lib/setup/sv/runit/memcached/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/memcached -u nobody
diff --git a/lib/setup/sv/runit/nfs-server/run b/lib/setup/sv/runit/nfs-server/run
new file mode 100755
index 0000000..f1f191d
--- /dev/null
+++ b/lib/setup/sv/runit/nfs-server/run
@@ -0,0 +1,6 @@
+#!/bin/sh
+exec 2>&1
+if command -v exportfs >/dev/null 2>&1; then exportfs -ra || :; fi
+if command -v rpc.statd >/dev/null 2>&1; then rpc.statd --no-notify || :; fi
+if command -v rpc.nfsd >/dev/null 2>&1; then rpc.nfsd 8 || :; fi
+exec /usr/sbin/rpc.mountd -F
diff --git a/lib/setup/sv/runit/openvpn/run b/lib/setup/sv/runit/openvpn/run
new file mode 100755
index 0000000..bd917d7
--- /dev/null
+++ b/lib/setup/sv/runit/openvpn/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/openvpn --config /etc/openvpn/server.conf
diff --git a/lib/setup/sv/runit/postgresql/run b/lib/setup/sv/runit/postgresql/run
new file mode 100755
index 0000000..4ce630d
--- /dev/null
+++ b/lib/setup/sv/runit/postgresql/run
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec 2>&1
+install -d -m 0755 /run/postgresql /var/lib/postgres/data || :
+exec /usr/bin/postgres -D /var/lib/postgres/data
diff --git a/lib/setup/sv/runit/tinyproxy/run b/lib/setup/sv/runit/tinyproxy/run
new file mode 100755
index 0000000..0a286fa
--- /dev/null
+++ b/lib/setup/sv/runit/tinyproxy/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/tinyproxy -d
diff --git a/lib/setup/sv/runit/tor/run b/lib/setup/sv/runit/tor/run
new file mode 100755
index 0000000..e83de8c
--- /dev/null
+++ b/lib/setup/sv/runit/tor/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/tor --RunAsDaemon 0
diff --git a/lib/setup/sv/runit/unbound/run b/lib/setup/sv/runit/unbound/run
new file mode 100755
index 0000000..9ec1a54
--- /dev/null
+++ b/lib/setup/sv/runit/unbound/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/unbound -d
diff --git a/lib/setup/sv/runit/usbmuxd/run b/lib/setup/sv/runit/usbmuxd/run
new file mode 100755
index 0000000..568bc71
--- /dev/null
+++ b/lib/setup/sv/runit/usbmuxd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/usbmuxd -f
diff --git a/lib/setup/sv/runit/valkey/run b/lib/setup/sv/runit/valkey/run
new file mode 100755
index 0000000..2aec40b
--- /dev/null
+++ b/lib/setup/sv/runit/valkey/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/valkey-server --daemonize no --bind 127.0.0.1
diff --git a/lib/setup/sv/s6/avahi-daemon/run b/lib/setup/sv/s6/avahi-daemon/run
new file mode 100755
index 0000000..2d0d88e
--- /dev/null
+++ b/lib/setup/sv/s6/avahi-daemon/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/avahi-daemon --no-chroot
diff --git a/lib/setup/sv/s6/conntrackd/run b/lib/setup/sv/s6/conntrackd/run
new file mode 100755
index 0000000..9e1a1fb
--- /dev/null
+++ b/lib/setup/sv/s6/conntrackd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/conntrackd -C /etc/conntrackd/conntrackd.conf
diff --git a/lib/setup/sv/s6/distccd/run b/lib/setup/sv/s6/distccd/run
new file mode 100755
index 0000000..a40a418
--- /dev/null
+++ b/lib/setup/sv/s6/distccd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/distccd --no-detach --allow 127.0.0.1 --log-stderr
diff --git a/lib/setup/sv/s6/dnsmasq/run b/lib/setup/sv/s6/dnsmasq/run
new file mode 100755
index 0000000..c605f8b
--- /dev/null
+++ b/lib/setup/sv/s6/dnsmasq/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/dnsmasq -k
diff --git a/lib/setup/sv/s6/dropbear/run b/lib/setup/sv/s6/dropbear/run
new file mode 100755
index 0000000..f2b653f
--- /dev/null
+++ b/lib/setup/sv/s6/dropbear/run
@@ -0,0 +1,7 @@
+#!/bin/sh
+exec 2>&1
+if command -v dropbearkey >/dev/null 2>&1; then
+  mkdir -p /etc/dropbear
+  [ -f /etc/dropbear/dropbear_rsa_host_key ] || dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key >/dev/null
+fi
+exec /usr/sbin/dropbear -F -E
diff --git a/lib/setup/sv/s6/fail2ban/run b/lib/setup/sv/s6/fail2ban/run
new file mode 100755
index 0000000..7bc8461
--- /dev/null
+++ b/lib/setup/sv/s6/fail2ban/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/fail2ban-server -xf --logtarget=stdout
diff --git a/lib/setup/sv/s6/haproxy/run b/lib/setup/sv/s6/haproxy/run
new file mode 100755
index 0000000..4c215a4
--- /dev/null
+++ b/lib/setup/sv/s6/haproxy/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -db
diff --git a/lib/setup/sv/s6/libvirtd/run b/lib/setup/sv/s6/libvirtd/run
new file mode 100755
index 0000000..47c5776
--- /dev/null
+++ b/lib/setup/sv/s6/libvirtd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/libvirtd
diff --git a/lib/setup/sv/s6/lighttpd/run b/lib/setup/sv/s6/lighttpd/run
new file mode 100755
index 0000000..bf94dcc
--- /dev/null
+++ b/lib/setup/sv/s6/lighttpd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/lighttpd -D -f /etc/lighttpd/lighttpd.conf
diff --git a/lib/setup/sv/s6/lxc-net/run b/lib/setup/sv/s6/lxc-net/run
new file mode 100755
index 0000000..74db834
--- /dev/null
+++ b/lib/setup/sv/s6/lxc-net/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/libexec/lxc/lxc-net start
diff --git a/lib/setup/sv/s6/mariadbd/run b/lib/setup/sv/s6/mariadbd/run
new file mode 100755
index 0000000..93d30aa
--- /dev/null
+++ b/lib/setup/sv/s6/mariadbd/run
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec 2>&1
+install -d -m 0750 /run/mysqld /var/lib/mysql || :
+exec /usr/sbin/mariadbd --datadir=/var/lib/mysql
diff --git a/lib/setup/sv/s6/memcached/run b/lib/setup/sv/s6/memcached/run
new file mode 100755
index 0000000..9191b7b
--- /dev/null
+++ b/lib/setup/sv/s6/memcached/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/memcached -u nobody
diff --git a/lib/setup/sv/s6/nfs-server/run b/lib/setup/sv/s6/nfs-server/run
new file mode 100755
index 0000000..f1f191d
--- /dev/null
+++ b/lib/setup/sv/s6/nfs-server/run
@@ -0,0 +1,6 @@
+#!/bin/sh
+exec 2>&1
+if command -v exportfs >/dev/null 2>&1; then exportfs -ra || :; fi
+if command -v rpc.statd >/dev/null 2>&1; then rpc.statd --no-notify || :; fi
+if command -v rpc.nfsd >/dev/null 2>&1; then rpc.nfsd 8 || :; fi
+exec /usr/sbin/rpc.mountd -F
diff --git a/lib/setup/sv/s6/openvpn/run b/lib/setup/sv/s6/openvpn/run
new file mode 100755
index 0000000..bd917d7
--- /dev/null
+++ b/lib/setup/sv/s6/openvpn/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/openvpn --config /etc/openvpn/server.conf
diff --git a/lib/setup/sv/s6/postgresql/run b/lib/setup/sv/s6/postgresql/run
new file mode 100755
index 0000000..4ce630d
--- /dev/null
+++ b/lib/setup/sv/s6/postgresql/run
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec 2>&1
+install -d -m 0755 /run/postgresql /var/lib/postgres/data || :
+exec /usr/bin/postgres -D /var/lib/postgres/data
diff --git a/lib/setup/sv/s6/tinyproxy/run b/lib/setup/sv/s6/tinyproxy/run
new file mode 100755
index 0000000..0a286fa
--- /dev/null
+++ b/lib/setup/sv/s6/tinyproxy/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/tinyproxy -d
diff --git a/lib/setup/sv/s6/tor/run b/lib/setup/sv/s6/tor/run
new file mode 100755
index 0000000..e83de8c
--- /dev/null
+++ b/lib/setup/sv/s6/tor/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/tor --RunAsDaemon 0
diff --git a/lib/setup/sv/s6/unbound/run b/lib/setup/sv/s6/unbound/run
new file mode 100755
index 0000000..9ec1a54
--- /dev/null
+++ b/lib/setup/sv/s6/unbound/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/unbound -d
diff --git a/lib/setup/sv/s6/usbmuxd/run b/lib/setup/sv/s6/usbmuxd/run
new file mode 100755
index 0000000..568bc71
--- /dev/null
+++ b/lib/setup/sv/s6/usbmuxd/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/sbin/usbmuxd -f
diff --git a/lib/setup/sv/s6/valkey/run b/lib/setup/sv/s6/valkey/run
new file mode 100755
index 0000000..2aec40b
--- /dev/null
+++ b/lib/setup/sv/s6/valkey/run
@@ -0,0 +1,3 @@
+#!/bin/sh
+exec 2>&1
+exec /usr/bin/valkey-server --daemonize no --bind 127.0.0.1
diff --git a/lib/setup/sv/shepherd/avahi-daemon.scm b/lib/setup/sv/shepherd/avahi-daemon.scm
new file mode 100644
index 0000000..bb0dd8c
--- /dev/null
+++ b/lib/setup/sv/shepherd/avahi-daemon.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(avahi-daemon)
+        #:requirement '(dbus)
+        #:documentation "Avahi mDNS/DNS-SD daemon"
+        #:start (make-forkexec-constructor '("/usr/sbin/avahi-daemon" "--no-chroot"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/conntrackd.scm b/lib/setup/sv/shepherd/conntrackd.scm
new file mode 100644
index 0000000..5ed14bb
--- /dev/null
+++ b/lib/setup/sv/shepherd/conntrackd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(conntrackd)
+        #:requirement '()
+        #:documentation "connection tracking daemon"
+        #:start (make-forkexec-constructor '("/usr/sbin/conntrackd" "-C" "/etc/conntrackd/conntrackd.conf"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/distccd.scm b/lib/setup/sv/shepherd/distccd.scm
new file mode 100644
index 0000000..5eaffc2
--- /dev/null
+++ b/lib/setup/sv/shepherd/distccd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(distccd)
+        #:requirement '()
+        #:documentation "distributed C/C++ compilation daemon"
+        #:start (make-forkexec-constructor '("/usr/bin/distccd" "--no-detach" "--allow" "127.0.0.1" "--log-stderr"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/dnsmasq.scm b/lib/setup/sv/shepherd/dnsmasq.scm
new file mode 100644
index 0000000..9a59f2b
--- /dev/null
+++ b/lib/setup/sv/shepherd/dnsmasq.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(dnsmasq)
+        #:requirement '()
+        #:documentation "DNS and DHCP server"
+        #:start (make-forkexec-constructor '("/usr/sbin/dnsmasq" "-k"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/dropbear.scm b/lib/setup/sv/shepherd/dropbear.scm
new file mode 100644
index 0000000..cb18306
--- /dev/null
+++ b/lib/setup/sv/shepherd/dropbear.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(dropbear)
+        #:requirement '()
+        #:documentation "Dropbear SSH server"
+        #:start (make-forkexec-constructor '("/usr/sbin/dropbear" "-F" "-E"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/fail2ban.scm b/lib/setup/sv/shepherd/fail2ban.scm
new file mode 100644
index 0000000..b1d61e9
--- /dev/null
+++ b/lib/setup/sv/shepherd/fail2ban.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(fail2ban)
+        #:requirement '()
+        #:documentation "fail2ban authentication ban daemon"
+        #:start (make-forkexec-constructor '("/usr/bin/fail2ban-server" "-xf" "--logtarget=stdout"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/haproxy.scm b/lib/setup/sv/shepherd/haproxy.scm
new file mode 100644
index 0000000..5a07060
--- /dev/null
+++ b/lib/setup/sv/shepherd/haproxy.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(haproxy)
+        #:requirement '()
+        #:documentation "HAProxy load balancer"
+        #:start (make-forkexec-constructor '("/usr/sbin/haproxy" "-f" "/etc/haproxy/haproxy.cfg" "-db"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/libvirtd.scm b/lib/setup/sv/shepherd/libvirtd.scm
new file mode 100644
index 0000000..0bd32ae
--- /dev/null
+++ b/lib/setup/sv/shepherd/libvirtd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(libvirtd)
+        #:requirement '(dbus)
+        #:documentation "libvirt virtualization daemon"
+        #:start (make-forkexec-constructor '("/usr/sbin/libvirtd"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/lighttpd.scm b/lib/setup/sv/shepherd/lighttpd.scm
new file mode 100644
index 0000000..434cd7d
--- /dev/null
+++ b/lib/setup/sv/shepherd/lighttpd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(lighttpd)
+        #:requirement '()
+        #:documentation "lighttpd web server"
+        #:start (make-forkexec-constructor '("/usr/sbin/lighttpd" "-D" "-f" "/etc/lighttpd/lighttpd.conf"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/lxc-net.scm b/lib/setup/sv/shepherd/lxc-net.scm
new file mode 100644
index 0000000..184f0ea
--- /dev/null
+++ b/lib/setup/sv/shepherd/lxc-net.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(lxc-net)
+        #:requirement '()
+        #:documentation "LXC bridge networking"
+        #:start (make-forkexec-constructor '("/usr/libexec/lxc/lxc-net" "start"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/mariadbd.scm b/lib/setup/sv/shepherd/mariadbd.scm
new file mode 100644
index 0000000..acdf28f
--- /dev/null
+++ b/lib/setup/sv/shepherd/mariadbd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(mariadbd)
+        #:requirement '()
+        #:documentation "MariaDB database server"
+        #:start (make-forkexec-constructor '("/usr/sbin/mariadbd" "--datadir=/var/lib/mysql"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/memcached.scm b/lib/setup/sv/shepherd/memcached.scm
new file mode 100644
index 0000000..0bacd54
--- /dev/null
+++ b/lib/setup/sv/shepherd/memcached.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(memcached)
+        #:requirement '()
+        #:documentation "memcached object cache"
+        #:start (make-forkexec-constructor '("/usr/bin/memcached" "-u" "nobody"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/nfs-server.scm b/lib/setup/sv/shepherd/nfs-server.scm
new file mode 100644
index 0000000..057ecdd
--- /dev/null
+++ b/lib/setup/sv/shepherd/nfs-server.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(nfs-server)
+        #:requirement '()
+        #:documentation "NFS server"
+        #:start (make-forkexec-constructor '("/usr/sbin/rpc.mountd" "-F"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/openvpn.scm b/lib/setup/sv/shepherd/openvpn.scm
new file mode 100644
index 0000000..605b9e8
--- /dev/null
+++ b/lib/setup/sv/shepherd/openvpn.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(openvpn)
+        #:requirement '()
+        #:documentation "OpenVPN server"
+        #:start (make-forkexec-constructor '("/usr/sbin/openvpn" "--config" "/etc/openvpn/server.conf"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/postgresql.scm b/lib/setup/sv/shepherd/postgresql.scm
new file mode 100644
index 0000000..c89446f
--- /dev/null
+++ b/lib/setup/sv/shepherd/postgresql.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(postgresql)
+        #:requirement '()
+        #:documentation "PostgreSQL database server"
+        #:start (make-forkexec-constructor '("/usr/bin/postgres" "-D" "/var/lib/postgres/data"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/tinyproxy.scm b/lib/setup/sv/shepherd/tinyproxy.scm
new file mode 100644
index 0000000..8b9c994
--- /dev/null
+++ b/lib/setup/sv/shepherd/tinyproxy.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(tinyproxy)
+        #:requirement '()
+        #:documentation "Tinyproxy HTTP proxy"
+        #:start (make-forkexec-constructor '("/usr/bin/tinyproxy" "-d"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/tor.scm b/lib/setup/sv/shepherd/tor.scm
new file mode 100644
index 0000000..c6693b0
--- /dev/null
+++ b/lib/setup/sv/shepherd/tor.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(tor)
+        #:requirement '()
+        #:documentation "Tor anonymity daemon"
+        #:start (make-forkexec-constructor '("/usr/bin/tor" "--RunAsDaemon" "0"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/unbound.scm b/lib/setup/sv/shepherd/unbound.scm
new file mode 100644
index 0000000..860a11a
--- /dev/null
+++ b/lib/setup/sv/shepherd/unbound.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(unbound)
+        #:requirement '()
+        #:documentation "validating recursive DNS resolver"
+        #:start (make-forkexec-constructor '("/usr/sbin/unbound" "-d"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/usbmuxd.scm b/lib/setup/sv/shepherd/usbmuxd.scm
new file mode 100644
index 0000000..1138cbb
--- /dev/null
+++ b/lib/setup/sv/shepherd/usbmuxd.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(usbmuxd)
+        #:requirement '()
+        #:documentation "iOS USB multiplexing daemon"
+        #:start (make-forkexec-constructor '("/usr/sbin/usbmuxd" "-f"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/shepherd/valkey.scm b/lib/setup/sv/shepherd/valkey.scm
new file mode 100644
index 0000000..2762921
--- /dev/null
+++ b/lib/setup/sv/shepherd/valkey.scm
@@ -0,0 +1,9 @@
+(use-modules (shepherd service))
+(register-services
+ (list (service
+        '(valkey)
+        #:requirement '()
+        #:documentation "Valkey data store"
+        #:start (make-forkexec-constructor '("/usr/bin/valkey-server" "--daemonize" "no" "--bind" "127.0.0.1"))
+        #:stop (make-kill-destructor)
+        #:respawn? #t)))
diff --git a/lib/setup/sv/systemd/avahi-daemon.service b/lib/setup/sv/systemd/avahi-daemon.service
new file mode 100644
index 0000000..e661fb4
--- /dev/null
+++ b/lib/setup/sv/systemd/avahi-daemon.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Avahi mDNS/DNS-SD daemon
+After=network.target dbus.service
+
+[Service]
+ExecStart=/usr/sbin/avahi-daemon --no-chroot
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/conntrackd.service b/lib/setup/sv/systemd/conntrackd.service
new file mode 100644
index 0000000..a2c78b8
--- /dev/null
+++ b/lib/setup/sv/systemd/conntrackd.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=connection tracking daemon
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/conntrackd -C /etc/conntrackd/conntrackd.conf
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/distccd.service b/lib/setup/sv/systemd/distccd.service
new file mode 100644
index 0000000..c916d75
--- /dev/null
+++ b/lib/setup/sv/systemd/distccd.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=distributed C/C++ compilation daemon
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/distccd --no-detach --allow 127.0.0.1 --log-stderr
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/dnsmasq.service b/lib/setup/sv/systemd/dnsmasq.service
new file mode 100644
index 0000000..ad65d05
--- /dev/null
+++ b/lib/setup/sv/systemd/dnsmasq.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=DNS and DHCP server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/dnsmasq -k
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/dropbear.service b/lib/setup/sv/systemd/dropbear.service
new file mode 100644
index 0000000..8b581e9
--- /dev/null
+++ b/lib/setup/sv/systemd/dropbear.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Dropbear SSH server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/dropbear -F -E
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/fail2ban.service b/lib/setup/sv/systemd/fail2ban.service
new file mode 100644
index 0000000..0fb885a
--- /dev/null
+++ b/lib/setup/sv/systemd/fail2ban.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=fail2ban authentication ban daemon
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/fail2ban-server -xf --logtarget=stdout
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/haproxy.service b/lib/setup/sv/systemd/haproxy.service
new file mode 100644
index 0000000..7d23486
--- /dev/null
+++ b/lib/setup/sv/systemd/haproxy.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=HAProxy load balancer
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -db
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/libvirtd.service b/lib/setup/sv/systemd/libvirtd.service
new file mode 100644
index 0000000..a930a04
--- /dev/null
+++ b/lib/setup/sv/systemd/libvirtd.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=libvirt virtualization daemon
+After=network.target dbus.service
+
+[Service]
+ExecStart=/usr/sbin/libvirtd
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/lighttpd.service b/lib/setup/sv/systemd/lighttpd.service
new file mode 100644
index 0000000..51a2a0b
--- /dev/null
+++ b/lib/setup/sv/systemd/lighttpd.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=lighttpd web server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/lighttpd -D -f /etc/lighttpd/lighttpd.conf
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/lxc-net.service b/lib/setup/sv/systemd/lxc-net.service
new file mode 100644
index 0000000..83d2f4b
--- /dev/null
+++ b/lib/setup/sv/systemd/lxc-net.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=LXC bridge networking
+After=network.target
+
+[Service]
+ExecStart=/usr/libexec/lxc/lxc-net start
+Type=oneshot
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/mariadb.service b/lib/setup/sv/systemd/mariadb.service
new file mode 100644
index 0000000..ae43341
--- /dev/null
+++ b/lib/setup/sv/systemd/mariadb.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=MariaDB database server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/mariadbd --datadir=/var/lib/mysql
+Restart=on-failure
+User=mysql
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/memcached.service b/lib/setup/sv/systemd/memcached.service
new file mode 100644
index 0000000..59a5c27
--- /dev/null
+++ b/lib/setup/sv/systemd/memcached.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=memcached object cache
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/memcached -u nobody
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/nfs-server.service b/lib/setup/sv/systemd/nfs-server.service
new file mode 100644
index 0000000..8010151
--- /dev/null
+++ b/lib/setup/sv/systemd/nfs-server.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=NFS server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/rpc.mountd -F
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/openvpn.service b/lib/setup/sv/systemd/openvpn.service
new file mode 100644
index 0000000..a79608a
--- /dev/null
+++ b/lib/setup/sv/systemd/openvpn.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=OpenVPN server
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/openvpn --config /etc/openvpn/server.conf
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/postgresql.service b/lib/setup/sv/systemd/postgresql.service
new file mode 100644
index 0000000..c357732
--- /dev/null
+++ b/lib/setup/sv/systemd/postgresql.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=PostgreSQL database server
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/postgres -D /var/lib/postgres/data
+Restart=on-failure
+User=postgres
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/tinyproxy.service b/lib/setup/sv/systemd/tinyproxy.service
new file mode 100644
index 0000000..c84cb3d
--- /dev/null
+++ b/lib/setup/sv/systemd/tinyproxy.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Tinyproxy HTTP proxy
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/tinyproxy -d
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/tor.service b/lib/setup/sv/systemd/tor.service
new file mode 100644
index 0000000..a84fc3b
--- /dev/null
+++ b/lib/setup/sv/systemd/tor.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Tor anonymity daemon
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/tor --RunAsDaemon 0
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/unbound.service b/lib/setup/sv/systemd/unbound.service
new file mode 100644
index 0000000..8eba19f
--- /dev/null
+++ b/lib/setup/sv/systemd/unbound.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=validating recursive DNS resolver
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/unbound -d
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/usbmuxd.service b/lib/setup/sv/systemd/usbmuxd.service
new file mode 100644
index 0000000..0e54cdd
--- /dev/null
+++ b/lib/setup/sv/systemd/usbmuxd.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=iOS USB multiplexing daemon
+After=network.target
+
+[Service]
+ExecStart=/usr/sbin/usbmuxd -f
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/lib/setup/sv/systemd/valkey.service b/lib/setup/sv/systemd/valkey.service
new file mode 100644
index 0000000..f82eefd
--- /dev/null
+++ b/lib/setup/sv/systemd/valkey.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Valkey data store
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/valkey-server --daemonize no --bind 127.0.0.1
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/tests/test_services.sh b/tests/test_services.sh
index b49bf53..d47d882 100644
--- a/tests/test_services.sh
+++ b/tests/test_services.sh
@@ -97,6 +97,18 @@ enabled=$(setup_service_enable_one "$root2" shepherd sshd) ||
 [ -f "$root2/etc/shepherd.d/sshd.scm" ] ||
 	fail 'stock shepherd sshd.scm was not copied'
 
+enabled=$(setup_service_enable_one "$root2" runit postgresql) ||
+	fail 'runit enable of postgresql from stock scripts failed'
+[ -f "$root2/etc/sv/postgresql/run" ] ||
+	fail 'stock runit postgresql/run was not copied'
+[ -L "$root2/etc/runit/runsvdir/default/postgresql" ] ||
+	fail 'runit postgresql was not linked into runsvdir/default'
+
+enabled=$(setup_service_enable_one "$root2" dinit unbound) ||
+	fail 'dinit enable of unbound from stock scripts failed'
+[ -f "$root2/etc/dinit.d/unbound" ] ||
+	fail 'stock dinit unbound was not copied'
+
 # Do not overwrite a package-shipped run script.
 mkdir -p "$root2/etc/sv/sshd"
 printf '%s\n' '#!/bin/sh' 'exec /opt/custom/sshd' >"$root2/etc/sv/sshd/run"
diff --git a/tests/test_stock_services.sh b/tests/test_stock_services.sh
new file mode 100755
index 0000000..a999eae
--- /dev/null
+++ b/tests/test_stock_services.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+# Every mapped daemon must have stock scripts for all supported inits.
+
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+fail() { printf 'test_stock_services: %s\n' "$*" >&2; exit 1; }
+
+map=$project_dir/lib/setup/services
+sv=$project_dir/lib/setup/sv
+[ -f "$map" ] || fail "missing $map"
+
+count=0
+while read -r pkg unit _wanted rc _level; do
+	case $pkg in
+		''|\#*) continue ;;
+	esac
+	[ -n "$unit" ] && [ -n "$rc" ] || fail "bad map row for $pkg"
+	[ -f "$sv/systemd/$unit" ] || fail "missing systemd stock $unit ($pkg)"
+	[ -f "$sv/openrc/$rc" ] || fail "missing openrc stock $rc ($pkg)"
+	[ -f "$sv/runit/$rc/run" ] || fail "missing runit stock $rc/run ($pkg)"
+	[ -f "$sv/s6/$rc/run" ] || fail "missing s6 stock $rc/run ($pkg)"
+	[ -f "$sv/dinit/$rc" ] || fail "missing dinit stock $rc ($pkg)"
+	[ -f "$sv/shepherd/$rc.scm" ] || fail "missing shepherd stock $rc.scm ($pkg)"
+	count=$((count + 1))
+done <"$map"
+
+[ "$count" -ge 20 ] || fail "expected at least 20 mapped services, got $count"
+printf '%s\n' "stock service files present for $count map rows"
diff --git a/tests/test_upgrade_recipes.sh b/tests/test_upgrade_recipes.sh
new file mode 100755
index 0000000..5ad6d28
--- /dev/null
+++ b/tests/test_upgrade_recipes.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+fail() { printf 'test_upgrade_recipes: %s\n' "$*" >&2; exit 1; }
+
+tool=$project_dir/tools/upgrade-recipes
+[ -f "$tool" ] || fail "missing $tool"
+command -v python3 >/dev/null 2>&1 || fail 'python3 is required for upgrade-recipes'
+
+python3 "$tool" --selftest >/dev/null || fail 'upgrade-recipes --selftest failed'
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-upgrade-test.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+mkdir -p "$tmp/tree/demo"
+cat >"$tmp/tree/demo/recipe" <<'EOF'
+name        demo
+version     1.0
+release     3
+arch        any
+description demo: offline fixture for upgrade-recipes
+
+source      https://example.org/demo-1.0.tar.gz
+hash        sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+
+depend      filesystem
+install     mkdir -p "$PKG/usr/share/demo"
+install     printf '%s\n' "${version}" >"$PKG/usr/share/demo/version"
+EOF
+
+# A dry-run against an unreachable host must still parse the tree.
+out=$(python3 "$tool" --trees "$tmp/tree" --only demo --jobs 1 2>/dev/null || true)
+printf '%s\n' "$out" | grep -q 'summary' || fail "plan output missing summary: $out"
+
+printf '%s\n' 'upgrade-recipes tests passed'
diff --git a/tools/upgrade-recipes b/tools/upgrade-recipes
new file mode 100755
index 0000000..a2796b1
--- /dev/null
+++ b/tools/upgrade-recipes
@@ -0,0 +1,1154 @@
+#!/usr/bin/env python3
+"""Upgrade SPS recipe trees to the newest upstream stable release.
+
+Maintainers run one command against core and extra. The tool detects the
+current source, finds the newest non-prerelease upstream, rewrites
+version/source/hash, resets release to 1, and scans the new tarball for
+libraries the recipe does not yet declare. Names that already exist in the
+trees are added as depend or builddep. Names that do not exist are reported,
+and with --scaffold-missing a conservative extra recipe is drafted when a
+source URL can be resolved.
+
+Default is a dry-run plan. Pass --apply to write recipes.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import ssl
+import sys
+import tarfile
+import tempfile
+import zipfile
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+from urllib.request import Request, urlopen
+
+UA = "sps-upgrade-recipes/1.0 (+https://github.com/RobertFlexx/SPS)"
+CACHE = Path(os.environ.get("SPS_UPGRADE_CACHE", str(Path.home() / ".cache" / "sps-upgrade")))
+GITHUB_API = "https://api.github.com"
+REPOLOGY = "https://repology.org/api/v1/project/{}"
+
+PRE_RE = re.compile(
+    r"(?:^|[.\-_])(?:alpha|beta|rc|pre|preview|dev|git|snapshot|nightly|test)(?:\d|$)|"
+    r"(?:alpha|beta|rc)\d",
+    re.I,
+)
+GNOME_EVEN = {
+    "gtk", "gtk4", "glib", "pango", "gdk-pixbuf", "atk", "at-spi2-core",
+    "libadwaita", "libsoup", "gtksourceview", "vte", "gexiv2",
+}
+
+# pkg-config / cmake / meson names -> SPS package names
+PC_MAP = {
+    "libssl": "openssl", "libcrypto": "openssl", "openssl": "openssl",
+    "zlib": "zlib", "libz": "zlib", "liblzma": "xz", "libzstd": "zstd",
+    "libbz2": "bzip2", "bzip2": "bzip2", "libpcre2-8": "pcre2", "libpcre": "pcre",
+    "libpng": "libpng", "libpng16": "libpng", "libjpeg": "libjpeg-turbo",
+    "libturbojpeg": "libjpeg-turbo", "libtiff-4": "tiff", "libwebp": "libwebp",
+    "freetype2": "freetype", "harfbuzz": "harfbuzz", "graphite2": "graphite2",
+    "fontconfig": "fontconfig", "cairo": "cairo", "pango": "pango",
+    "pangocairo": "pango", "gdk-pixbuf-2.0": "gdk-pixbuf",
+    "gtk+-3.0": "gtk3", "gtk4": "gtk4", "libadwaita-1": "libadwaita",
+    "glib-2.0": "glib", "gio-2.0": "glib", "gobject-2.0": "glib",
+    "gmodule-2.0": "glib", "gthread-2.0": "glib",
+    "libxml-2.0": "libxml2", "libxslt": "libxslt", "icu-uc": "icu",
+    "icu-i18n": "icu", "sqlite3": "sqlite", "libcurl": "curl",
+    "libarchive": "libarchive", "expat": "expat", "libevent": "libevent",
+    "ncurses": "ncurses", "ncursesw": "ncurses", "readline": "readline",
+    "uuid": "util-linux", "mount": "util-linux", "blkid": "util-linux",
+    "libudev": "systemd", "libelf": "elfutils", "libdw": "elfutils",
+    "libcap": "libcap", "libcap-ng": "libcap-ng", "libseccomp": "libseccomp",
+    "dbus-1": "dbus", "libsystemd": "systemd", "libpulse": "pulseaudio",
+    "alsa": "alsa-lib", "alsa-lib": "alsa-lib",
+    "x11": "libx11", "xext": "libxext", "xrandr": "libxrandr",
+    "xinerama": "libxinerama", "xfixes": "libxfixes", "xi": "libxi",
+    "xkbcommon": "libxkbcommon", "wayland-client": "wayland",
+    "wayland-server": "wayland", "wayland-cursor": "wayland",
+    "wayland-egl": "wayland", "egl": "mesa", "glesv2": "mesa",
+    "gbm": "mesa", "dri": "mesa", "libdrm": "libdrm",
+    "vulkan": "vulkan-loader", "epoxy": "libepoxy",
+    "pixman-1": "pixman", "lcms2": "lcms2", "libass": "libass",
+    "libavcodec": "ffmpeg", "libavformat": "ffmpeg", "libavutil": "ffmpeg",
+    "libswscale": "ffmpeg", "libswresample": "ffmpeg",
+    "opus": "opus", "vorbis": "libvorbis", "ogg": "libogg",
+    "flac": "flac", "sndfile": "libsndfile", "samplerate": "libsamplerate",
+    "fftw3": "fftw", "fftw3f": "fftw", "libopenjp2": "openjpeg",
+    "Qt6Core": "qt6-qtbase", "Qt6Gui": "qt6-qtbase", "Qt6Widgets": "qt6-qtbase",
+    "Qt6Network": "qt6-qtbase", "Qt6Qml": "qt6-qtdeclarative",
+    "Qt6Quick": "qt6-qtdeclarative", "Qt6Svg": "qt6-qtsvg",
+    "Qt6WebEngineCore": "qt6-qtwebengine", "libplacebo": "libplacebo",
+    "libva": "libva", "vpx": "libvpx", "dav1d": "dav1d",
+    "x264": "x264", "x265": "x265", "aom": "aom", "SvtAv1Enc": "svt-av1",
+    "libbrotlidec": "brotli", "libbrotlienc": "brotli",
+    "libnghttp2": "nghttp2", "libidn2": "libidn2", "libpsl": "libpsl",
+    "gnutls": "gnutls", "nss": "nss", "nspr": "nspr",
+    "libgit2": "libgit2", "yaml-0.1": "libyaml", "libyaml": "libyaml",
+    "json-c": "json-c", "fmt": "fmt", "spdlog": "spdlog",
+    "protobuf": "protobuf", "re2": "re2", "snappy": "snappy",
+    "libssh": "libssh", "libssh2": "libssh2",
+    "fuse3": "fuse3", "fuse": "fuse", "libacl": "acl", "libattr": "attr",
+    "python-3.13": "python", "python3": "python", "lua": "lua",
+    "luajit": "luajit", "ruby": "ruby", "libpopt": "popt",
+    "libnl-3.0": "libnl", "libnl-genl-3.0": "libnl",
+    "libtirpc": "libtirpc", "libyajl": "yajl", "yajl": "yajl",
+    "libvirt": "libvirt", "libslirp": "libslirp",
+    "spice-server": "spice", "spice-protocol": "spice-protocol",
+    "gl": "mesa", "glew": "glew", "sdl2": "sdl2", "SDL2": "sdl2",
+    "sdl3": "sdl3", "gmp": "gmp", "mpfr": "mpfr", "mpc": "mpc",
+    "isl": "isl", "libffi": "libffi", "libtasn1": "libtasn1",
+    "nettle": "nettle", "hogweed": "nettle", "libidn": "libidn",
+    "jansson": "jansson", "libmicrohttpd": "libmicrohttpd",
+    "lmdb": "lmdb", "gdbm": "gdbm", "libaio": "libaio",
+    "liburing": "liburing", "jemalloc": "jemalloc",
+    "boost": "boost", "icu-io": "icu",
+}
+
+SKIP_DEPS = {
+    "threads", "thread", "dl", "dlib", "m", "rt", "pthread", "c", "stdc++",
+    "gcc", "gomp", "atomic", "iconv", "intl", "ws2_32", "ole32", "shell32",
+    "user32", "advapi32", "kernel32", "ntdll", "android", "log",
+    "CoreFoundation", "Foundation", "cocoa", "objc", "resolv",
+    "execinfo", "util", "kvm", "procstat", "inotify",
+}
+
+HOST_TOOLS = {
+    "cmake", "ninja", "meson", "pkgconf", "pkg-config", "python", "python3",
+    "perl", "bison", "flex", "gperf", "gawk", "m4", "autoconf", "automake",
+    "libtool", "gettext", "intltool", "patch", "make", "gcc", "g++", "cc",
+    "c++", "rustc", "cargo", "go", "nasm", "yasm",
+}
+
+# Packages upgraded together. First existing member supplies the version.
+PIN_GROUPS = [
+    re.compile(r"^qt6-qt"),
+    re.compile(r"^kf6-"),
+    re.compile(r"^plasma-"),
+    re.compile(r"^gst-"),
+]
+
+NO_AUTO = {
+    "linux", "linux-desktop", "linux-firmware", "splux-base", "filesystem",
+    "daily-drive", "qol-cli", "workstation-extra", "qt6", "kf6", "clang",
+}
+
+
+class HttpError(RuntimeError):
+    pass
+
+
+def log(msg: str) -> None:
+    print(msg, file=sys.stderr, flush=True)
+
+
+def http_get(url: str, timeout: int = 30, binary: bool = False, accept: str = ""):
+    headers = {"User-Agent": UA}
+    if accept:
+        headers["Accept"] = accept
+    token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+    if token and "api.github.com" in url:
+        headers["Authorization"] = f"Bearer {token}"
+    req = Request(url, headers=headers)
+    ctx = ssl.create_default_context()
+    try:
+        with urlopen(req, timeout=timeout, context=ctx) as resp:
+            data = resp.read()
+            if binary:
+                return data
+            return data.decode("utf-8", errors="replace")
+    except HTTPError as exc:
+        raise HttpError(f"{url}: HTTP {exc.code}") from exc
+    except URLError as exc:
+        raise HttpError(f"{url}: {exc.reason}") from exc
+
+
+def version_tokens(version: str) -> list[tuple[str, str]]:
+    tokens = []
+    i = 0
+    v = version or ""
+    while i < len(v):
+        c = v[i]
+        if c == "~":
+            tokens.append(("tilde", c))
+            i += 1
+        elif c.isdigit():
+            j = i
+            while j < len(v) and v[j].isdigit():
+                j += 1
+            tokens.append(("number", v[i:j]))
+            i = j
+        elif c.isalpha():
+            j = i
+            while j < len(v) and v[j].isalpha():
+                j += 1
+            tokens.append(("text", v[i:j].lower()))
+            i = j
+        else:
+            i += 1
+    return tokens
+
+
+def version_compare(left: str, right: str) -> int:
+    lt, rt = version_tokens(left), version_tokens(right)
+    n = max(len(lt), len(rt))
+    for i in range(n):
+        if i >= len(lt):
+            rest = rt[i:]
+            if rest and rest[0][0] == "tilde":
+                return 1
+            return -1 if rest else 0
+        if i >= len(rt):
+            rest = lt[i:]
+            if rest and rest[0][0] == "tilde":
+                return -1
+            return 1 if rest else 0
+        (lt_t, lt_v), (rt_t, rt_v) = lt[i], rt[i]
+        if lt_t == "tilde" or rt_t == "tilde":
+            if lt_t == rt_t:
+                continue
+            return -1 if lt_t == "tilde" else 1
+        if lt_t == "number" and rt_t == "number":
+            a, b = lt_v.lstrip("0") or "0", rt_v.lstrip("0") or "0"
+            if len(a) != len(b):
+                return -1 if len(a) < len(b) else 1
+            if a != b:
+                return -1 if a < b else 1
+            continue
+        if lt_t != rt_t:
+            return -1 if lt_t == "number" else 1
+        if lt_v != rt_v:
+            return -1 if lt_v < rt_v else 1
+    return 0
+
+
+def version_newer(candidate: str, current: str) -> bool:
+    return version_compare(candidate, current) > 0
+
+
+def is_prerelease(version: str) -> bool:
+    if not version:
+        return True
+    if PRE_RE.search(version):
+        return True
+    return False
+
+
+def gnome_stable(name: str, version: str) -> bool:
+    key = name.lower().replace("gtk4", "gtk")
+    if key not in GNOME_EVEN and name.lower() not in GNOME_EVEN:
+        return True
+    parts = re.findall(r"\d+", version)
+    if len(parts) < 2:
+        return True
+    return int(parts[1]) % 2 == 0
+
+
+def parse_recipe(path: Path) -> dict:
+    rec = {
+        "path": path,
+        "name": "",
+        "version": "",
+        "release": "1",
+        "arch": "",
+        "description": "",
+        "source": [],
+        "hash": [],
+        "depend": [],
+        "builddep": [],
+        "optional": [],
+        "conflict": [],
+        "prepare": [],
+        "configure": [],
+        "build": [],
+        "install": [],
+        "raw": path.read_text(encoding="utf-8"),
+    }
+    key = None
+    buf = []
+    for line in rec["raw"].splitlines():
+        if not line.strip() or line.lstrip().startswith("#"):
+            continue
+        if line.endswith("\\"):
+            piece = line[:-1]
+            if key is None:
+                parts = piece.split(None, 1)
+                if len(parts) != 2:
+                    continue
+                key, val = parts[0], parts[1]
+                buf = [val]
+            else:
+                buf.append(piece.strip())
+            continue
+        if key is not None:
+            buf.append(line.strip())
+            rec.setdefault(key, [])
+            if key in rec and isinstance(rec[key], list):
+                rec[key].append(" ".join(buf).strip())
+            key = None
+            buf = []
+            continue
+        parts = line.split(None, 1)
+        if len(parts) != 2:
+            continue
+        k, v = parts[0], parts[1]
+        if k in ("name", "version", "release", "arch", "description"):
+            rec[k] = v
+        elif k in rec and isinstance(rec[k], list):
+            rec[k].append(v)
+    rec["names"] = rec["name"]
+    return rec
+
+
+def recipe_has_source(rec: dict) -> bool:
+    return bool(rec.get("source"))
+
+
+def sha256_bytes(data: bytes) -> str:
+    return hashlib.sha256(data).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+    h = hashlib.sha256()
+    with path.open("rb") as fh:
+        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
+            h.update(chunk)
+    return h.hexdigest()
+
+
+def cache_path(url: str) -> Path:
+    CACHE.mkdir(parents=True, exist_ok=True)
+    name = hashlib.sha256(url.encode()).hexdigest()[:20] + "-" + url.rstrip("/").split("/")[-1][:80]
+    return CACHE / name
+
+
+def download(url: str) -> Path:
+    dest = cache_path(url)
+    if dest.exists() and dest.stat().st_size > 0:
+        return dest
+    tmp = dest.with_suffix(dest.suffix + ".part")
+    data = http_get(url, timeout=120, binary=True)
+    tmp.write_bytes(data)
+    tmp.replace(dest)
+    return dest
+
+
+def strip_tag(tag: str) -> str:
+    tag = tag.strip()
+    tag = re.sub(r"^(refs/tags/)", "", tag)
+    tag = re.sub(r"^(v|V|release-|Release-|version-|VERSION-)", "", tag)
+    tag = re.sub(r"^(llvmorg-)", "", tag)
+    return tag.lstrip("v")
+
+
+def github_repo(url: str) -> tuple[str, str] | None:
+    m = re.search(r"github\.com/([^/]+)/([^/]+)", url)
+    if not m:
+        return None
+    owner, repo = m.group(1), m.group(2)
+    repo = re.sub(r"\.git$", "", repo)
+    return owner, repo
+
+
+def latest_github(owner: str, repo: str) -> str | None:
+    try:
+        body = http_get(
+            f"{GITHUB_API}/repos/{owner}/{repo}/releases/latest",
+            accept="application/vnd.github+json",
+        )
+        data = json.loads(body)
+        tag = data.get("tag_name") or ""
+        if data.get("prerelease") or data.get("draft"):
+            tag = ""
+        ver = strip_tag(tag)
+        if ver and not is_prerelease(ver):
+            return ver
+    except (HttpError, json.JSONDecodeError, TypeError):
+        pass
+    try:
+        body = http_get(
+            f"{GITHUB_API}/repos/{owner}/{repo}/tags?per_page=30",
+            accept="application/vnd.github+json",
+        )
+        tags = json.loads(body)
+        best = None
+        for item in tags:
+            ver = strip_tag(item.get("name") or "")
+            if not ver or is_prerelease(ver):
+                continue
+            if best is None or version_newer(ver, best):
+                best = ver
+        return best
+    except (HttpError, json.JSONDecodeError, TypeError):
+        return None
+
+
+def latest_repology(name: str) -> str | None:
+    try:
+        body = http_get(REPOLOGY.format(name), timeout=40)
+        rows = json.loads(body)
+    except (HttpError, json.JSONDecodeError):
+        return None
+    best = None
+    for row in rows:
+        if row.get("status") not in {"newest", "unique"}:
+            continue
+        ver = str(row.get("version") or "")
+        if not ver or is_prerelease(ver):
+            continue
+        if not gnome_stable(name, ver):
+            continue
+        if best is None or version_newer(ver, best):
+            best = ver
+    return best
+
+
+def latest_gnome(url: str, name: str) -> str | None:
+    m = re.search(r"download\.gnome\.org/sources/([^/]+)/", url)
+    module = m.group(1) if m else name
+    try:
+        body = http_get(f"https://download.gnome.org/sources/{module}/cache.json")
+        data = json.loads(body)
+    except (HttpError, json.JSONDecodeError):
+        return None
+    versions = []
+    if isinstance(data, list) and len(data) >= 2 and isinstance(data[1], dict):
+        pkgmap = data[1].get(module) or data[1].get(name) or {}
+        for series, items in pkgmap.items():
+            if not gnome_stable(module, series + ".0"):
+                continue
+            if isinstance(items, list):
+                versions.extend(str(x) for x in items)
+    best = None
+    for ver in versions:
+        if is_prerelease(ver):
+            continue
+        if not gnome_stable(module, ver):
+            continue
+        if best is None or version_newer(ver, best):
+            best = ver
+    return best
+
+
+def latest_gnu(url: str, name: str) -> str | None:
+    parsed = urlparse(url)
+    directory = url.rsplit("/", 1)[0] + "/"
+    try:
+        body = http_get(directory)
+    except HttpError:
+        return None
+    versions = re.findall(
+        rf"href=\"{re.escape(name)}-([0-9][0-9A-Za-z.+~_-]*)\.(?:tar\.(?:gz|xz|bz2|lz)|tgz)\"",
+        body,
+    )
+    best = None
+    for ver in versions:
+        if is_prerelease(ver):
+            continue
+        if best is None or version_newer(ver, best):
+            best = ver
+    return best
+
+
+def latest_qemu() -> str | None:
+    try:
+        body = http_get("https://download.qemu.org/")
+    except HttpError:
+        return None
+    versions = re.findall(r"href=\"qemu-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz\"", body)
+    best = None
+    for ver in versions:
+        if best is None or version_newer(ver, best):
+            best = ver
+    return best
+
+
+def latest_pypi(name: str) -> str | None:
+    try:
+        body = http_get(f"https://pypi.org/pypi/{name}/json")
+        data = json.loads(body)
+        ver = data.get("info", {}).get("version")
+        if ver and not is_prerelease(ver):
+            return ver
+    except (HttpError, json.JSONDecodeError):
+        return None
+    return None
+
+
+def detect_latest(rec: dict) -> str | None:
+    name = rec["name"]
+    url = rec["source"][0] if rec["source"] else ""
+    found: list[str] = []
+
+    if "download.qemu.org" in url or name == "qemu":
+        v = latest_qemu()
+        if v:
+            found.append(v)
+    if "download.gnome.org" in url:
+        v = latest_gnome(url, name)
+        if v:
+            found.append(v)
+    if "ftp.gnu.org" in url or "ftpmirror.gnu.org" in url:
+        v = latest_gnu(url, name)
+        if v:
+            found.append(v)
+    gh = github_repo(url)
+    if gh:
+        v = latest_github(*gh)
+        if v:
+            found.append(v)
+    if "pypi.org" in url or "files.pythonhosted.org" in url:
+        v = latest_pypi(name)
+        if v:
+            found.append(v)
+
+    v = latest_repology(name)
+    if v:
+        found.append(v)
+
+    best = None
+    for ver in found:
+        if is_prerelease(ver):
+            continue
+        if not gnome_stable(name, ver):
+            continue
+        if best is None or version_newer(ver, best):
+            best = ver
+    return best
+
+
+def _series(version: str) -> str:
+    parts = version.split(".")
+    if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
+        return f"{parts[0]}.{parts[1]}"
+    return version
+
+
+def rewrite_url(url: str, old: str, new: str) -> str:
+    if not old or old == new:
+        return url
+    updated = url
+    variants = [
+        (old, new),
+        (old.replace(".", "_"), new.replace(".", "_")),
+        (old.replace(".", "-"), new.replace(".", "-")),
+        (old.replace(".", ""), new.replace(".", "")),
+    ]
+    for a, b in variants:
+        if a and a in updated:
+            updated = updated.replace(a, b)
+            break
+    old_s, new_s = _series(old), _series(new)
+    if old_s != new_s and f"/{old_s}/" in updated:
+        updated = updated.replace(f"/{old_s}/", f"/{new_s}/")
+    return updated
+
+
+def extract_archive(archive: Path, dest: Path) -> Path:
+    dest.mkdir(parents=True, exist_ok=True)
+    name = archive.name.lower()
+    if name.endswith(".zip"):
+        with zipfile.ZipFile(archive) as zf:
+            zf.extractall(dest)
+    else:
+        with tarfile.open(archive, "r:*") as tf:
+            try:
+                tf.extractall(dest, filter="data")
+            except TypeError:
+                tf.extractall(dest)
+    children = [p for p in dest.iterdir() if p.name not in {".", ".."}]
+    if len(children) == 1 and children[0].is_dir():
+        return children[0]
+    return dest
+
+
+MESON_DEP = re.compile(
+    r"""dependency\(\s*['\"]([^'\"]+)['\"]([^)]*)\)""",
+    re.M,
+)
+CMAKE_PKG = re.compile(
+    r"""find_package\(\s*([A-Za-z0-9_+.-]+)[^)]*REQUIRED""",
+    re.M,
+)
+CMAKE_PC = re.compile(
+    r"""pkg_check_modules\(\s*[A-Za-z0-9_]+\s+(?:REQUIRED\s+)?([A-Za-z0-9_+\-.]+)""",
+    re.I,
+)
+AUTOCONF_PC = re.compile(
+    r"""PKG_CHECK_MODULES\(\s*\[[^\]]+\]\s*,\s*\[([^\]>=]+)""",
+)
+PC_REQUIRES = re.compile(
+    r"^(?:Requires(?:\.private)?):\s*(.+)$",
+    re.M | re.I,
+)
+
+
+def scan_build_files(root: Path) -> tuple[set[str], set[str]]:
+    """Return (runtime-ish names, build-ish names) as pkg-config/cmake ids."""
+    runtime: set[str] = set()
+    build: set[str] = set()
+    files: list[Path] = []
+    for pat in ("meson.build", "CMakeLists.txt", "configure.ac", "configure.in"):
+        files.extend(root.rglob(pat))
+    files.extend(root.rglob("*.pc.in"))
+    files.extend(root.rglob("*.pc"))
+    seen = 0
+    for path in files:
+        if seen > 80:
+            break
+        try:
+            if path.stat().st_size > 2_000_000:
+                continue
+            text = path.read_text(encoding="utf-8", errors="replace")
+        except OSError:
+            continue
+        seen += 1
+        name = path.name
+        if name in {"meson.build", "meson_options.txt"}:
+            for match in MESON_DEP.finditer(text):
+                pc, rest = match.group(1), match.group(2)
+                if re.search(r"required\s*:\s*false", rest, re.I):
+                    continue
+                runtime.add(pc)
+        elif name == "CMakeLists.txt":
+            for match in CMAKE_PKG.finditer(text):
+                runtime.add(match.group(1))
+            for match in CMAKE_PC.finditer(text):
+                runtime.add(match.group(1).split()[0])
+        elif name in {"configure.ac", "configure.in"}:
+            for match in AUTOCONF_PC.finditer(text):
+                runtime.add(match.group(1).strip().split()[0])
+        elif name.endswith(".pc") or name.endswith(".pc.in"):
+            for match in PC_REQUIRES.finditer(text):
+                for tok in re.split(r"[ ,]+", match.group(1)):
+                    tok = re.sub(r"[<>=].*$", "", tok).strip()
+                    if tok:
+                        runtime.add(tok)
+    return runtime, build
+
+
+def map_dep(raw: str, known: dict[str, Path]) -> str | None:
+    raw = raw.strip()
+    if not raw or raw in SKIP_DEPS:
+        return None
+    if raw in PC_MAP:
+        mapped = PC_MAP[raw]
+        return mapped if mapped in known else mapped
+    lower = raw.lower()
+    if lower in known:
+        return lower
+    if raw in known:
+        return raw
+    slug = lower.replace("_", "-")
+    if slug in known:
+        return slug
+    if slug.startswith("lib") and slug[3:] in known:
+        return slug[3:]
+    if not slug.startswith("lib") and f"lib{slug}" in known:
+        return f"lib{slug}"
+    # gtk+-3.0 -> gtk3 already in map; qt6 bits
+    m = re.match(r"qt6(.+)", slug)
+    if m:
+        cand = "qt6-qt" + m.group(1).lower()
+        if cand in known:
+            return cand
+    return None
+
+
+def load_tree_names(trees: list[Path]) -> dict[str, Path]:
+    names: dict[str, Path] = {}
+    for tree in trees:
+        for recipe in tree.rglob("recipe"):
+            rec = parse_recipe(recipe)
+            if rec["name"]:
+                names[rec["name"]] = recipe
+    return names
+
+
+def rewrite_recipe(rec: dict, new_ver: str, new_urls: list[str], new_hashes: list[str],
+                   extra_depend: list[str], extra_builddep: list[str]) -> str:
+    old_ver = rec["version"]
+    lines = rec["raw"].splitlines()
+    out: list[str] = []
+    hash_i = 0
+    source_i = 0
+    seen_depend = False
+    inserted_deps = False
+    have_depend = set(rec["depend"])
+    have_build = set(rec["builddep"])
+
+    def flush_new_deps(dest: list[str]) -> None:
+        nonlocal inserted_deps
+        if inserted_deps:
+            return
+        for name in extra_depend:
+            if name not in have_depend:
+                dest.append(f"depend      {name}")
+                have_depend.add(name)
+        for name in extra_builddep:
+            if name not in have_build:
+                dest.append(f"builddep    {name}")
+                have_build.add(name)
+        inserted_deps = True
+
+    for line in lines:
+        if line.startswith("version"):
+            out.append(f"version     {new_ver}")
+            continue
+        if line.startswith("release"):
+            out.append("release     1")
+            continue
+        if line.startswith("source"):
+            if source_i < len(new_urls):
+                out.append(f"source      {new_urls[source_i]}")
+                source_i += 1
+            else:
+                out.append(rewrite_url(line, old_ver, new_ver) if old_ver else line)
+            continue
+        if line.startswith("hash"):
+            if hash_i < len(new_hashes):
+                out.append(f"hash        sha256:{new_hashes[hash_i]}")
+                hash_i += 1
+            else:
+                out.append(line)
+            continue
+        if line.startswith("depend"):
+            seen_depend = True
+            out.append(line)
+            continue
+        if seen_depend and not line.startswith(("depend", "builddep", "optional", "conflict")):
+            flush_new_deps(out)
+            seen_depend = False
+        if old_ver and old_ver in line and line.startswith(("prepare", "configure", "build", "install")):
+            out.append(line.replace(old_ver, new_ver))
+            continue
+        out.append(line)
+    flush_new_deps(out)
+    text = "\n".join(out)
+    if not text.endswith("\n"):
+        text += "\n"
+    return text
+
+
+def scaffold_recipe(tree: Path, name: str, version: str, url: str, digest: str,
+                    depend: list[str]) -> Path:
+    category = "libs"
+    dest = tree / category / name
+    dest.mkdir(parents=True, exist_ok=True)
+    deps = "\n".join(f"depend      {d}" for d in (["filesystem"] + depend))
+    body = (
+        f"name        {name}\n"
+        f"version     {version}\n"
+        f"release     1\n"
+        f"description {name}: auto-scaffolded runtime dependency\n"
+        f"\n"
+        f"source      {url}\n"
+        f"hash        sha256:{digest}\n"
+        f"\n"
+        f"{deps}\n"
+        f"\n"
+        f"configure   ./configure --prefix=/usr --libdir=/usr/lib64 --disable-static\n"
+        f"build       make -j\"$MAKEJOBS\"\n"
+        f"install     make install DESTDIR=\"$PKG\"\n"
+    )
+    recipe = dest / "recipe"
+    recipe.write_text(body, encoding="utf-8")
+    return recipe
+
+
+def guess_scaffold_url(name: str, version: str) -> str | None:
+    # Common GitHub-style; last resort only.
+    try:
+        body = http_get(
+            f"{GITHUB_API}/search/repositories?q={name}+in:name&per_page=3",
+            accept="application/vnd.github+json",
+        )
+        data = json.loads(body)
+        items = data.get("items") or []
+        for item in items:
+            if item.get("name", "").lower() != name.lower():
+                continue
+            full = item.get("full_name")
+            if not full:
+                continue
+            return f"https://github.com/{full}/archive/refs/tags/v{version}.tar.gz"
+    except (HttpError, json.JSONDecodeError):
+        return None
+    return None
+
+
+def pin_group_key(name: str) -> int | None:
+    for i, rx in enumerate(PIN_GROUPS):
+        if rx.search(name):
+            return i
+    return None
+
+
+class PlanItem:
+    def __init__(self, rec: dict):
+        self.rec = rec
+        self.name = rec["name"]
+        self.old = rec["version"]
+        self.new = rec["version"]
+        self.urls: list[str] = list(rec["source"])
+        self.hashes: list[str] = []
+        self.add_depend: list[str] = []
+        self.add_builddep: list[str] = []
+        self.missing: list[str] = []
+        self.status = "current"
+        self.detail = ""
+        self.group: int | None = pin_group_key(self.name)
+
+
+def collect_recipes(trees: list[Path], only: set[str] | None) -> list[dict]:
+    recs = []
+    for tree in trees:
+        for recipe in sorted(tree.rglob("recipe")):
+            rec = parse_recipe(recipe)
+            if not rec["name"]:
+                continue
+            if only and rec["name"] not in only:
+                continue
+            recs.append(rec)
+    return recs
+
+
+def probe_one(rec: dict, include_kernel: bool) -> PlanItem:
+    item = PlanItem(rec)
+    name = rec["name"]
+    if name in NO_AUTO and name not in {"qt6"}:
+        item.status = "skip"
+        item.detail = "metapackage or distro glue"
+        return item
+    if not include_kernel and name in {"linux", "linux-desktop", "linux-firmware"}:
+        item.status = "skip"
+        item.detail = "kernel/firmware (pass --include-kernel)"
+        return item
+    if not recipe_has_source(rec):
+        item.status = "skip"
+        item.detail = "no upstream source"
+        return item
+    try:
+        latest = detect_latest(rec)
+    except HttpError as exc:
+        item.status = "error"
+        item.detail = str(exc)
+        return item
+    if not latest:
+        item.status = "unknown"
+        item.detail = "could not detect a newer stable"
+        return item
+    if not version_newer(latest, rec["version"]):
+        item.status = "current"
+        item.new = rec["version"]
+        return item
+    item.new = latest
+    item.status = "update"
+    item.urls = [rewrite_url(u, rec["version"], latest) for u in rec["source"]]
+    return item
+
+
+def apply_pin_groups(items: list[PlanItem]) -> None:
+    by_group: dict[int, list[PlanItem]] = {}
+    for item in items:
+        if item.group is None:
+            continue
+        by_group.setdefault(item.group, []).append(item)
+    for members in by_group.values():
+        best = None
+        for item in members:
+            if item.status in {"update", "current"}:
+                if best is None or version_newer(item.new, best):
+                    best = item.new
+        if not best:
+            continue
+        for item in members:
+            if item.status in {"skip", "error"}:
+                continue
+            if item.old != best:
+                item.new = best
+                item.status = "update"
+                item.urls = [rewrite_url(u, item.old, best) for u in item.rec["source"]]
+                item.detail = "pinned with sibling modules"
+
+
+def inspect_new_deps(item: PlanItem, known: dict[str, Path]) -> None:
+    if item.status != "update" or not item.urls:
+        return
+    url = item.urls[0]
+    try:
+        archive = download(url)
+        item.hashes = [sha256_file(archive)]
+        for extra in item.urls[1:]:
+            item.hashes.append(sha256_file(download(extra)))
+    except (HttpError, OSError) as exc:
+        item.status = "error"
+        item.detail = f"download failed: {exc}"
+        return
+    runtime, _build = set(), set()
+    try:
+        with tempfile.TemporaryDirectory(prefix="sps-upg-") as tmp:
+            root = extract_archive(archive, Path(tmp) / "src")
+            runtime, _build = scan_build_files(root)
+    except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
+        item.detail = (item.detail + f" (scan skipped: {exc})").strip()
+        return
+    have = set()
+    for spec in item.rec["depend"] + item.rec["builddep"]:
+        have.add(re.split(r"[<>=]", spec, 1)[0])
+    for raw in sorted(runtime):
+        mapped = map_dep(raw, known)
+        if mapped is None:
+            if raw.lower() in SKIP_DEPS or raw in HOST_TOOLS:
+                continue
+            item.missing.append(raw)
+            continue
+        if mapped in have or mapped == item.name or mapped in HOST_TOOLS:
+            continue
+        if mapped not in known:
+            item.missing.append(mapped)
+            continue
+        item.add_depend.append(mapped)
+        have.add(mapped)
+
+
+def write_item(item: PlanItem) -> None:
+    text = rewrite_recipe(
+        item.rec, item.new, item.urls, item.hashes,
+        item.add_depend, item.add_builddep,
+    )
+    item.rec["path"].write_text(text, encoding="utf-8")
+
+
+def format_plan(items: list[PlanItem]) -> str:
+    lines = []
+    counts = {}
+    for item in items:
+        counts[item.status] = counts.get(item.status, 0) + 1
+        if item.status == "current":
+            continue
+        extra = ""
+        if item.add_depend:
+            extra += f"  +depend {', '.join(item.add_depend)}"
+        if item.missing:
+            extra += f"  missing {', '.join(item.missing)}"
+        if item.detail:
+            extra += f"  ({item.detail})"
+        if item.status == "update":
+            lines.append(f"UPDATE  {item.name:28} {item.old} -> {item.new}{extra}")
+        elif item.status == "skip":
+            lines.append(f"SKIP    {item.name:28} {item.detail}")
+        elif item.status == "unknown":
+            lines.append(f"UNKNOWN {item.name:28} {item.old} {item.detail}")
+        else:
+            lines.append(f"ERROR   {item.name:28} {item.detail}")
+    summary = " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
+    lines.append(f"summary {summary}")
+    return "\n".join(lines) + "\n"
+
+
+def selftest() -> int:
+    assert version_compare("1.2.3", "1.2.3") == 0
+    assert version_compare("1.2.10", "1.2.9") > 0
+    assert version_compare("2.0", "1.9.9") > 0
+    assert version_compare("1.0~rc1", "1.0") < 0
+    assert is_prerelease("1.2.3-rc1")
+    assert not is_prerelease("1.2.3")
+    assert gnome_stable("gtk", "4.22.4")
+    assert not gnome_stable("gtk", "4.23.1")
+    url = rewrite_url(
+        "https://download.gnome.org/sources/gtk/4.18/gtk-4.18.6.tar.xz",
+        "4.18.6", "4.22.4",
+    )
+    assert "4.22.4" in url and "4.18.6" not in url
+    # series directory also rewritten
+    assert "/4.22/" in url or "4.22.4" in url
+    raw = map_dep("libssl", {"openssl": Path(".")})
+    assert raw == "openssl"
+    meson = "dependency('libpng', required: true)\ndependency('threads')\n"
+    with tempfile.TemporaryDirectory() as tmp:
+        p = Path(tmp)
+        (p / "meson.build").write_text(meson, encoding="utf-8")
+        runtime, _ = scan_build_files(p)
+        assert "libpng" in runtime
+        assert "threads" in runtime
+    rec_text = (
+        "name        demo\n"
+        "version     1.0\n"
+        "release     3\n"
+        "description demo: test\n"
+        "\n"
+        "source      https://example.org/demo-1.0.tar.gz\n"
+        "hash        sha256:" + ("a" * 64) + "\n"
+        "\n"
+        "depend      filesystem\n"
+        "install     make install DESTDIR=\"$PKG\"\n"
+    )
+    with tempfile.TemporaryDirectory() as tmp:
+        recipe = Path(tmp) / "recipe"
+        recipe.write_text(rec_text, encoding="utf-8")
+        rec = parse_recipe(recipe)
+        assert rec["name"] == "demo"
+        new = rewrite_recipe(rec, "1.1", ["https://example.org/demo-1.1.tar.gz"],
+                             ["b" * 64], ["zlib"], [])
+        assert "version     1.1" in new
+        assert "release     1" in new
+        assert "depend      zlib" in new
+        assert "demo-1.1.tar.gz" in new
+    print("selftest ok")
+    return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+    ap = argparse.ArgumentParser(
+        description="Upgrade SPS recipes to the newest upstream stable release.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=(
+            "Examples:\n"
+            "  tools/upgrade-recipes --trees /usr/src/sps/core:/usr/src/sps/extra\n"
+            "  tools/upgrade-recipes --trees $PWD/../sps-core:$PWD/../sps-extra --apply\n"
+            "  tools/upgrade-recipes --trees extra --only gtk4,qemu,llvm --apply\n"
+        ),
+    )
+    ap.add_argument("--trees", default="",
+                    help="colon-separated recipe trees (core:extra)")
+    ap.add_argument("--apply", action="store_true",
+                    help="write recipe changes (default is a plan only)")
+    ap.add_argument("--scan-deps", action="store_true",
+                    help="download new tarballs and scan for undeclared dependencies "
+                         "(implied by --apply)")
+    ap.add_argument("--no-scan-deps", action="store_true",
+                    help="do not download tarballs or scan dependencies")
+    ap.add_argument("--only", default="",
+                    help="comma-separated package names")
+    ap.add_argument("--jobs", type=int, default=8,
+                    help="parallel upstream probes (default 8)")
+    ap.add_argument("--include-kernel", action="store_true",
+                    help="allow linux and firmware recipes")
+    ap.add_argument("--scaffold-missing", action="store_true",
+                    help="draft extra recipes for newly required unpackaged deps")
+    ap.add_argument("--json", action="store_true",
+                    help="print machine-readable plan")
+    ap.add_argument("--selftest", action="store_true",
+                    help="run offline unit checks and exit")
+    args = ap.parse_args(argv)
+
+    if args.selftest:
+        return selftest()
+
+    trees = [Path(p) for p in args.trees.split(":") if p]
+    if not trees:
+        env = os.environ.get("SPS_RECIPE_TREES", "")
+        trees = [Path(p) for p in env.split(":") if p]
+    if not trees:
+        ap.error("pass --trees CORE:EXTRA (or set SPS_RECIPE_TREES)")
+
+    for tree in trees:
+        if not tree.is_dir():
+            ap.error(f"recipe tree is not a directory: {tree}")
+
+    only = {x.strip() for x in args.only.split(",") if x.strip()} or None
+    recs = collect_recipes(trees, only)
+    if not recs:
+        log("no recipes found")
+        return 1
+
+    log(f"scanning {len(recs)} recipes in {len(trees)} tree(s)")
+    items: list[PlanItem] = []
+    with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
+        futs = {pool.submit(probe_one, rec, args.include_kernel): rec for rec in recs}
+        for fut in as_completed(futs):
+            item = fut.result()
+            items.append(item)
+            if item.status == "update":
+                log(f"plan  {item.name} {item.old} -> {item.new}")
+    items.sort(key=lambda i: i.name)
+    apply_pin_groups(items)
+
+    known = load_tree_names(trees)
+    extra_tree = trees[-1]
+    updates = [i for i in items if i.status == "update"]
+    do_scan = (args.apply or args.scan_deps) and not args.no_scan_deps
+    if do_scan:
+        for item in updates:
+            log(f"fetch {item.name} {item.new}")
+            inspect_new_deps(item, known)
+            for miss in list(item.missing):
+                mapped = map_dep(miss, known) or miss
+                if mapped in known:
+                    if mapped not in item.add_depend:
+                        item.add_depend.append(mapped)
+                    item.missing.remove(miss)
+                    continue
+                if not args.scaffold_missing:
+                    continue
+                latest = latest_repology(mapped) or item.new
+                url = guess_scaffold_url(mapped, latest)
+                if not url:
+                    continue
+                try:
+                    archive = download(url)
+                    digest = sha256_file(archive)
+                except (HttpError, OSError):
+                    continue
+                path = scaffold_recipe(extra_tree, mapped, latest, url, digest, [])
+                known[mapped] = path
+                item.add_depend.append(mapped)
+                item.missing.remove(miss)
+                log(f"scaffold {mapped} -> {path}")
+    elif args.apply:
+        for item in updates:
+            log(f"fetch {item.name} {item.new}")
+            if not item.urls:
+                item.status = "error"
+                item.detail = "no source URL"
+                continue
+            try:
+                item.hashes = [sha256_file(download(u)) for u in item.urls]
+            except (HttpError, OSError) as exc:
+                item.status = "error"
+                item.detail = f"download failed: {exc}"
+
+    if args.json:
+        payload = []
+        for item in items:
+            payload.append({
+                "name": item.name,
+                "status": item.status,
+                "old": item.old,
+                "new": item.new,
+                "urls": item.urls,
+                "add_depend": item.add_depend,
+                "missing": item.missing,
+                "detail": item.detail,
+            })
+        print(json.dumps(payload, indent=2))
+    else:
+        sys.stdout.write(format_plan(items))
+
+    if not args.apply:
+        return 0 if not any(i.status == "error" for i in items) else 2
+
+    written = 0
+    for item in items:
+        if item.status != "update":
+            continue
+        if not item.hashes:
+            log(f"skip write {item.name}: no hash")
+            continue
+        write_item(item)
+        written += 1
+        log(f"wrote {item.rec['path']}")
+    log(f"applied {written} recipe update(s)")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        raise SystemExit(main())
+    except KeyboardInterrupt:
+        raise SystemExit(130)
diff --git a/tools/write-stock-services.py b/tools/write-stock-services.py
new file mode 100755
index 0000000..6535c4b
--- /dev/null
+++ b/tools/write-stock-services.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+"""Write stock init scripts for every supported init, and ship systemd plus
+OpenRC copies next to extra recipes that provide the daemon.
+
+Supported inits: systemd, openrc, s6, runit, dinit, shepherd.
+"""
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+SPS = Path(__file__).resolve().parents[1]
+SV = SPS / "lib" / "setup" / "sv"
+MAP = SPS / "lib" / "setup" / "services"
+
+# package, rc, unit, wanted, runlevel, description, argv, flags
+# flags: dbus, user, pre (list of shell lines for supervised run scripts)
+SERVICES = [
+    ("fail2ban", "fail2ban", "fail2ban.service", "multi-user.target", "default",
+     "fail2ban authentication ban daemon",
+     ["/usr/bin/fail2ban-server", "-xf", "--logtarget=stdout"], {}),
+    ("unbound", "unbound", "unbound.service", "multi-user.target", "default",
+     "validating recursive DNS resolver",
+     ["/usr/sbin/unbound", "-d"], {}),
+    ("dnsmasq", "dnsmasq", "dnsmasq.service", "multi-user.target", "default",
+     "DNS and DHCP server",
+     ["/usr/sbin/dnsmasq", "-k"], {}),
+    ("openvpn", "openvpn", "openvpn.service", "multi-user.target", "default",
+     "OpenVPN server",
+     ["/usr/sbin/openvpn", "--config", "/etc/openvpn/server.conf"], {}),
+    ("tor", "tor", "tor.service", "multi-user.target", "default",
+     "Tor anonymity daemon",
+     ["/usr/bin/tor", "--RunAsDaemon", "0"], {}),
+    ("dropbear", "dropbear", "dropbear.service", "multi-user.target", "default",
+     "Dropbear SSH server",
+     ["/usr/sbin/dropbear", "-F", "-E"],
+     {"pre": ["if command -v dropbearkey >/dev/null 2>&1; then",
+              "  mkdir -p /etc/dropbear",
+              "  [ -f /etc/dropbear/dropbear_rsa_host_key ] || dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key >/dev/null",
+              "fi"]}),
+    ("tinyproxy", "tinyproxy", "tinyproxy.service", "multi-user.target", "default",
+     "Tinyproxy HTTP proxy",
+     ["/usr/bin/tinyproxy", "-d"], {}),
+    ("haproxy", "haproxy", "haproxy.service", "multi-user.target", "default",
+     "HAProxy load balancer",
+     ["/usr/sbin/haproxy", "-f", "/etc/haproxy/haproxy.cfg", "-db"], {}),
+    ("lighttpd", "lighttpd", "lighttpd.service", "multi-user.target", "default",
+     "lighttpd web server",
+     ["/usr/sbin/lighttpd", "-D", "-f", "/etc/lighttpd/lighttpd.conf"], {}),
+    ("avahi", "avahi-daemon", "avahi-daemon.service", "multi-user.target", "default",
+     "Avahi mDNS/DNS-SD daemon",
+     ["/usr/sbin/avahi-daemon", "--no-chroot"],
+     {"dbus": True}),
+    ("nfs-utils", "nfs-server", "nfs-server.service", "multi-user.target", "default",
+     "NFS server",
+     ["/usr/sbin/rpc.mountd", "-F"],
+     {"pre": ["if command -v exportfs >/dev/null 2>&1; then exportfs -ra || :; fi",
+              "if command -v rpc.statd >/dev/null 2>&1; then rpc.statd --no-notify || :; fi",
+              "if command -v rpc.nfsd >/dev/null 2>&1; then rpc.nfsd 8 || :; fi"]}),
+    ("libvirt", "libvirtd", "libvirtd.service", "multi-user.target", "default",
+     "libvirt virtualization daemon",
+     ["/usr/sbin/libvirtd"],
+     {"dbus": True}),
+    ("postgresql", "postgresql", "postgresql.service", "multi-user.target", "default",
+     "PostgreSQL database server",
+     ["/usr/bin/postgres", "-D", "/var/lib/postgres/data"],
+     {"user": "postgres",
+      "pre": ["install -d -m 0755 /run/postgresql /var/lib/postgres/data || :"]}),
+    ("mariadb", "mariadbd", "mariadb.service", "multi-user.target", "default",
+     "MariaDB database server",
+     ["/usr/sbin/mariadbd", "--datadir=/var/lib/mysql"],
+     {"user": "mysql",
+      "pre": ["install -d -m 0750 /run/mysqld /var/lib/mysql || :"]}),
+    ("memcached", "memcached", "memcached.service", "multi-user.target", "default",
+     "memcached object cache",
+     ["/usr/bin/memcached", "-u", "nobody"], {}),
+    ("valkey", "valkey", "valkey.service", "multi-user.target", "default",
+     "Valkey data store",
+     ["/usr/bin/valkey-server", "--daemonize", "no", "--bind", "127.0.0.1"], {}),
+    ("conntrack-tools", "conntrackd", "conntrackd.service", "multi-user.target", "default",
+     "connection tracking daemon",
+     ["/usr/sbin/conntrackd", "-C", "/etc/conntrackd/conntrackd.conf"], {}),
+    ("usbmuxd", "usbmuxd", "usbmuxd.service", "multi-user.target", "default",
+     "iOS USB multiplexing daemon",
+     ["/usr/sbin/usbmuxd", "-f"], {}),
+    ("distcc", "distccd", "distccd.service", "multi-user.target", "default",
+     "distributed C/C++ compilation daemon",
+     ["/usr/bin/distccd", "--no-detach", "--allow", "127.0.0.1", "--log-stderr"], {}),
+    ("lxc", "lxc-net", "lxc-net.service", "multi-user.target", "default",
+     "LXC bridge networking",
+     ["/usr/libexec/lxc/lxc-net", "start"],
+     {"oneshot": True}),
+]
+
+ALIASES = {
+    "nfs-server": "nfs-utils",
+    "libvirtd": "libvirt",
+    "conntrackd": "conntrack-tools",
+    "avahi-daemon": "avahi",
+    "mariadbd": "mariadb",
+    "distccd": "distcc",
+    "lxc-net": "lxc",
+}
+
+
+def sh_join(argv: list[str]) -> str:
+    return " ".join(argv)
+
+
+def scm_list(argv: list[str]) -> str:
+    return " ".join(f'"{a}"' for a in argv)
+
+
+def systemd_unit(svc: dict) -> str:
+    after = ["network.target"]
+    if svc["flags"].get("dbus"):
+        after.append("dbus.service")
+    lines = [
+        "[Unit]",
+        f"Description={svc['desc']}",
+        f"After={' '.join(after)}",
+        "",
+        "[Service]",
+        f"ExecStart={sh_join(svc['argv'])}",
+    ]
+    if svc["flags"].get("oneshot"):
+        lines.append("Type=oneshot")
+        lines.append("RemainAfterExit=yes")
+    else:
+        lines.append("Restart=on-failure")
+    user = svc["flags"].get("user")
+    if user:
+        lines.append(f"User={user}")
+    lines += ["", "[Install]", f"WantedBy={svc['wanted']}", ""]
+    return "\n".join(lines)
+
+
+def openrc_script(svc: dict) -> str:
+    depend = ["    use net", "    after net"]
+    if svc["flags"].get("dbus"):
+        depend = ["    need dbus", "    after dbus"]
+    pre = svc["flags"].get("pre") or []
+    user = svc["flags"].get("user")
+    body = [
+        "#!/sbin/openrc-run",
+        f'description="{svc["desc"]}"',
+        f'command="{svc["argv"][0]}"',
+        f'command_args="{" ".join(svc["argv"][1:])}"',
+    ]
+    if svc["flags"].get("oneshot"):
+        body.append('command_background="no"')
+    else:
+        body.extend([
+            'command_background="yes"',
+            f'pidfile="/run/{svc["rc"]}.pid"',
+        ])
+    if user:
+        body.append(f'command_user="{user}"')
+    body.append("")
+    body.append("depend() {")
+    body.extend(depend)
+    body.append("}")
+    if pre:
+        body.append("")
+        body.append("start_pre() {")
+        body.extend(f"\t{line}" for line in pre)
+        body.append("}")
+    body.append("")
+    return "\n".join(body)
+
+
+def supervised_run(svc: dict) -> str:
+    lines = ["#!/bin/sh", "exec 2>&1"]
+    for line in svc["flags"].get("pre") or []:
+        lines.append(line)
+    lines.append(f"exec {sh_join(svc['argv'])}")
+    lines.append("")
+    return "\n".join(lines)
+
+
+def dinit_file(svc: dict) -> str:
+    lines = [
+        "type = process",
+        f"command = {sh_join(svc['argv'])}",
+        "restart = true",
+        "smooth-recovery = true",
+    ]
+    if svc["flags"].get("dbus"):
+        lines.append("depends-on = dbus")
+    lines.append("")
+    return "\n".join(lines)
+
+
+def shepherd_file(svc: dict) -> str:
+    req = "'(dbus)" if svc["flags"].get("dbus") else "'()"
+    return (
+        "(use-modules (shepherd service))\n"
+        "(register-services\n"
+        " (list (service\n"
+        f"        '({svc['rc']})\n"
+        f"        #:requirement {req}\n"
+        f"        #:documentation \"{svc['desc']}\"\n"
+        f"        #:start (make-forkexec-constructor '({scm_list(svc['argv'])}))\n"
+        "        #:stop (make-kill-destructor)\n"
+        "        #:respawn? #t)))\n"
+    )
+
+
+def write_file(path: Path, text: str, mode: int = 0o644) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(text, encoding="utf-8")
+    path.chmod(mode)
+
+
+def parse_services() -> list[dict]:
+    out = []
+    for pkg, rc, unit, wanted, level, desc, argv, flags in SERVICES:
+        out.append({
+            "pkg": pkg, "rc": rc, "unit": unit, "wanted": wanted,
+            "level": level, "desc": desc, "argv": argv, "flags": flags,
+        })
+    return out
+
+
+def write_stock(services: list[dict]) -> None:
+    for svc in services:
+        write_file(SV / "systemd" / svc["unit"], systemd_unit(svc), 0o644)
+        write_file(SV / "openrc" / svc["rc"], openrc_script(svc), 0o755)
+        write_file(SV / "runit" / svc["rc"] / "run", supervised_run(svc), 0o755)
+        write_file(SV / "s6" / svc["rc"] / "run", supervised_run(svc), 0o755)
+        write_file(SV / "dinit" / svc["rc"], dinit_file(svc), 0o644)
+        write_file(SV / "shepherd" / f"{svc['rc']}.scm", shepherd_file(svc), 0o644)
+
+
+def update_map(services: list[dict]) -> None:
+    existing = MAP.read_text(encoding="utf-8")
+    lines = existing.rstrip() + "\n"
+    have = set()
+    for raw in existing.splitlines():
+        if not raw.strip() or raw.lstrip().startswith("#"):
+            continue
+        have.add(raw.split()[0])
+    added = []
+    for svc in services:
+        row = f"{svc['pkg']}\t{svc['unit']}\t{svc['wanted']}\t{svc['rc']}\t{svc['level']}\n"
+        if svc["pkg"] not in have:
+            lines += row
+            have.add(svc["pkg"])
+            added.append(svc["pkg"])
+        for alias, pkg in ALIASES.items():
+            if pkg == svc["pkg"] and alias not in have:
+                lines += (
+                    f"{alias}\t{svc['unit']}\t{svc['wanted']}\t"
+                    f"{svc['rc']}\t{svc['level']}\n"
+                )
+                have.add(alias)
+                added.append(alias)
+    MAP.write_text(lines, encoding="utf-8")
+    return added
+
+
+def find_recipe(tree: Path, name: str) -> Path | None:
+    for recipe in tree.rglob("recipe"):
+        try:
+            for line in recipe.read_text(encoding="utf-8").splitlines():
+                if line.startswith("name"):
+                    rec_name = line.split(None, 1)[1].strip()
+                    if rec_name == name:
+                        return recipe
+                    break
+        except OSError:
+            continue
+    return None
+
+
+def bump_release(text: str) -> str:
+    out = []
+    done = False
+    for line in text.splitlines(keepends=True):
+        if not done and line.startswith("release"):
+            parts = line.split(None, 1)
+            try:
+                rel = int(parts[1].strip())
+            except (IndexError, ValueError):
+                rel = 1
+            line = f"release     {rel + 1}\n"
+            done = True
+        out.append(line)
+    return "".join(out)
+
+
+def install_recipe_files(tree: Path, services: list[dict]) -> list[str]:
+    touched = []
+    for svc in services:
+        recipe = find_recipe(tree, svc["pkg"])
+        if recipe is None:
+            continue
+        files = recipe.parent / "files"
+        write_file(files / svc["unit"], systemd_unit(svc), 0o644)
+        write_file(files / f"openrc-{svc['rc']}", openrc_script(svc), 0o755)
+        text = recipe.read_text(encoding="utf-8")
+        marker = f"$SRC/files/{svc['unit']}"
+        if marker not in text:
+            text = bump_release(text)
+            if not text.endswith("\n"):
+                text += "\n"
+            text += (
+                'install     mkdir -p "$PKG/usr/lib/systemd/system" "$PKG/etc/init.d"\n'
+                f'install     install -m 0644 "$SRC/files/{svc["unit"]}" '
+                f'"$PKG/usr/lib/systemd/system/{svc["unit"]}"\n'
+                f'install     install -m 0755 "$SRC/files/openrc-{svc["rc"]}" '
+                f'"$PKG/etc/init.d/{svc["rc"]}"\n'
+            )
+            recipe.write_text(text, encoding="utf-8")
+        touched.append(str(recipe))
+    return touched
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description=__doc__)
+    ap.add_argument("--extra", default="/tmp/sps-extra",
+                    help="extra recipe tree (default: /tmp/sps-extra)")
+    ap.add_argument("--core", default="", help="optional core recipe tree")
+    args = ap.parse_args()
+    services = parse_services()
+    write_stock(services)
+    added = update_map(services)
+    extra = Path(args.extra)
+    touched = []
+    if extra.is_dir():
+        touched.extend(install_recipe_files(extra, services))
+    if args.core:
+        core = Path(args.core)
+        if core.is_dir():
+            touched.extend(install_recipe_files(core, services))
+    print(f"stock services: {len(services)}")
+    print(f"map added: {', '.join(added) if added else '(none)'}")
+    print(f"recipes updated: {len(touched)}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())