Splux Linux

source-based Linux using SPS

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

fd201b3


setup: add a dialog installer and mkiso live TTY images

Date
Commitfd201b3e8257e03bd8d065af1f253c3c416b0cf7
GitHubmirror

Parents

Stat

 MANIFESTO.md                              |    2 +
 Makefile                                  |   23 +-
 README.md                                 |   52 ++
 bin/mkiso                                 |  569 ++++++++++++++++
 bin/setup                                 | 1014 +++++++++++++++++++++++++++++
 lib/mkiso/initrd-init                     |   75 +++
 lib/mkiso/issue                           |    3 +
 lib/mkiso/live-init                       |   72 ++
 lib/mkiso/motd                            |   20 +
 lib/mkiso/seed-bins                       |  127 ++++
 lib/setup/extras                          |   72 ++
 lib/setup/keymaps                         |   21 +
 lib/setup/locales                         |   32 +
 lib/setup/profiles/minimal.profile        |    5 +
 lib/setup/profiles/plasma-desktop.profile |   14 +
 lib/setup/profiles/plasma-full.profile    |   17 +
 lib/setup/profiles/server.profile         |    9 +
 lib/setup/sets/bluetooth.set              |    5 +
 lib/setup/sets/browsers.set               |    5 +
 lib/setup/sets/containers.set             |    7 +
 lib/setup/sets/devel-tools.set            |    5 +
 lib/setup/sets/editors-extra.set          |    7 +
 lib/setup/sets/firmware.set               |    5 +
 lib/setup/sets/flatpak.set                |    9 +
 lib/setup/sets/fonts-extra.set            |    8 +
 lib/setup/sets/kde-apps.set               |    5 +
 lib/setup/sets/languages.set              |    5 +
 lib/setup/sets/multimedia.set             |   13 +
 lib/setup/sets/network-extra.set          |   12 +
 lib/setup/sets/nvidia.set                 |    7 +
 lib/setup/sets/office.set                 |    8 +
 lib/setup/sets/power.set                  |    8 +
 lib/setup/sets/printing.set               |    7 +
 lib/setup/sets/qol-cli.set                |    5 +
 lib/setup/sets/shells-extra.set           |    6 +
 lib/setup/sets/ssh.set                    |    5 +
 lib/setup/sets/storage.set                |   16 +
 lib/setup/sets/wifi.set                   |    7 +
 lib/setup/shells                          |    4 +
 lib/setup/timezones                       |   28 +
 man/mkiso.1                               |   79 +++
 man/setup.1                               |  173 +++++
 tests/setup-fixture/extras                |    1 +
 tests/setup-fixture/keymaps               |    2 +
 tests/setup-fixture/locales               |    2 +
 tests/setup-fixture/profiles/tiny.profile |    4 +
 tests/setup-fixture/sets/extra.set        |    5 +
 tests/setup-fixture/shells                |    2 +
 tests/setup-fixture/timezones             |    2 +
 tests/test_mkiso.sh                       |   52 ++
 tests/test_setup.sh                       |  133 ++++
 51 files changed, 2768 insertions(+), 1 deletion(-)

Diff

diff --git a/MANIFESTO.md b/MANIFESTO.md
index 73a28c8..23e1cd7 100644
--- a/MANIFESTO.md
+++ b/MANIFESTO.md
@@ -34,6 +34,8 @@ pkdel     low-level package removal
 pkstat    installed package queries
 pkcheck   installed-state verification
 pkmark    explicit/dependency package marking
+setup     dialog-based system installer
+mkiso     live TTY ISO builder
 ```
 
 Each tool has a boundary.
diff --git a/Makefile b/Makefile
index 547f49b..58d79f1 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ MANDIR = $(PREFIX)/share/man
 SYSCONFDIR = /etc/sps
 DESTDIR =
 
-PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark
+PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark setup mkiso
 AWK_LIBS = common.awk config.awk db.awk deps.awk index.awk package.awk recipe.awk repository.awk relations.awk version.awk
 
 .PHONY: all check lint check-version source-archive release-check \
@@ -148,6 +148,27 @@ install:
 	@set -e; for page in man/*.5; do \
 		install -m 0644 $$page "$(DESTDIR)$(MANDIR)/man5/$${page##*/}"; \
 	done
+	install -d "$(DESTDIR)$(LIBDIR)/setup/profiles" \
+		"$(DESTDIR)$(LIBDIR)/setup/sets"
+	install -m 0644 lib/setup/keymaps lib/setup/timezones \
+		lib/setup/locales lib/setup/shells lib/setup/extras \
+		"$(DESTDIR)$(LIBDIR)/setup/"
+	@set -e; for f in lib/setup/profiles/*.profile; do \
+		install -m 0644 $$f "$(DESTDIR)$(LIBDIR)/setup/profiles/"; \
+	done
+	@set -e; for f in lib/setup/sets/*.set; do \
+		install -m 0644 $$f "$(DESTDIR)$(LIBDIR)/setup/sets/"; \
+	done
+	install -d "$(DESTDIR)$(LIBDIR)/mkiso"
+	@set -e; for f in lib/mkiso/*; do \
+		[ -f "$$f" ] || continue; \
+		case $$f in \
+			*/live-init|*/initrd-init) \
+				install -m 0755 $$f "$(DESTDIR)$(LIBDIR)/mkiso/" ;; \
+			*) \
+				install -m 0644 $$f "$(DESTDIR)$(LIBDIR)/mkiso/" ;; \
+		esac; \
+	done
 
 # Configuration is separate so installation never overwrites administrator
 # policy. Existing files are deliberately left untouched.
diff --git a/README.md b/README.md
index b94517b..a07c721 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@ SPS is a set of tools rather than one large front end:
 - `pkstat` reads the installed package database.
 - `pkcheck` checks installed files and database consistency.
 - `pkmark` changes whether an installed package is explicit or dependency-only.
+- `setup` is a dialog(1) system installer (Slackware/FreeBSD style).
+- `mkiso` builds a live TTY ISO you can flash and boot into `setup`.
 
 The split matters. `pkin` does not search repositories. `pkdel` does not solve
 dependencies. `src` does not install anything. `sget` ties the lower-level tools
@@ -136,6 +138,56 @@ pkdel foo
 This is useful for recovery work, distro installers, package development, and
 cases where the administrator wants to bypass dependency policy on purpose.
 
+## Install a system
+
+`setup` is the distro installer. It uses `dialog` screens, or the same choices
+from flags for scripts:
+
+```sh
+setup
+setup --plan --profile plasma-desktop --target /mnt --user alex
+setup --non-interactive --profile minimal --target /mnt --hostname darkstar
+```
+
+Profiles are `minimal`, `server`, `plasma-desktop`, and `plasma-full`. Optional
+sets cover CLI tools, development, languages, KDE apps, NVIDIA, Flatpak, power
+management, multimedia, printing, browsers, extra shells, fonts, office tools,
+SSH, and more. `setup --list-sets` prints them. There is also an extra-package
+checklist (`--extra`) plus locale and login-shell screens.
+
+The installer writes hostname, timezone, keymap, and a first user into the
+target, then runs `src update` and `sget install`. It does not partition disks
+and does not run `grub-install` unless you pass `--install-bootloader`, which
+still refuses to guess a disk.
+
+## Live TTY ISO
+
+`mkiso` writes a hybrid ISO. Flash it to USB, boot to a console, mount a
+destination filesystem on `/mnt`, and run `setup`. A GitHub release attached
+to this repository is the downloadable image:
+
+```sh
+mkiso --output dist/sps-live.iso \
+  --busybox /path/to/busybox \
+  --core /usr/src/sps/core \
+  --extra /usr/src/sps/extra
+```
+
+`--layout DIR` writes the live root without making an ISO. `--no-seed` skips
+copying the host compiler. `--with-firmware` copies `/lib/firmware` (large;
+published images omit it). The live image never runs `grub-install` on a disk.
+
+Download a ready-made hybrid ISO from this repository's GitHub Releases, flash
+it to USB, boot the "SPS live TTY" entry, mount a destination filesystem on
+`/mnt`, and run `setup`. The image includes the installer, sps-core and
+sps-extra recipes, and a host seed compiler so `setup` can build packages.
+A full Plasma install compiles Qt and KDE from source and takes a long time.
+
+```sh
+# flash (example)
+dd if=sps-live-tty.iso of=/dev/sdX bs=4M status=progress conv=fsync
+```
+
 ## Alternate roots
 
 `SPS_ROOT` points SPS at another filesystem tree. Database, cache, and build
diff --git a/bin/mkiso b/bin/mkiso
new file mode 100755
index 0000000..aa0f479
--- /dev/null
+++ b/bin/mkiso
@@ -0,0 +1,569 @@
+#!/bin/sh
+# Build a bootable SPS live TTY ISO. Users flash it, boot to a console, mount
+# a destination filesystem, and run setup(1). It never runs grub-install on a
+# disk.
+
+sps_prog=mkiso
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+	if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+		SPS_LIBDIR=$sps_bin_dir/../lib
+	else
+		SPS_LIBDIR=$sps_bin_dir/../lib/sps
+	fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+	printf '%s\n' "mkiso: cannot load $SPS_LIBDIR/common.sh" >&2
+	exit 1
+fi
+
+mkiso_usage()
+{
+	cat <<'EOF'
+usage: mkiso --output FILE.iso [options]
+       mkiso --layout DIR [options]
+
+Build a live TTY ISO (or a layout directory) that boots to a console with
+setup(1) on PATH.
+
+options:
+  --output FILE         write a hybrid ISO
+  --layout DIR          populate a live root and stop (tests, inspection)
+  --work DIR            working directory (default: a temp dir)
+  --kernel FILE         vmlinuz (default: /boot/vmlinuz)
+  --modules DIR         kernel modules (default: /lib/modules/RELEASE)
+  --busybox FILE        static busybox for the initramfs
+  --core DIR            sps-core recipes (copied to /usr/src/sps/core)
+  --extra DIR           sps-extra recipes (copied to /usr/src/sps/extra)
+  --with-rust           copy rustc and cargo from the host
+  --with-go             copy the host Go toolchain
+  --with-firmware       copy /lib/firmware (large; omit from published ISOs)
+  --no-modules          do not copy /lib/modules (smaller, less hardware)
+  --no-seed             do not copy host compiler tools (layout/tests)
+  -h, --help
+EOF
+}
+
+mkiso_output=
+mkiso_layout=
+mkiso_work=
+mkiso_work_created=0
+mkiso_kernel=
+mkiso_modules=
+mkiso_busybox=
+mkiso_core=
+mkiso_extra=
+mkiso_with_rust=0
+mkiso_with_go=0
+mkiso_with_firmware=0
+mkiso_copy_modules=1
+mkiso_copy_seed=1
+mkiso_libdir=$SPS_LIBDIR/mkiso
+
+mkiso_need_value()
+{
+	[ "$#" -ge 2 ] && [ -n "${2-}" ] ||
+		sps_die "$SPS_EX_USAGE" "option '$1' requires an argument"
+}
+
+while [ "$#" -gt 0 ]; do
+	case $1 in
+		-h|--help)
+			mkiso_usage
+			exit 0
+			;;
+		--output)
+			mkiso_need_value "$@"
+			mkiso_output=$2
+			shift 2
+			;;
+		--layout)
+			mkiso_need_value "$@"
+			mkiso_layout=$2
+			shift 2
+			;;
+		--work)
+			mkiso_need_value "$@"
+			mkiso_work=$2
+			shift 2
+			;;
+		--kernel)
+			mkiso_need_value "$@"
+			mkiso_kernel=$2
+			shift 2
+			;;
+		--modules)
+			mkiso_need_value "$@"
+			mkiso_modules=$2
+			shift 2
+			;;
+		--busybox)
+			mkiso_need_value "$@"
+			mkiso_busybox=$2
+			shift 2
+			;;
+		--core)
+			mkiso_need_value "$@"
+			mkiso_core=$2
+			shift 2
+			;;
+		--extra)
+			mkiso_need_value "$@"
+			mkiso_extra=$2
+			shift 2
+			;;
+		--with-rust) mkiso_with_rust=1; shift ;;
+		--with-go) mkiso_with_go=1; shift ;;
+		--with-firmware) mkiso_with_firmware=1; shift ;;
+		--no-modules) mkiso_copy_modules=0; shift ;;
+		--no-seed) mkiso_copy_seed=0; shift ;;
+		-*)
+			sps_die "$SPS_EX_USAGE" "unknown option: $1"
+			;;
+		*)
+			sps_die "$SPS_EX_USAGE" "unexpected argument: $1"
+			;;
+	esac
+done
+
+if [ -z "$mkiso_output" ] && [ -z "$mkiso_layout" ]; then
+	sps_die "$SPS_EX_USAGE" "pass --output FILE.iso or --layout DIR"
+fi
+
+mkiso_cleanup()
+{
+	if [ "$mkiso_work_created" -eq 1 ] && [ -n "$mkiso_work" ] &&
+	   [ -d "$mkiso_work" ]; then
+		rm -rf "$mkiso_work"
+	fi
+}
+
+if [ -z "$mkiso_work" ]; then
+	mkiso_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" mkiso) || exit $?
+	mkiso_work_created=1
+	trap mkiso_cleanup 0 HUP INT TERM
+else
+	case $mkiso_work in
+		/*) ;;
+		*) sps_die "$SPS_EX_USAGE" "work directory must be absolute" ;;
+	esac
+	mkdir -p "$mkiso_work" ||
+		sps_die "$SPS_EX_PERMISSION" "cannot create $mkiso_work"
+fi
+
+if [ -n "$mkiso_layout" ]; then
+	case $mkiso_layout in
+		/*) ;;
+		*) sps_die "$SPS_EX_USAGE" "layout directory must be absolute" ;;
+	esac
+	mkiso_live=$mkiso_layout
+else
+	mkiso_live=$mkiso_work/live-root
+fi
+
+mkdir -p "$mkiso_live" ||
+	sps_die "$SPS_EX_PERMISSION" "cannot create live root"
+
+# --- copy helpers -------------------------------------------------------
+
+mkiso_copy_path()
+{
+	mkiso_src=$1
+	[ -e "$mkiso_src" ] || [ -L "$mkiso_src" ] || return 0
+	mkiso_dest=$mkiso_live$mkiso_src
+	mkdir -p "$(dirname "$mkiso_dest")" || return 1
+	if [ -d "$mkiso_src" ] && [ ! -L "$mkiso_src" ]; then
+		mkdir -p "$mkiso_dest"
+		cp -a "$mkiso_src" "$mkiso_dest" 2>/dev/null ||
+			cp -R "$mkiso_src" "$mkiso_dest" 2>/dev/null || :
+	else
+		cp -a "$mkiso_src" "$mkiso_dest" 2>/dev/null ||
+			cp "$mkiso_src" "$mkiso_dest" 2>/dev/null || :
+	fi
+}
+
+mkiso_copy_libs()
+{
+	mkiso_bin=$1
+	[ -f "$mkiso_bin" ] || return 0
+	command -v ldd >/dev/null 2>&1 || return 0
+	ldd "$mkiso_bin" 2>/dev/null | while IFS= read -r mkiso_line; do
+		mkiso_lib=
+		case $mkiso_line in
+			*linux-vdso*) continue ;;
+		esac
+		# "libx.so => /lib64/libx.so (0x...)"
+		mkiso_lib=${mkiso_line#*=> }
+		mkiso_lib=${mkiso_lib%% (*}
+		mkiso_lib=${mkiso_lib## }
+		case $mkiso_lib in
+			/*) mkiso_copy_path "$mkiso_lib" ;;
+		esac
+		# interpreter-only lines: "/lib64/ld-linux-x86-64.so.2 (0x...)"
+		case $mkiso_line in
+			"	/"*|" /"*)
+				mkiso_interp=${mkiso_line%% (*}
+				mkiso_interp=${mkiso_interp## }
+				mkiso_interp=${mkiso_interp#	}
+				case $mkiso_interp in
+					/*) mkiso_copy_path "$mkiso_interp" ;;
+				esac
+				;;
+		esac
+	done
+}
+
+# BusyBox --install -s writes absolute symlinks to the host path. Relative
+# links keep applets working inside the squashfs and initramfs.
+mkiso_busybox_links()
+{
+	mkiso_bbdir=$1
+	[ -x "$mkiso_bbdir/busybox" ] || return 1
+	"$mkiso_bbdir/busybox" --list | while IFS= read -r mkiso_ap || [ -n "$mkiso_ap" ]
+	do
+		[ -n "$mkiso_ap" ] || continue
+		[ "$mkiso_ap" = busybox ] && continue
+		ln -sf busybox "$mkiso_bbdir/$mkiso_ap"
+	done
+	ln -sf busybox "$mkiso_bbdir/sh"
+}
+
+# --- live root contents -------------------------------------------------
+
+printf '%s\n' "mkiso: installing SPS tools into the live root" >&2
+make -C "$sps_bin_dir/.." \
+	DESTDIR="$mkiso_live" PREFIX=/usr \
+	install install-config >/dev/null ||
+	sps_die "$SPS_EX_BUILD" "make install into the live root failed"
+
+mkdir -p "$mkiso_live/bin" "$mkiso_live/sbin" "$mkiso_live/usr/bin" \
+	"$mkiso_live/usr/sbin" "$mkiso_live/root" "$mkiso_live/mnt" \
+	"$mkiso_live/proc" "$mkiso_live/sys" "$mkiso_live/dev" \
+	"$mkiso_live/tmp" "$mkiso_live/run" "$mkiso_live/etc/sps" \
+	"$mkiso_live/usr/src/sps" "$mkiso_live/var/cache/sps" \
+	"$mkiso_live/var/lib/sps" "$mkiso_live/var/tmp/sps"
+
+if [ -z "$mkiso_busybox" ]; then
+	for mkiso_bb in /tmp/sps-wave/busybox.static \
+		"$mkiso_libdir/busybox" /usr/bin/busybox /bin/busybox
+	do
+		if [ -x "$mkiso_bb" ]; then
+			mkiso_busybox=$mkiso_bb
+			break
+		fi
+	done
+fi
+[ -n "$mkiso_busybox" ] && [ -x "$mkiso_busybox" ] || {
+	if [ -n "$mkiso_output" ]; then
+		sps_die "$SPS_EX_PACKAGE" \
+			"busybox is required for an ISO (pass --busybox FILE)"
+	fi
+}
+
+if [ -n "$mkiso_busybox" ] && [ -x "$mkiso_busybox" ]; then
+	cp -a "$mkiso_busybox" "$mkiso_live/bin/busybox"
+	chmod 755 "$mkiso_live/bin/busybox"
+	mkiso_busybox_links "$mkiso_live/bin"
+	ln -sf ../bin/busybox "$mkiso_live/sbin/busybox" 2>/dev/null || :
+fi
+
+install -m 0755 "$mkiso_libdir/live-init" "$mkiso_live/sbin/init"
+install -m 0644 "$mkiso_libdir/motd" "$mkiso_live/etc/motd"
+install -m 0644 "$mkiso_libdir/issue" "$mkiso_live/etc/issue"
+printf '%s\n' live >"$mkiso_live/etc/sps/live"
+printf '%s\n' sps-live >"$mkiso_live/etc/hostname"
+printf '%s\n' 'root:x:0:0:root:/root:/bin/sh' >"$mkiso_live/etc/passwd"
+printf '%s\n' 'root:x:0:' >"$mkiso_live/etc/group"
+# Empty password on the live console only.
+printf '%s\n' 'root::0:0:99999:7:::' >"$mkiso_live/etc/shadow"
+chmod 600 "$mkiso_live/etc/shadow"
+
+printf '%s\n' \
+	'db /var/lib/sps' \
+	'cache /var/cache/sps' \
+	'build /var/tmp/sps' \
+	'repo_root /usr/src/sps' \
+	"makejobs ${SPS_MAKEJOBS:-$(nproc 2>/dev/null || printf 2)}" \
+	'compression auto' \
+	'preserve etc' \
+	>"$mkiso_live/etc/sps/sps.conf"
+
+mkiso_repos=$mkiso_live/etc/sps/repos.conf
+: >"$mkiso_repos"
+
+if [ -n "$mkiso_core" ] && [ -d "$mkiso_core" ]; then
+	printf '%s: copying sps-core recipes\n' "$sps_prog" >&2
+	rm -rf "$mkiso_live/usr/src/sps/core"
+	mkdir -p "$mkiso_live/usr/src/sps/core"
+	cp -a "$mkiso_core"/. "$mkiso_live/usr/src/sps/core"/
+	rm -rf "$mkiso_live/usr/src/sps/core/.git"
+	printf 'dir core /usr/src/sps/core 100\n' >>"$mkiso_repos"
+fi
+if [ -n "$mkiso_extra" ] && [ -d "$mkiso_extra" ]; then
+	printf '%s: copying sps-extra recipes\n' "$sps_prog" >&2
+	rm -rf "$mkiso_live/usr/src/sps/extra"
+	mkdir -p "$mkiso_live/usr/src/sps/extra"
+	cp -a "$mkiso_extra"/. "$mkiso_live/usr/src/sps/extra"/
+	rm -rf "$mkiso_live/usr/src/sps/extra/.git"
+	printf 'dir extra /usr/src/sps/extra 80\n' >>"$mkiso_repos"
+fi
+if [ ! -s "$mkiso_repos" ]; then
+	cat >>"$mkiso_repos" <<'EOF'
+git core https://github.com/RobertFlexx/sps-core.git 100
+git extra https://github.com/RobertFlexx/sps-extra.git 80
+EOF
+fi
+
+if [ "$mkiso_copy_seed" -eq 1 ] && [ -f "$mkiso_libdir/seed-bins" ]; then
+	printf '%s: copying host seed tools\n' "$sps_prog" >&2
+	while IFS= read -r mkiso_bin || [ -n "$mkiso_bin" ]; do
+		case $mkiso_bin in ''|\#*) continue ;; esac
+		[ -e "$mkiso_bin" ] || continue
+		mkiso_copy_path "$mkiso_bin"
+		mkiso_copy_libs "$mkiso_bin"
+	done <"$mkiso_libdir/seed-bins"
+	# Compiler support files.
+	mkiso_copy_path /usr/libexec/gcc
+	mkiso_copy_path /usr/lib64/gcc
+	mkiso_copy_path /usr/include
+	mkiso_copy_path /usr/lib64/python3.12
+	mkiso_copy_path /usr/share/terminfo
+	mkiso_copy_path /usr/share/zoneinfo
+	mkiso_copy_path /etc/ssl
+	mkiso_copy_path /etc/ca-certificates
+	mkiso_copy_path /usr/share/ca-certificates
+	mkiso_copy_path /etc/ld.so.conf
+	mkiso_copy_path /etc/ld.so.conf.d
+	mkiso_copy_path /usr/lib64/grub
+	mkiso_copy_path /usr/lib/grub
+	mkiso_copy_path /usr/share/grub
+	if [ "$mkiso_with_firmware" -eq 1 ] && [ -d /lib/firmware ]; then
+		printf '%s: copying /lib/firmware\n' "$sps_prog" >&2
+		mkiso_copy_path /lib/firmware
+	fi
+	if [ "$mkiso_with_rust" -eq 1 ]; then
+		mkiso_copy_path /usr/lib64/rustlib
+		mkiso_copy_path /usr/bin/rustc
+		mkiso_copy_path /usr/bin/cargo
+		mkiso_copy_libs /usr/bin/rustc
+		mkiso_copy_libs /usr/bin/cargo
+	fi
+	if [ "$mkiso_with_go" -eq 1 ]; then
+		mkiso_copy_path /usr/lib64/go1.27.0
+	fi
+fi
+
+# Initramfs modules are required to mount the ISO even when the live
+# squashfs does not ship the full /lib/modules tree.
+if [ -z "$mkiso_modules" ]; then
+	mkiso_krel=$(uname -r)
+	mkiso_modules=/lib/modules/$mkiso_krel
+fi
+if [ "$mkiso_copy_modules" -eq 1 ] && [ -d "$mkiso_modules" ]; then
+	printf '%s: copying kernel modules from %s\n' \
+		"$sps_prog" "$mkiso_modules" >&2
+	mkdir -p "$mkiso_live/lib/modules"
+	cp -a "$mkiso_modules" "$mkiso_live/lib/modules/"
+fi
+
+# Busybox getty/init must win over a dangling seed copy of /sbin/init.
+install -m 0755 "$mkiso_libdir/live-init" "$mkiso_live/sbin/init"
+
+if [ -n "$mkiso_layout" ] && [ -z "$mkiso_output" ]; then
+	printf '%s\n' "$mkiso_live"
+	exit 0
+fi
+
+# --- squashfs + initrd + ISO --------------------------------------------
+
+command -v mksquashfs >/dev/null 2>&1 ||
+	sps_die "$SPS_EX_PACKAGE" "mksquashfs is required"
+command -v xorriso >/dev/null 2>&1 ||
+	sps_die "$SPS_EX_PACKAGE" "xorriso is required"
+
+if [ -z "$mkiso_kernel" ]; then
+	if [ -f /boot/vmlinuz-$(uname -r) ]; then
+		mkiso_kernel=/boot/vmlinuz-$(uname -r)
+	elif [ -f /boot/vmlinuz ]; then
+		mkiso_kernel=/boot/vmlinuz
+	fi
+fi
+# Follow a single symlink so the ISO contains a real kernel, not a dangling link.
+if [ -L "$mkiso_kernel" ]; then
+	mkiso_ktarget=$(readlink "$mkiso_kernel")
+	case $mkiso_ktarget in
+		/*) mkiso_kernel=$mkiso_ktarget ;;
+		*) mkiso_kernel=$(dirname "$mkiso_kernel")/$mkiso_ktarget ;;
+	esac
+fi
+[ -f "$mkiso_kernel" ] ||
+	sps_die "$SPS_EX_NOTFOUND" "kernel not found: $mkiso_kernel"
+
+mkiso_iso=$mkiso_work/iso
+mkdir -p "$mkiso_iso/live" "$mkiso_iso/boot/grub"
+cp -a "$mkiso_kernel" "$mkiso_iso/live/vmlinuz"
+
+printf '%s: writing squashfs (this takes a while)\n' "$sps_prog" >&2
+mksquashfs "$mkiso_live" "$mkiso_iso/live/filesystem.squashfs" \
+	-comp xz -e boot -noappend >/dev/null ||
+	sps_die "$SPS_EX_BUILD" "mksquashfs failed"
+
+# Initramfs: busybox + init + filesystem modules.
+mkiso_ird=$mkiso_work/initrd-root
+rm -rf "$mkiso_ird"
+mkdir -p "$mkiso_ird/bin" "$mkiso_ird/sbin" "$mkiso_ird/lib/modules" \
+	"$mkiso_ird/proc" "$mkiso_ird/sys" "$mkiso_ird/dev" "$mkiso_ird/mnt"
+cp -a "$mkiso_busybox" "$mkiso_ird/bin/busybox"
+chmod 755 "$mkiso_ird/bin/busybox"
+mkiso_busybox_links "$mkiso_ird/bin"
+install -m 0755 "$mkiso_libdir/initrd-init" "$mkiso_ird/init"
+
+if [ -d "$mkiso_modules" ]; then
+	mkiso_kver=$(basename "$mkiso_modules")
+	mkdir -p "$mkiso_ird/lib/modules/$mkiso_kver"
+	# Copy only what the initramfs needs to find and mount the ISO.
+	# Follow host module dependencies so overlay/loop are complete.
+	mkiso_copy_one_mod()
+	{
+		mkiso_mp=$1
+		[ -f "$mkiso_mp" ] || return 0
+		mkiso_rel=${mkiso_mp#"$mkiso_modules"/}
+		mkdir -p "$mkiso_ird/lib/modules/$mkiso_kver/$(dirname "$mkiso_rel")"
+		case $mkiso_mp in
+			*.ko.xz)
+				xz -dc "$mkiso_mp" >"$mkiso_ird/lib/modules/$mkiso_kver/${mkiso_rel%.xz}"
+				;;
+			*.ko.zst)
+				zstd -q -dc "$mkiso_mp" >"$mkiso_ird/lib/modules/$mkiso_kver/${mkiso_rel%.zst}"
+				;;
+			*)
+				cp -a "$mkiso_mp" \
+					"$mkiso_ird/lib/modules/$mkiso_kver/$mkiso_rel"
+				;;
+		esac
+	}
+	for mkiso_mod in squashfs overlay loop isofs cdrom sr_mod sd_mod \
+		ahci nvme usb-storage xhci-pci ehci-hcd uhci-hcd \
+		virtio_blk virtio_pci virtio_scsi virtio_net \
+		nls_cp437 nls_iso8859-1 vfat fat crc32c libcrc32c
+	do
+		if command -v modprobe >/dev/null 2>&1; then
+			modprobe -D "$mkiso_mod" 2>/dev/null | while IFS= read -r mkiso_line
+			do
+				mkiso_cmd=${mkiso_line%% *}
+				mkiso_mp=${mkiso_line#* }
+				mkiso_mp=${mkiso_mp%% *}
+				[ "$mkiso_cmd" = insmod ] || continue
+				mkiso_copy_one_mod "$mkiso_mp"
+			done
+		fi
+		find "$mkiso_modules" -name "${mkiso_mod}.ko*" 2>/dev/null |
+			while IFS= read -r mkiso_mp; do
+				mkiso_copy_one_mod "$mkiso_mp"
+			done
+	done
+	cp -a "$mkiso_modules"/modules.dep \
+		"$mkiso_ird/lib/modules/$mkiso_kver"/ 2>/dev/null || :
+	cp -a "$mkiso_modules"/modules.alias \
+		"$mkiso_ird/lib/modules/$mkiso_kver"/ 2>/dev/null || :
+	cp -a "$mkiso_modules"/modules.builtin \
+		"$mkiso_ird/lib/modules/$mkiso_kver"/ 2>/dev/null || :
+fi
+
+printf '%s: packing initramfs\n' "$sps_prog" >&2
+(
+	cd "$mkiso_ird" || exit 1
+	find . | cpio -o -H newc 2>/dev/null
+) | gzip -n -9 >"$mkiso_iso/live/initrd.img" ||
+	sps_die "$SPS_EX_BUILD" "initramfs cpio failed"
+
+cat >"$mkiso_iso/boot/grub/grub.cfg" <<'EOF'
+set timeout=8
+set default=0
+serial --unit=0 --speed=115200
+terminal_input serial console
+terminal_output serial console
+
+menuentry "SPS live TTY" {
+	linux /live/vmlinuz boot=live console=tty0 console=ttyS0,115200n8
+	initrd /live/initrd.img
+}
+
+menuentry "SPS live TTY (run setup)" {
+	linux /live/vmlinuz boot=live sps=setup console=tty0 console=ttyS0,115200n8
+	initrd /live/initrd.img
+}
+EOF
+
+# BIOS + UEFI hybrid ISO. grub-mkrescue on this host requires
+# /usr/share/grub/unicode.pf2, which is not shipped, so mkiso builds
+# the image with grub-mkimage and xorriso instead. It never runs
+# grub-install on a disk.
+command -v grub-mkimage >/dev/null 2>&1 ||
+	sps_die "$SPS_EX_PACKAGE" "grub-mkimage is required"
+mkiso_pc=
+mkiso_efi=
+for mkiso_gdir in /usr/lib64/grub /usr/lib/grub
+do
+	[ -d "$mkiso_gdir/i386-pc" ] && mkiso_pc=$mkiso_gdir/i386-pc
+	[ -d "$mkiso_gdir/x86_64-efi" ] && mkiso_efi=$mkiso_gdir/x86_64-efi
+done
+[ -n "$mkiso_pc" ] ||
+	sps_die "$SPS_EX_PACKAGE" "GRUB i386-pc modules not found"
+
+mkiso_grub_mods='iso9660 linux normal configfile echo search fat part_gpt part_msdos ext2 serial'
+grub-mkimage -O i386-pc -o "$mkiso_work/core.img" -p /boot/grub \
+	biosdisk $mkiso_grub_mods ||
+	sps_die "$SPS_EX_BUILD" "grub-mkimage (i386-pc) failed"
+cat "$mkiso_pc/cdboot.img" "$mkiso_work/core.img" >"$mkiso_iso/boot/grub/bios.img"
+
+mkdir -p "$mkiso_iso/EFI/BOOT"
+if [ -n "$mkiso_efi" ]; then
+	grub-mkimage -O x86_64-efi -o "$mkiso_iso/EFI/BOOT/BOOTX64.EFI" \
+		-p /boot/grub $mkiso_grub_mods ||
+		sps_die "$SPS_EX_BUILD" "grub-mkimage (x86_64-efi) failed"
+	truncate -s 8M "$mkiso_iso/boot/grub/efi.img"
+	if command -v mformat >/dev/null 2>&1 && command -v mcopy >/dev/null 2>&1; then
+		mformat -v SPS_EFI -i "$mkiso_iso/boot/grub/efi.img" ::
+		mmd -i "$mkiso_iso/boot/grub/efi.img" ::EFI ::EFI/BOOT
+		mcopy -i "$mkiso_iso/boot/grub/efi.img" \
+			"$mkiso_iso/EFI/BOOT/BOOTX64.EFI" ::EFI/BOOT/BOOTX64.EFI
+	elif command -v mkfs.vfat >/dev/null 2>&1 || [ -x /sbin/mkfs.vfat ]; then
+		mkiso_mkfs=$(command -v mkfs.vfat 2>/dev/null || printf /sbin/mkfs.vfat)
+		"$mkiso_mkfs" "$mkiso_iso/boot/grub/efi.img" >/dev/null
+		mmd -i "$mkiso_iso/boot/grub/efi.img" ::EFI ::EFI/BOOT
+		mcopy -i "$mkiso_iso/boot/grub/efi.img" \
+			"$mkiso_iso/EFI/BOOT/BOOTX64.EFI" ::EFI/BOOT/BOOTX64.EFI
+	fi
+fi
+
+printf '%s: writing ISO\n' "$sps_prog" >&2
+case $mkiso_output in
+	/*) ;;
+	*) mkiso_output=$(CDPATH= cd -P "$(pwd)" && printf '%s/%s' "$(pwd)" "$mkiso_output") ;;
+esac
+mkdir -p "$(dirname "$mkiso_output")"
+mkiso_xorriso_log=$mkiso_work/xorriso.log
+if [ -f "$mkiso_iso/boot/grub/efi.img" ]; then
+	xorriso -as mkisofs -r -J -V SPS_LIVE \
+		-o "$mkiso_output" \
+		-b boot/grub/bios.img -no-emul-boot -boot-load-size 4 \
+		-boot-info-table --grub2-boot-info \
+		--grub2-mbr "$mkiso_pc/boot_hybrid.img" \
+		-eltorito-alt-boot -e boot/grub/efi.img -no-emul-boot \
+		-isohybrid-gpt-basdat \
+		"$mkiso_iso" >"$mkiso_xorriso_log" 2>&1 ||
+		sps_die "$SPS_EX_BUILD" "xorriso failed (see $mkiso_xorriso_log)"
+else
+	xorriso -as mkisofs -r -J -V SPS_LIVE \
+		-o "$mkiso_output" \
+		-b boot/grub/bios.img -no-emul-boot -boot-load-size 4 \
+		-boot-info-table --grub2-boot-info \
+		--grub2-mbr "$mkiso_pc/boot_hybrid.img" \
+		"$mkiso_iso" >"$mkiso_xorriso_log" 2>&1 ||
+		sps_die "$SPS_EX_BUILD" "xorriso failed (see $mkiso_xorriso_log)"
+fi
+
+printf '%s\n' "$mkiso_output"
+ls -lh "$mkiso_output" >&2
+exit 0
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..e77ba47
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,1014 @@
+#!/bin/sh
+# SPS system installer. Interactive screens use dialog(1); every choice is
+# also available as a command-line option for scripts and tests.
+
+sps_prog=setup
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+	if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+		SPS_LIBDIR=$sps_bin_dir/../lib
+	else
+		SPS_LIBDIR=$sps_bin_dir/../lib/sps
+	fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+	printf '%s\n' "setup: cannot load $SPS_LIBDIR/common.sh" >&2
+	exit 1
+fi
+sps_load_config || exit $?
+
+setup_usage()
+{
+	cat <<'EOF'
+usage: setup [options]
+
+Install an SPS system into a target root using dialog(1) screens, or the
+same choices from the command line.
+
+options:
+  --target DIR              installation root (SPS_ROOT)
+  --profile NAME            minimal, server, plasma-desktop, or plasma-full
+  --enable SET[,SET...]     extra sets (repeatable)
+  --disable SET[,SET...]    extra sets to leave off (repeatable)
+  --hostname NAME
+  --timezone ZONE           e.g. UTC or America/Los_Angeles
+  --keymap MAP              e.g. us, uk, de, dvorak
+  --locale NAME             e.g. C.UTF-8 or en_US.UTF-8
+  --shell PATH              login shell for the first user
+  --user NAME               first unprivileged user (uid 1000)
+  --user-gecos TEXT
+  --extra PKG[,PKG...]      individual packages in addition to sets
+  --ssh yes|no              include the ssh set (OpenSSH)
+  --printing yes|no         include the printing set (CUPS)
+  --flatpak yes|no          add Flathub after install when flatpak is present
+  --nvidia yes|no           include the nvidia set
+  --bootloader none|grub    write bootloader notes; never runs grub-install
+  --install-bootloader      actually run grub-install (explicit, dangerous)
+  --update|--no-update      run or skip src update before installing
+  --non-interactive         do not start dialog; all choices from options
+  --plan                    print the package list and exit
+  --write-answers FILE      write the resolved answers
+  --read-answers FILE       load answers (options override file values)
+  --list-profiles
+  --list-sets
+  -h, --help
+EOF
+}
+
+setup_dir=${SPS_SETUP_DIR:-$SPS_LIBDIR/setup}
+setup_work=
+setup_interactive=1
+setup_plan=0
+setup_list_profiles=0
+setup_list_sets=0
+setup_do_update=1
+setup_install_bootloader=0
+setup_target=
+setup_profile=
+setup_hostname=
+setup_timezone=
+setup_keymap=
+setup_locale=
+setup_shell=
+setup_user=
+setup_user_gecos=
+setup_extra=
+setup_ssh=
+setup_printing=
+setup_flatpak=
+setup_nvidia=
+setup_bootloader=none
+setup_answers_in=
+setup_answers_out=
+setup_enable=
+setup_disable=
+
+setup_cleanup()
+{
+	if [ -n "$setup_work" ] && [ -d "$setup_work" ]; then
+		rm -rf "$setup_work"
+	fi
+}
+
+setup_fail()
+{
+	setup_code=$1
+	shift
+	printf '%s: %s\n' "$sps_prog" "$*" >&2
+	exit "$setup_code"
+}
+
+setup_need_value()
+{
+	[ "$#" -ge 2 ] && [ -n "${2-}" ] ||
+		setup_fail "$SPS_EX_USAGE" "option '$1' requires an argument"
+}
+
+setup_append_csv()
+{
+	setup_dest=$1
+	setup_csv=$2
+	setup_old=$3
+	setup_item=
+	setup_rest=$setup_csv,
+	while [ -n "$setup_rest" ]; do
+		setup_item=${setup_rest%%,*}
+		setup_rest=${setup_rest#*,}
+		[ -n "$setup_item" ] || continue
+		case " $setup_old " in
+			*" $setup_item "*) ;;
+			*) setup_old="$setup_old $setup_item" ;;
+		esac
+	done
+	eval "$setup_dest=\"\$(printf '%s' \"\$setup_old\" | sed 's/^ //')\""
+}
+
+while [ "$#" -gt 0 ]; do
+	case $1 in
+		-h|--help)
+			setup_usage
+			exit 0
+			;;
+		--target)
+			setup_need_value "$@"
+			setup_target=$2
+			shift 2
+			;;
+		--profile)
+			setup_need_value "$@"
+			setup_profile=$2
+			shift 2
+			;;
+		--enable)
+			setup_need_value "$@"
+			setup_append_csv setup_enable "$2" "$setup_enable"
+			shift 2
+			;;
+		--disable)
+			setup_need_value "$@"
+			setup_append_csv setup_disable "$2" "$setup_disable"
+			shift 2
+			;;
+		--hostname)
+			setup_need_value "$@"
+			setup_hostname=$2
+			shift 2
+			;;
+		--timezone)
+			setup_need_value "$@"
+			setup_timezone=$2
+			shift 2
+			;;
+		--keymap)
+			setup_need_value "$@"
+			setup_keymap=$2
+			shift 2
+			;;
+		--locale)
+			setup_need_value "$@"
+			setup_locale=$2
+			shift 2
+			;;
+		--shell)
+			setup_need_value "$@"
+			setup_shell=$2
+			shift 2
+			;;
+		--extra)
+			setup_need_value "$@"
+			setup_append_csv setup_extra "$2" "$setup_extra"
+			shift 2
+			;;
+		--ssh)
+			setup_need_value "$@"
+			setup_ssh=$2
+			shift 2
+			;;
+		--printing)
+			setup_need_value "$@"
+			setup_printing=$2
+			shift 2
+			;;
+		--user)
+			setup_need_value "$@"
+			setup_user=$2
+			shift 2
+			;;
+		--user-gecos)
+			setup_need_value "$@"
+			setup_user_gecos=$2
+			shift 2
+			;;
+		--flatpak)
+			setup_need_value "$@"
+			setup_flatpak=$2
+			shift 2
+			;;
+		--nvidia)
+			setup_need_value "$@"
+			setup_nvidia=$2
+			shift 2
+			;;
+		--bootloader)
+			setup_need_value "$@"
+			setup_bootloader=$2
+			shift 2
+			;;
+		--install-bootloader)
+			setup_install_bootloader=1
+			shift
+			;;
+		--update)
+			setup_do_update=1
+			shift
+			;;
+		--no-update)
+			setup_do_update=0
+			shift
+			;;
+		--non-interactive)
+			setup_interactive=0
+			shift
+			;;
+		--plan)
+			setup_plan=1
+			setup_interactive=0
+			shift
+			;;
+		--write-answers)
+			setup_need_value "$@"
+			setup_answers_out=$2
+			shift 2
+			;;
+		--read-answers)
+			setup_need_value "$@"
+			setup_answers_in=$2
+			shift 2
+			;;
+		--list-profiles)
+			setup_list_profiles=1
+			shift
+			;;
+		--list-sets)
+			setup_list_sets=1
+			shift
+			;;
+		--)
+			shift
+			break
+			;;
+		-*)
+			setup_fail "$SPS_EX_USAGE" "unknown option: $1"
+			;;
+		*)
+			setup_fail "$SPS_EX_USAGE" "unexpected argument: $1"
+			;;
+	esac
+done
+
+[ "$#" -eq 0 ] || setup_fail "$SPS_EX_USAGE" "unexpected argument: $1"
+
+setup_field()
+{
+	setup_file=$1
+	setup_key=$2
+	awk -v k="$setup_key" '
+		$1 == k {
+			$1 = ""
+			sub(/^[ \t]+/, "")
+			print
+			exit
+		}
+	' "$setup_file"
+}
+
+setup_packages_from()
+{
+	awk '
+		$1 == "package" {
+			$1 = ""
+			sub(/^[ \t]+/, "")
+			if ($0 != "") print
+		}
+	' "$1"
+}
+
+setup_profile_file()
+{
+	printf '%s/profiles/%s.profile' "$setup_dir" "$1"
+}
+
+setup_set_file()
+{
+	printf '%s/sets/%s.set' "$setup_dir" "$1"
+}
+
+setup_each_profile()
+{
+	setup_p=
+	for setup_p in "$setup_dir"/profiles/*.profile; do
+		[ -f "$setup_p" ] || continue
+		setup_field "$setup_p" name
+	done
+}
+
+setup_each_set()
+{
+	setup_s=
+	for setup_s in "$setup_dir"/sets/*.set; do
+		[ -f "$setup_s" ] || continue
+		setup_field "$setup_s" name
+	done
+}
+
+setup_valid_profile()
+{
+	[ -f "$(setup_profile_file "$1")" ]
+}
+
+setup_valid_set()
+{
+	[ -f "$(setup_set_file "$1")" ]
+}
+
+setup_default_sets_for_profile()
+{
+	setup_ds=
+	setup_sf=
+	for setup_sf in "$setup_dir"/sets/*.set; do
+		[ -f "$setup_sf" ] || continue
+		setup_sn=$(setup_field "$setup_sf" name)
+		setup_def=$(setup_field "$setup_sf" default)
+		[ "$setup_def" = 1 ] || continue
+		case $setup_profile in
+			plasma-desktop|plasma-full)
+				setup_ds="$setup_ds $setup_sn"
+				;;
+			*)
+				# Power defaults on for desktops only.
+				[ "$setup_sn" = power ] && continue
+				[ "$setup_sn" = qol-cli ] &&
+					setup_ds="$setup_ds $setup_sn"
+				;;
+		esac
+	done
+	printf '%s' "$setup_ds" | sed 's/^ //'
+}
+
+if [ "$setup_list_profiles" -eq 1 ]; then
+	for setup_pf in "$setup_dir"/profiles/*.profile; do
+		[ -f "$setup_pf" ] || continue
+		printf '%s\t%s\t%s\n' \
+			"$(setup_field "$setup_pf" name)" \
+			"$(setup_field "$setup_pf" title)" \
+			"$(setup_field "$setup_pf" description)"
+	done
+	exit 0
+fi
+
+if [ "$setup_list_sets" -eq 1 ]; then
+	for setup_sf in "$setup_dir"/sets/*.set; do
+		[ -f "$setup_sf" ] || continue
+		printf '%s\t%s\t%s\n' \
+			"$(setup_field "$setup_sf" name)" \
+			"$(setup_field "$setup_sf" title)" \
+			"$(setup_field "$setup_sf" description)"
+	done
+	exit 0
+fi
+
+setup_load_answers()
+{
+	setup_af=$1
+	[ -f "$setup_af" ] && [ -r "$setup_af" ] ||
+		setup_fail "$SPS_EX_NOTFOUND" "cannot read answers file: $setup_af"
+	while IFS= read -r setup_line || [ -n "$setup_line" ]; do
+		case $setup_line in
+			''|\#*) continue ;;
+		esac
+		setup_ak=${setup_line%% *}
+		setup_av=${setup_line#* }
+		[ "$setup_ak" = "$setup_line" ] && setup_av=
+		case $setup_ak in
+			target) [ -n "$setup_target" ] || setup_target=$setup_av ;;
+			profile) [ -n "$setup_profile" ] || setup_profile=$setup_av ;;
+			hostname) [ -n "$setup_hostname" ] || setup_hostname=$setup_av ;;
+			timezone) [ -n "$setup_timezone" ] || setup_timezone=$setup_av ;;
+			keymap) [ -n "$setup_keymap" ] || setup_keymap=$setup_av ;;
+			locale) [ -n "$setup_locale" ] || setup_locale=$setup_av ;;
+			shell) [ -n "$setup_shell" ] || setup_shell=$setup_av ;;
+			user) [ -n "$setup_user" ] || setup_user=$setup_av ;;
+			user-gecos) [ -n "$setup_user_gecos" ] || setup_user_gecos=$setup_av ;;
+			extra) setup_append_csv setup_extra "$setup_av" "$setup_extra" ;;
+			ssh) [ -n "$setup_ssh" ] || setup_ssh=$setup_av ;;
+			printing) [ -n "$setup_printing" ] || setup_printing=$setup_av ;;
+			flatpak) [ -n "$setup_flatpak" ] || setup_flatpak=$setup_av ;;
+			nvidia) [ -n "$setup_nvidia" ] || setup_nvidia=$setup_av ;;
+			bootloader) [ "$setup_bootloader" = none ] && [ -n "$setup_av" ] && setup_bootloader=$setup_av ;;
+			enable) setup_append_csv setup_enable "$setup_av" "$setup_enable" ;;
+			disable) setup_append_csv setup_disable "$setup_av" "$setup_disable" ;;
+			update)
+				[ "$setup_av" = no ] && setup_do_update=0
+				[ "$setup_av" = yes ] && setup_do_update=1
+				;;
+		esac
+	done <"$setup_af"
+}
+
+[ -z "$setup_answers_in" ] || setup_load_answers "$setup_answers_in"
+
+setup_yesno_norm()
+{
+	case $1 in
+		yes|y|true|1) printf 'yes\n' ;;
+		no|n|false|0|'') printf 'no\n' ;;
+		*) setup_fail "$SPS_EX_USAGE" "expected yes or no, got '$1'" ;;
+	esac
+}
+
+setup_dialog()
+{
+	command -v dialog >/dev/null 2>&1 ||
+		setup_fail "$SPS_EX_PACKAGE" \
+			"dialog is not on PATH; install it or use --non-interactive"
+	dialog --backtitle "SPS Linux installer" --output-fd 1 "$@"
+}
+
+setup_msg()
+{
+	if [ "$setup_interactive" -eq 1 ]; then
+		dialog --backtitle "SPS Linux installer" --msgbox "$1" 12 64 || :
+	else
+		printf '%s\n' "$1"
+	fi
+}
+
+setup_confirm()
+{
+	if [ "$setup_interactive" -eq 1 ]; then
+		dialog --backtitle "SPS Linux installer" --yesno "$1" 14 64
+	else
+		return 0
+	fi
+}
+
+# Interactive defaults when flags were omitted.
+if [ "$setup_interactive" -eq 1 ] && [ ! -t 0 ]; then
+	setup_fail "$SPS_EX_USAGE" \
+		"stdin is not a terminal; use --non-interactive"
+fi
+
+if [ "$setup_interactive" -eq 1 ]; then
+	command -v dialog >/dev/null 2>&1 ||
+		setup_fail "$SPS_EX_PACKAGE" \
+			"dialog is required for interactive setup"
+fi
+
+setup_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" setup) || exit $?
+trap setup_cleanup 0 HUP INT TERM
+
+if [ "$setup_interactive" -eq 1 ]; then
+	if [ -f /etc/sps/live ]; then
+		setup_msg \
+			"This is the SPS live TTY. Mount the destination filesystem on /mnt first.
+
+Then continue. The installer never partitions disks. Bootloader install is a separate, explicit step."
+	else
+		setup_msg \
+			"This installer configures an SPS root the way Slackware and FreeBSD setup do: a sequence of dialog screens, then a visible package install.
+
+It never partitions disks. Point it at a mounted filesystem. Installing the bootloader is a separate, explicit step."
+	fi
+
+	if [ -z "$setup_target" ]; then
+		setup_target=$(setup_dialog --title "Installation target" \
+			--inputbox "Absolute path of the target root.
+
+Use /mnt after mounting the destination filesystem. / is the live system." \
+			10 64 "${setup_target:-/mnt}") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_profile" ]; then
+		setup_menu_args=
+		for setup_pf in "$setup_dir"/profiles/*.profile; do
+			[ -f "$setup_pf" ] || continue
+			setup_pn=$(setup_field "$setup_pf" name)
+			setup_pt=$(setup_field "$setup_pf" title)
+			setup_menu_args="$setup_menu_args $setup_pn '$setup_pt'"
+		done
+		setup_profile=$(eval "setup_dialog --title 'Installation profile' \
+			--menu 'Choose a base system.' 16 64 8 $setup_menu_args") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_hostname" ]; then
+		setup_hostname=$(setup_dialog --title "Hostname" \
+			--inputbox "System hostname." 8 50 "${setup_hostname:-sps}") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_timezone" ]; then
+		setup_tz_args=
+		while IFS= read -r setup_tz || [ -n "$setup_tz" ]; do
+			[ -n "$setup_tz" ] || continue
+			setup_tz_args="$setup_tz_args $setup_tz '$setup_tz'"
+		done <"$setup_dir/timezones"
+		setup_timezone=$(eval "setup_dialog --title 'Timezone' \
+			--menu 'Select a timezone.' 20 60 12 $setup_tz_args") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_locale" ]; then
+		setup_loc_args=
+		while IFS= read -r setup_loc || [ -n "$setup_loc" ]; do
+			[ -n "$setup_loc" ] || continue
+			setup_loc_args="$setup_loc_args $setup_loc '$setup_loc'"
+		done <"$setup_dir/locales"
+		setup_locale=$(eval "setup_dialog --title 'Locale' \
+			--menu 'LANG for /etc/locale.conf. C.UTF-8 needs no generated locales.' 20 60 12 $setup_loc_args") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_keymap" ]; then
+		setup_km_args=
+		while IFS= read -r setup_km || [ -n "$setup_km" ]; do
+			[ -n "$setup_km" ] || continue
+			setup_km_args="$setup_km_args $setup_km '$setup_km'"
+		done <"$setup_dir/keymaps"
+		setup_keymap=$(eval "setup_dialog --title 'Keyboard' \
+			--menu 'Keyboard map for the console.' 18 50 12 $setup_km_args") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	setup_chk_args=
+	for setup_sf in "$setup_dir"/sets/*.set; do
+		[ -f "$setup_sf" ] || continue
+		setup_sn=$(setup_field "$setup_sf" name)
+		setup_st=$(setup_field "$setup_sf" title)
+		setup_sd=$(setup_field "$setup_sf" description)
+		setup_on=off
+		case " $setup_enable " in *" $setup_sn "*) setup_on=on ;; esac
+		if [ -z "$setup_enable" ]; then
+			setup_def=$(setup_field "$setup_sf" default)
+			if [ "$setup_def" = 1 ]; then
+				case $setup_profile in
+					plasma-desktop|plasma-full) setup_on=on ;;
+					*)
+						[ "$setup_sn" = qol-cli ] && setup_on=on
+						;;
+				esac
+			fi
+		fi
+		case " $setup_disable " in *" $setup_sn "*) setup_on=off ;; esac
+		setup_chk_args="$setup_chk_args $setup_sn '$setup_st' $setup_on"
+	done
+	setup_sel=$(eval "setup_dialog --title 'Optional sets' \
+		--checklist 'Space toggles a set. These are installed in addition to the profile.' \
+		20 72 12 $setup_chk_args") ||
+		setup_fail "$SPS_EX_USAGE" "cancelled"
+	setup_enable=
+	# dialog may quote tags
+	set -- $setup_sel
+	for setup_tok; do
+		setup_tok=${setup_tok#\"}
+		setup_tok=${setup_tok%\"}
+		[ -n "$setup_tok" ] || continue
+		setup_append_csv setup_enable "$setup_tok" "$setup_enable"
+	done
+
+	if [ -f "$setup_dir/extras" ]; then
+		setup_ex_args=
+		while IFS= read -r setup_exline || [ -n "$setup_exline" ]; do
+			[ -n "$setup_exline" ] || continue
+			case $setup_exline in \#*) continue ;; esac
+			setup_en=${setup_exline%%	*}
+			setup_et=${setup_exline#*	}
+			[ -n "$setup_en" ] || continue
+			[ "$setup_et" != "$setup_exline" ] || setup_et=$setup_en
+			setup_on=off
+			case " $setup_extra " in *" $setup_en "*) setup_on=on ;; esac
+			setup_ex_args="$setup_ex_args $setup_en '$setup_et' $setup_on"
+		done <"$setup_dir/extras"
+		if [ -n "$setup_ex_args" ]; then
+			setup_sel=$(eval "setup_dialog --title 'Extra packages' \
+				--checklist 'Optional packages on top of the profile and sets. Space toggles an item.' \
+				22 72 14 $setup_ex_args") ||
+				setup_fail "$SPS_EX_USAGE" "cancelled"
+			set -- $setup_sel
+			for setup_tok; do
+				setup_tok=${setup_tok#\"}
+				setup_tok=${setup_tok%\"}
+				[ -n "$setup_tok" ] || continue
+				setup_append_csv setup_extra "$setup_tok" "$setup_extra"
+			done
+		fi
+	fi
+
+	if [ -z "$setup_nvidia" ]; then
+		if setup_dialog --title "NVIDIA" --yesno \
+			"Include NVIDIA open kernel modules and user-space utilities?
+
+This requires a matching kernel on the target and is off by default." 10 64
+		then
+			setup_nvidia=yes
+		else
+			setup_nvidia=no
+		fi
+	fi
+
+	if [ -z "$setup_flatpak" ]; then
+		if setup_dialog --title "Flatpak" --yesno \
+			"Install Flatpak and add the Flathub remote after package installation?" 9 64
+		then
+			setup_flatpak=yes
+		else
+			setup_flatpak=no
+		fi
+	fi
+
+	if [ -z "$setup_user" ]; then
+		setup_user=$(setup_dialog --title "First user" \
+			--inputbox "Unprivileged account (uid 1000). Leave empty to skip." \
+			9 50 "${setup_user:-}") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_user_gecos" ] && [ -n "$setup_user" ]; then
+		setup_user_gecos=$(setup_dialog --title "User full name" \
+			--inputbox "GECOS / display name for $setup_user." \
+			8 50 "$setup_user") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	if [ -z "$setup_shell" ]; then
+		setup_sh_args=
+		if [ -f "$setup_dir/shells" ]; then
+			while IFS= read -r setup_sh || [ -n "$setup_sh" ]; do
+				[ -n "$setup_sh" ] || continue
+				setup_sh_args="$setup_sh_args $setup_sh '$setup_sh'"
+			done <"$setup_dir/shells"
+		fi
+		[ -n "$setup_sh_args" ] || setup_sh_args="/bin/sh '/bin/sh' /bin/bash '/bin/bash'"
+		setup_shell=$(eval "setup_dialog --title 'Login shell' \
+			--menu 'Login shell for the first user, or the default account later. zsh and fish need the shells-extra set.' 16 64 6 $setup_sh_args") ||
+			setup_fail "$SPS_EX_USAGE" "cancelled"
+	fi
+
+	setup_dialog --title "Network" --msgbox \
+		"This installer does not configure DHCP, static addresses, or wireless.
+
+After the first boot, put an interface file in /etc/network or use the NetworkManager applet on Plasma. Notes are written to /root/sps-network.txt." 12 64 || :
+
+	setup_dialog --title "Disks" --msgbox \
+		"This installer does not partition, format, or write /etc/fstab.
+
+Mount the destination filesystem on the target path before running setup. Swap and fstab remain administrator work. Notes are written to /root/sps-fstab.txt." 12 64 || :
+
+	setup_bl=$(setup_dialog --title "Bootloader" \
+		--menu "SPS never runs grub-install unless you pass --install-bootloader. The usual choice is to write notes only." \
+		14 64 3 \
+		none "Write /root/sps-bootloader.txt only" \
+		grub "Write GRUB notes (still does not install)") ||
+		setup_fail "$SPS_EX_USAGE" "cancelled"
+	setup_bootloader=$setup_bl
+fi
+
+# Non-interactive defaults
+[ -n "$setup_target" ] || setup_target=/mnt
+[ -n "$setup_profile" ] || setup_profile=minimal
+[ -n "$setup_hostname" ] || setup_hostname=sps
+[ -n "$setup_timezone" ] || setup_timezone=UTC
+[ -n "$setup_keymap" ] || setup_keymap=us
+[ -n "$setup_locale" ] || setup_locale=C.UTF-8
+[ -n "$setup_shell" ] || setup_shell=/bin/sh
+setup_flatpak=$(setup_yesno_norm "${setup_flatpak:-no}")
+setup_nvidia=$(setup_yesno_norm "${setup_nvidia:-no}")
+setup_ssh=$(setup_yesno_norm "${setup_ssh:-no}")
+setup_printing=$(setup_yesno_norm "${setup_printing:-no}")
+case $setup_bootloader in
+	none|grub) ;;
+	*) setup_fail "$SPS_EX_USAGE" "bootloader must be none or grub" ;;
+esac
+
+setup_valid_profile "$setup_profile" ||
+	setup_fail "$SPS_EX_NOTFOUND" "unknown profile: $setup_profile"
+
+case $setup_target in
+	/*) ;;
+	*) setup_fail "$SPS_EX_USAGE" "target must be an absolute path: $setup_target" ;;
+esac
+
+case $setup_hostname in
+	*[!A-Za-z0-9.-]*|'') setup_fail "$SPS_EX_USAGE" "invalid hostname: $setup_hostname" ;;
+esac
+
+case $setup_locale in
+	*[!A-Za-z0-9._+-]*|'') setup_fail "$SPS_EX_USAGE" "invalid locale: $setup_locale" ;;
+esac
+
+case $setup_shell in
+	/*) ;;
+	*) setup_fail "$SPS_EX_USAGE" "shell must be an absolute path: $setup_shell" ;;
+esac
+
+if [ -n "$setup_user" ]; then
+	case $setup_user in
+		*[!a-z0-9_-]*|''|root|nobody)
+			setup_fail "$SPS_EX_USAGE" "invalid user name: $setup_user"
+			;;
+	esac
+fi
+
+# Non-interactive installs start from profile defaults, then --enable.
+if [ "$setup_interactive" -eq 0 ]; then
+	setup_defs=$(setup_default_sets_for_profile)
+	for setup_def in $setup_defs; do
+		setup_append_csv setup_enable "$setup_def" "$setup_enable"
+	done
+fi
+
+# Apply nvidia/flatpak convenience flags to sets.
+if [ "$setup_nvidia" = yes ]; then
+	setup_append_csv setup_enable nvidia "$setup_enable"
+fi
+if [ "$setup_flatpak" = yes ]; then
+	setup_append_csv setup_enable flatpak "$setup_enable"
+fi
+if [ "$setup_ssh" = yes ]; then
+	setup_append_csv setup_enable ssh "$setup_enable"
+fi
+if [ "$setup_printing" = yes ]; then
+	setup_append_csv setup_enable printing "$setup_enable"
+fi
+case $setup_shell in
+	*/zsh|*/fish)
+		setup_append_csv setup_enable shells-extra "$setup_enable"
+		;;
+esac
+
+# --disable wins
+setup_en_new=
+for setup_item in $setup_enable; do
+	setup_skip=0
+	for setup_d in $setup_disable; do
+		[ "$setup_item" = "$setup_d" ] && setup_skip=1
+	done
+	[ "$setup_skip" -eq 0 ] || continue
+	setup_valid_set "$setup_item" ||
+		setup_fail "$SPS_EX_NOTFOUND" "unknown set: $setup_item"
+	setup_en_new="$setup_en_new $setup_item"
+done
+setup_enable=$(printf '%s' "$setup_en_new" | sed 's/^ //')
+
+setup_pkg_file=$setup_work/packages
+: >"$setup_pkg_file"
+setup_add_pkg()
+{
+	setup_pkg=$1
+	[ -n "$setup_pkg" ] || return 0
+	grep -qx "$setup_pkg" "$setup_pkg_file" 2>/dev/null && return 0
+	printf '%s\n' "$setup_pkg" >>"$setup_pkg_file"
+}
+
+setup_packages_from "$(setup_profile_file "$setup_profile")" |
+	while IFS= read -r setup_pkg; do
+		setup_add_pkg "$setup_pkg"
+	done
+
+# The pipeline subshell cannot update the file... actually it can, the file is
+# in the filesystem. setup_add_pkg appends. Good.
+
+for setup_item in $setup_enable; do
+	setup_packages_from "$(setup_set_file "$setup_item")" |
+		while IFS= read -r setup_pkg; do
+			setup_add_pkg "$setup_pkg"
+		done
+done
+
+for setup_item in $setup_extra; do
+	setup_add_pkg "$setup_item"
+done
+
+setup_write_answers_file()
+{
+	setup_out=$1
+	{
+		printf 'target %s\n' "$setup_target"
+		printf 'profile %s\n' "$setup_profile"
+		printf 'hostname %s\n' "$setup_hostname"
+		printf 'timezone %s\n' "$setup_timezone"
+		printf 'keymap %s\n' "$setup_keymap"
+		printf 'locale %s\n' "$setup_locale"
+		printf 'shell %s\n' "$setup_shell"
+		[ -n "$setup_user" ] && printf 'user %s\n' "$setup_user"
+		[ -n "$setup_user_gecos" ] && printf 'user-gecos %s\n' "$setup_user_gecos"
+		printf 'flatpak %s\n' "$setup_flatpak"
+		printf 'nvidia %s\n' "$setup_nvidia"
+		printf 'ssh %s\n' "$setup_ssh"
+		printf 'printing %s\n' "$setup_printing"
+		printf 'bootloader %s\n' "$setup_bootloader"
+		printf 'update %s\n' "$([ "$setup_do_update" -eq 1 ] && printf yes || printf no)"
+		for setup_item in $setup_enable; do
+			printf 'enable %s\n' "$setup_item"
+		done
+		for setup_item in $setup_extra; do
+			printf 'extra %s\n' "$setup_item"
+		done
+	} >"$setup_out"
+}
+
+setup_summary()
+{
+	printf 'Target:     %s\n' "$setup_target"
+	printf 'Profile:    %s\n' "$setup_profile"
+	printf 'Hostname:   %s\n' "$setup_hostname"
+	printf 'Timezone:   %s\n' "$setup_timezone"
+	printf 'Keymap:     %s\n' "$setup_keymap"
+	printf 'Locale:     %s\n' "$setup_locale"
+	printf 'Shell:      %s\n' "$setup_shell"
+	printf 'User:       %s\n' "${setup_user:-<none>}"
+	printf 'Sets:       %s\n' "${setup_enable:-<none>}"
+	printf 'Extra:      %s\n' "${setup_extra:-<none>}"
+	printf 'Flatpak:    %s\n' "$setup_flatpak"
+	printf 'NVIDIA:     %s\n' "$setup_nvidia"
+	printf 'SSH:        %s\n' "$setup_ssh"
+	printf 'Printing:   %s\n' "$setup_printing"
+	printf 'Bootloader: %s\n' "$setup_bootloader"
+	printf '\nPackages:\n'
+	sort -u "$setup_pkg_file"
+}
+
+if [ -n "$setup_answers_out" ]; then
+	setup_write_answers_file "$setup_answers_out"
+fi
+
+if [ "$setup_plan" -eq 1 ]; then
+	setup_summary
+	exit 0
+fi
+
+if [ "$setup_interactive" -eq 1 ]; then
+	setup_confirm "Install with this configuration?
+
+$(setup_summary)
+
+Package builds run on this terminal. Source packages may take a long time." ||
+		setup_fail "$SPS_EX_USAGE" "cancelled"
+fi
+
+setup_apply_config()
+{
+	setup_etc=$setup_root/etc
+	mkdir -p "$setup_etc/sps" "$setup_root/root" \
+		"$setup_etc" ||
+		setup_fail "$SPS_EX_PERMISSION" "cannot create target directories"
+
+	printf '%s\n' "$setup_hostname" >"$setup_etc/hostname"
+	printf '%s\n' "$setup_timezone" >"$setup_etc/timezone"
+	printf 'KEYMAP=%s\n' "$setup_keymap" >"$setup_etc/vconsole.conf"
+	printf 'keymap %s\n' "$setup_keymap" >"$setup_etc/sps/keymap"
+	printf 'LANG=%s\n' "$setup_locale" >"$setup_etc/locale.conf"
+
+	if [ -f /usr/share/zoneinfo/"$setup_timezone" ]; then
+		cp /usr/share/zoneinfo/"$setup_timezone" "$setup_etc/localtime" 2>/dev/null || :
+	fi
+
+	setup_write_answers_file "$setup_etc/sps/setup-answers"
+
+	if [ -n "$setup_user" ]; then
+		setup_home=/home/$setup_user
+		mkdir -p "$setup_root$setup_home"
+		if [ ! -f "$setup_etc/passwd" ]; then
+			printf '%s\n' \
+				'root:x:0:0:root:/root:/bin/sh' \
+				'nobody:x:65534:65534:Unprivileged User:/:/usr/bin/nologin' \
+				>"$setup_etc/passwd"
+			printf '%s\n' 'root:x:0:' 'nogroup:x:65534:' \
+				>"$setup_etc/group"
+			printf '%s\n' \
+				'root:!:0:0:99999:7:::' \
+				'nobody:!:0:0:99999:7:::' \
+				>"$setup_etc/shadow"
+		fi
+		if ! grep -q "^$setup_user:" "$setup_etc/passwd"; then
+			printf '%s:x:1000:1000:%s:%s:%s\n' \
+				"$setup_user" \
+				"${setup_user_gecos:-$setup_user}" \
+				"$setup_home" \
+				"$setup_shell" >>"$setup_etc/passwd"
+		fi
+		if [ -f "$setup_etc/group" ] &&
+		   ! grep -q "^$setup_user:" "$setup_etc/group"; then
+			printf '%s:x:1000:\n' "$setup_user" >>"$setup_etc/group"
+		fi
+		if [ -f "$setup_etc/shadow" ] &&
+		   ! grep -q "^$setup_user:" "$setup_etc/shadow"; then
+			printf '%s:!:0:0:99999:7:::\n' "$setup_user" >>"$setup_etc/shadow"
+		fi
+		cat >"$setup_root/root/sps-user.txt" <<EOF
+The account '$setup_user' (uid 1000) was recorded in /etc/passwd.
+The password is locked. From a live root run:
+
+    passwd $setup_user
+EOF
+	fi
+
+	cat >"$setup_root/root/sps-bootloader.txt" <<EOF
+SPS does not run grub-install or change EFI variables during setup.
+
+To install GRUB yourself, after chrooting into $setup_root:
+
+    grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=SPS
+    grub-mkconfig -o /boot/grub/grub.cfg
+
+Or for BIOS:
+
+    grub-install /dev/DISK
+    grub-mkconfig -o /boot/grub/grub.cfg
+
+Replace DISK with the actual disk. This is an administrator action.
+EOF
+
+	cat >"$setup_root/root/sps-network.txt" <<EOF
+SPS setup does not write DHCP or static network configuration.
+
+On a Plasma system, use plasma-nm after the first graphical login.
+On a console system, configure the interface by hand, for example with
+ip(8) and a simple /etc/network script of your own.
+
+OpenSSH is installed when the server profile or the ssh set is selected.
+EOF
+
+	cat >"$setup_root/root/sps-fstab.txt" <<EOF
+SPS setup does not partition disks or write /etc/fstab.
+
+Mount the target filesystem on the path you passed to --target (or the
+target screen) before running the installer. Add swap and extra mounts
+yourself after inspecting lsblk and writing /etc/fstab.
+EOF
+}
+
+# Packages first, then hostname/user so filesystem's passwd is already present.
+setup_root=${setup_target%/}
+mkdir -p "$setup_root" ||
+	setup_fail "$SPS_EX_PERMISSION" "cannot create target: $setup_root"
+
+export SPS_ROOT=$setup_root
+
+if [ "$setup_do_update" -eq 1 ]; then
+	printf '%s: updating source repositories\n' "$sps_prog" >&2
+	src update || setup_fail "$SPS_EX_NETWORK" "src update failed"
+fi
+
+setup_pkgs=
+while IFS= read -r setup_pkg; do
+	[ -n "$setup_pkg" ] || continue
+	setup_pkgs="$setup_pkgs $setup_pkg"
+done <<EOF
+$(sort -u "$setup_pkg_file")
+EOF
+
+printf '%s: installing packages:%s\n' "$sps_prog" "$setup_pkgs" >&2
+# sget treats requested names as explicit world roots.
+# shellcheck disable=SC2086
+sget install $setup_pkgs ||
+	setup_fail "$SPS_EX_BUILD" "package installation failed"
+
+# Hostname, user, and notes land after packages so filesystem's passwd wins.
+setup_apply_config
+
+if [ "$setup_flatpak" = yes ]; then
+	setup_fp=$setup_root/usr/bin/flatpak
+	if [ -x "$setup_fp" ]; then
+		printf '%s: adding Flathub remote\n' "$sps_prog" >&2
+		"$setup_fp" --installation=system remote-add --if-not-exists flathub \
+			https://dl.flathub.org/repo/flathub.flatpakrepo \
+			--user=false 2>/dev/null ||
+			SPS_ROOT=$setup_root "$setup_fp" remote-add --if-not-exists \
+				flathub https://dl.flathub.org/repo/flathub.flatpakrepo ||
+			printf '%s: could not add Flathub; add it after first boot\n' \
+				"$sps_prog" >&2
+	else
+		printf '%s: flatpak is not in the target yet; Flathub skipped\n' \
+			"$sps_prog" >&2
+	fi
+fi
+
+if [ "$setup_install_bootloader" -eq 1 ]; then
+	command -v grub-install >/dev/null 2>&1 ||
+		setup_fail "$SPS_EX_PACKAGE" "grub-install is not available"
+	setup_fail "$SPS_EX_USAGE" \
+		"--install-bootloader requires a disk argument; refusing to guess. See /root/sps-bootloader.txt"
+fi
+
+setup_msg "Installation finished.
+
+Target: $setup_root
+Answers: $setup_root/etc/sps/setup-answers
+Bootloader notes: $setup_root/root/sps-bootloader.txt
+
+Set passwords before the first login. If you selected NVIDIA, rebuild the
+kernel modules against the installed kernel."
+
+exit 0
diff --git a/lib/mkiso/initrd-init b/lib/mkiso/initrd-init
new file mode 100755
index 0000000..f245fd5
--- /dev/null
+++ b/lib/mkiso/initrd-init
@@ -0,0 +1,75 @@
+#!/bin/sh
+# Live initramfs. Mount the ISO, squashfs, and overlay, then switch_root.
+
+PATH=/bin:/sbin:/usr/bin:/usr/sbin
+export PATH
+
+/bin/busybox --install -s /bin 2>/dev/null || :
+# Applets may already be relative links from mkiso.
+
+mkdir -p /proc /sys /dev /tmp /mnt/iso /mnt/ro /mnt/rw /mnt/new
+
+mount -t proc proc /proc
+mount -t sysfs sysfs /sys
+mount -t devtmpfs devtmpfs /dev 2>/dev/null || mount -t tmpfs tmpfs /dev
+mkdir -p /dev/pts
+mount -t devpts devpts /dev/pts 2>/dev/null || :
+
+# Load filesystem modules when they are not built in.
+for mod in loop squashfs overlay isofs vfat nls_cp437 nls_iso8859-1 \
+	usb-storage xhci-pci ehci-pci uhci-hcd sr_mod sd_mod ahci nvme \
+	virtio_blk virtio_pci virtio_scsi virtio_net
+do
+	modprobe "$mod" 2>/dev/null || :
+done
+
+sleep 1
+
+found=
+for i in 1 2 3 4 5 6 7 8 9 10; do
+	for dev in /dev/sr0 /dev/sr1 /dev/cdrom \
+		/dev/nvme0n1p1 /dev/nvme1n1p1 \
+		/dev/sda /dev/sda1 /dev/sdb /dev/sdb1 \
+		/dev/vda /dev/vda1 /dev/vdb /dev/vdb1
+	do
+		[ -b "$dev" ] || continue
+		if mount -t iso9660 -o ro "$dev" /mnt/iso 2>/dev/null ||
+		   mount -o ro "$dev" /mnt/iso 2>/dev/null; then
+			if [ -f /mnt/iso/live/filesystem.squashfs ]; then
+				found=$dev
+				break
+			fi
+			umount /mnt/iso 2>/dev/null || :
+		fi
+	done
+	[ -n "$found" ] && break
+	sleep 1
+done
+
+if [ -z "$found" ]; then
+	printf '%s\n' "mkiso: cannot find the SPS live filesystem on a CD/USB."
+	printf '%s\n' "Dropping to a shell."
+	exec setsid /bin/sh </dev/console >/dev/console 2>&1
+fi
+
+mount -t squashfs -o ro /mnt/iso/live/filesystem.squashfs /mnt/ro || {
+	printf '%s\n' "mkiso: squashfs mount failed"
+	exec setsid /bin/sh </dev/console >/dev/console 2>&1
+}
+
+mount -t tmpfs tmpfs /mnt/rw
+mkdir -p /mnt/rw/upper /mnt/rw/work
+mount -t overlay overlay \
+	-o lowerdir=/mnt/ro,upperdir=/mnt/rw/upper,workdir=/mnt/rw/work \
+	/mnt/new || {
+	printf '%s\n' "mkiso: overlay mount failed"
+	exec setsid /bin/sh </dev/console >/dev/console 2>&1
+}
+
+mkdir -p /mnt/new/mnt/iso /mnt/new/proc /mnt/new/sys /mnt/new/dev /mnt/new/run /mnt/new/tmp
+mount --move /mnt/iso /mnt/new/mnt/iso 2>/dev/null || :
+mount --move /proc /mnt/new/proc 2>/dev/null || :
+mount --move /sys /mnt/new/sys 2>/dev/null || :
+mount --move /dev /mnt/new/dev 2>/dev/null || :
+
+exec switch_root /mnt/new /sbin/init
diff --git a/lib/mkiso/issue b/lib/mkiso/issue
new file mode 100644
index 0000000..fd669f6
--- /dev/null
+++ b/lib/mkiso/issue
@@ -0,0 +1,3 @@
+SPS Linux live (console)
+
+Mount a destination filesystem on /mnt, then run: setup
diff --git a/lib/mkiso/live-init b/lib/mkiso/live-init
new file mode 100755
index 0000000..491b29b
--- /dev/null
+++ b/lib/mkiso/live-init
@@ -0,0 +1,72 @@
+#!/bin/sh
+# PID 1 on the live squashfs. Bring up consoles and optionally start setup.
+
+PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+if [ -d /usr/lib64/go1.27.0/go/bin ]; then
+	PATH=/usr/lib64/go1.27.0/go/bin:$PATH
+fi
+export PATH
+export HOME=/root
+export TERM=${TERM:-linux}
+export CARGO_HOME=${CARGO_HOME:-/root/.cargo}
+
+mkdir -p /proc /sys /dev /run /tmp /root /mnt /etc/sps
+mount -t proc proc /proc 2>/dev/null || :
+mount -t sysfs sysfs /sys 2>/dev/null || :
+mount -t devtmpfs devtmpfs /dev 2>/dev/null || :
+mkdir -p /dev/pts /dev/shm
+mount -t devpts devpts /dev/pts 2>/dev/null || :
+mount -t tmpfs tmpfs /run 2>/dev/null || :
+mount -t tmpfs tmpfs /tmp 2>/dev/null || :
+
+if command -v ldconfig >/dev/null 2>&1; then
+	ldconfig 2>/dev/null || :
+fi
+
+ln -sf /proc/self/fd /dev/fd 2>/dev/null || :
+ln -sf /proc/self/fd/0 /dev/stdin 2>/dev/null || :
+ln -sf /proc/self/fd/1 /dev/stdout 2>/dev/null || :
+ln -sf /proc/self/fd/2 /dev/stderr 2>/dev/null || :
+
+hostname sps-live 2>/dev/null || :
+[ -f /etc/hostname ] || printf '%s\n' sps-live >/etc/hostname
+
+# Load remaining modules from the live image if present.
+if [ -d /lib/modules ] && command -v depmod >/dev/null 2>&1; then
+	depmod -a 2>/dev/null || :
+fi
+for mod in e1000 e1000e r8169 virtio_net usbnet hid-generic usbhid atkbd
+do
+	modprobe "$mod" 2>/dev/null || :
+done
+
+if command -v ip >/dev/null 2>&1; then
+	ip link set lo up 2>/dev/null || :
+fi
+if command -v dhcpcd >/dev/null 2>&1; then
+	dhcpcd -b 2>/dev/null || :
+elif command -v dhclient >/dev/null 2>&1; then
+	dhclient -nw 2>/dev/null || :
+fi
+
+cat /etc/issue 2>/dev/null || :
+cat /etc/motd 2>/dev/null || :
+
+cmdline=$(cat /proc/cmdline 2>/dev/null || :)
+case " $cmdline " in
+	*" sps=setup "*)
+		if command -v setup >/dev/null 2>&1; then
+			setup || :
+		fi
+		;;
+esac
+
+# Consoles: a root shell. There is no login prompt on the live image.
+# ttyS0 is for qemu -nographic; tty1 is a hardware VGA console.
+if [ -c /dev/tty1 ]; then
+	setsid /bin/sh -c 'exec /bin/sh <>/dev/tty1 >&0 2>&1' &
+fi
+if [ -c /dev/ttyS0 ]; then
+	exec setsid /bin/sh -c 'exec /bin/sh <>/dev/ttyS0 >&0 2>&1'
+fi
+exec /bin/sh </dev/console >/dev/console 2>&1
diff --git a/lib/mkiso/motd b/lib/mkiso/motd
new file mode 100644
index 0000000..595f2ba
--- /dev/null
+++ b/lib/mkiso/motd
@@ -0,0 +1,20 @@
+SPS Linux live TTY
+
+This disc boots a console. It does not partition disks.
+
+1. Plug in or identify the destination disk (lsblk).
+2. Partition and format it yourself, then mount it on /mnt.
+3. Run:  setup --target /mnt
+   Or just: setup
+   and point the target screen at /mnt.
+
+Optional: setup --plan --profile plasma-full --target /mnt
+
+Recipes for sps-core and sps-extra are in /usr/src/sps. Package
+builds run on this live system into the mounted target. A Plasma
+install compiles Qt and KDE from source and takes a long time.
+
+Kernel modules match the kernel on this disc. NVIDIA modules still
+need that kernel after the first boot of the installed system.
+
+Root has an open shell on the live image. Set passwords after install.
diff --git a/lib/mkiso/seed-bins b/lib/mkiso/seed-bins
new file mode 100644
index 0000000..c6b3be3
--- /dev/null
+++ b/lib/mkiso/seed-bins
@@ -0,0 +1,127 @@
+/bin/sh
+/bin/bash
+/bin/busybox
+/usr/bin/dialog
+/usr/bin/git
+/usr/bin/curl
+/usr/bin/wget
+/usr/bin/make
+/usr/bin/gcc
+/usr/bin/g++
+/usr/bin/cc
+/usr/bin/c++
+/usr/bin/as
+/usr/bin/ld
+/usr/bin/ar
+/usr/bin/ranlib
+/usr/bin/nm
+/usr/bin/objdump
+/usr/bin/strip
+/usr/bin/pkgconf
+/usr/bin/pkg-config
+/usr/bin/cmake
+/usr/bin/ninja
+/usr/bin/meson
+/usr/bin/python3
+/usr/bin/python3.12
+/usr/bin/perl
+/usr/bin/rsync
+/usr/bin/tar
+/usr/bin/xz
+/usr/bin/gzip
+/usr/bin/bzip2
+/usr/bin/zstd
+/usr/bin/unzip
+/usr/bin/zip
+/usr/bin/file
+/usr/bin/less
+/usr/bin/nano
+/usr/bin/vim
+/usr/bin/ssh
+/usr/bin/scp
+/usr/bin/openssl
+/usr/sbin/ip
+/sbin/ip
+/bin/ip
+/sbin/modprobe
+/sbin/lsmod
+/sbin/depmod
+/sbin/insmod
+/usr/bin/lsblk
+/usr/bin/blkid
+/usr/sbin/blkid
+/usr/sbin/fdisk
+/usr/sbin/sfdisk
+/usr/sbin/mkfs
+/usr/sbin/mkfs.ext4
+/sbin/mkfs.ext4
+/usr/sbin/mkfs.vfat
+/sbin/mkfs.vfat
+/usr/sbin/mkswap
+/sbin/mkswap
+/usr/bin/mount
+/usr/bin/umount
+/bin/mount
+/bin/umount
+/usr/sbin/swapon
+/usr/sbin/swapoff
+/usr/bin/ls
+/usr/bin/cat
+/usr/bin/cp
+/usr/bin/mv
+/usr/bin/rm
+/usr/bin/mkdir
+/usr/bin/ln
+/usr/bin/chmod
+/usr/bin/chown
+/usr/bin/sed
+/usr/bin/grep
+/usr/bin/gawk
+/usr/bin/find
+/usr/bin/sort
+/usr/bin/head
+/usr/bin/tail
+/usr/bin/tr
+/usr/bin/cut
+/usr/bin/uname
+/usr/bin/tty
+/usr/bin/clear
+/usr/bin/reset
+/usr/bin/sha256sum
+/usr/bin/mktemp
+/usr/bin/install
+/usr/bin/which
+/usr/bin/env
+/usr/bin/ldd
+/usr/sbin/dhcpcd
+/sbin/dhcpcd
+/usr/bin/ping
+/bin/ping
+/usr/bin/dmesg
+/usr/bin/free
+/usr/bin/ps
+/usr/bin/top
+/usr/bin/kill
+/usr/bin/wget
+/usr/bin/curl
+/usr/bin/git
+/usr/bin/patch
+/usr/bin/diff
+/usr/sbin/grub-install
+/usr/bin/grub-mkconfig
+/usr/sbin/grub-mkconfig
+/usr/bin/grub-probe
+/usr/sbin/grub-probe
+/sbin/ldconfig
+/usr/sbin/ldconfig
+/usr/sbin/zic
+/usr/bin/zic
+/usr/bin/localedef
+/usr/bin/locale
+/usr/bin/getent
+/usr/bin/iconv
+/lib64/ld-linux-x86-64.so.2
+/usr/lib64/libgcc_s.so.1
+/usr/lib64/libstdc++.so.6
+/usr/lib64/libgomp.so.1
+/usr/lib64/libatomic.so.1
diff --git a/lib/setup/extras b/lib/setup/extras
new file mode 100644
index 0000000..b6269f9
--- /dev/null
+++ b/lib/setup/extras
@@ -0,0 +1,72 @@
+neovim	Neovim
+vim	Vim
+nano	GNU nano
+emacs	GNU Emacs
+helix	Helix editor
+kakoune	Kakoune editor
+htop	htop
+btop	btop
+tmux	tmux
+sudo	sudo
+doas	OpenDoas
+zsh	Z shell
+fish	fish shell
+git	Git
+rsync	rsync
+openssh	OpenSSH
+gnupg	GnuPG
+firefox	Firefox
+falkon	Falkon
+flatpak	Flatpak
+pipewire	PipeWire
+wget	wget
+curl	curl
+jq	jq
+ripgrep	ripgrep
+fd	fd
+bat	bat
+eza	eza
+fzf	fzf
+lazygit	lazygit
+fastfetch	fastfetch
+python	Python
+lua	Lua
+go	Go
+rust	Rust
+nodejs	Node.js
+perl	Perl
+ruby	Ruby
+cmake	CMake
+meson	Meson
+ninja	Ninja
+gdb	GDB
+strace	strace
+lsof	lsof
+nmap	nmap
+iperf3	iperf3
+cups	CUPS printing
+sddm	SDDM
+mesa	Mesa
+nvidia-open	NVIDIA open kernel modules
+e2fsprogs	e2fsprogs
+dosfstools	dosfstools
+cryptsetup	cryptsetup
+iwd	iwd
+dhcpcd	dhcpcd
+micro	micro editor
+starship	starship prompt
+yq	yq
+networkmanager	NetworkManager
+parted	GNU parted
+gptfdisk	GPT fdisk
+ntfs-3g	NTFS driver
+exfatprogs	exFAT tools
+mdadm	Linux MD RAID
+smartmontools	S.M.A.R.T. tools
+bluez	BlueZ Bluetooth
+alsa-utils	ALSA utilities
+vulkan-tools	vulkaninfo
+ethtool	ethtool
+nftables	nftables
+wireguard-tools	WireGuard tools
+
diff --git a/lib/setup/keymaps b/lib/setup/keymaps
new file mode 100644
index 0000000..90f7419
--- /dev/null
+++ b/lib/setup/keymaps
@@ -0,0 +1,21 @@
+us
+uk
+de
+fr
+es
+it
+ru
+br
+dvorak
+colemak
+jp
+kr
+cn
+tr
+pl
+nl
+se
+no
+fi
+dvorak-uk
+us-intl
diff --git a/lib/setup/locales b/lib/setup/locales
new file mode 100644
index 0000000..112bcb7
--- /dev/null
+++ b/lib/setup/locales
@@ -0,0 +1,32 @@
+C.UTF-8
+en_US.UTF-8
+en_GB.UTF-8
+de_DE.UTF-8
+fr_FR.UTF-8
+es_ES.UTF-8
+it_IT.UTF-8
+pt_BR.UTF-8
+pt_PT.UTF-8
+nl_NL.UTF-8
+pl_PL.UTF-8
+ru_RU.UTF-8
+uk_UA.UTF-8
+sv_SE.UTF-8
+nb_NO.UTF-8
+da_DK.UTF-8
+fi_FI.UTF-8
+cs_CZ.UTF-8
+hu_HU.UTF-8
+ro_RO.UTF-8
+tr_TR.UTF-8
+el_GR.UTF-8
+ja_JP.UTF-8
+ko_KR.UTF-8
+zh_CN.UTF-8
+zh_TW.UTF-8
+ar_SA.UTF-8
+he_IL.UTF-8
+hi_IN.UTF-8
+th_TH.UTF-8
+vi_VN.UTF-8
+id_ID.UTF-8
diff --git a/lib/setup/profiles/minimal.profile b/lib/setup/profiles/minimal.profile
new file mode 100644
index 0000000..5501cc7
--- /dev/null
+++ b/lib/setup/profiles/minimal.profile
@@ -0,0 +1,5 @@
+name        minimal
+title       Minimal console system
+description Core userland without a graphical environment.
+package     base-system
+package     dialog
diff --git a/lib/setup/profiles/plasma-desktop.profile b/lib/setup/profiles/plasma-desktop.profile
new file mode 100644
index 0000000..f25d095
--- /dev/null
+++ b/lib/setup/profiles/plasma-desktop.profile
@@ -0,0 +1,14 @@
+name        plasma-desktop
+title       Plasma Desktop
+description KDE Plasma desktop session without the full application suite.
+package     base-system
+package     dialog
+package     plasma-desktop
+package     sddm
+package     dejavu-fonts
+package     mesa
+package     pipewire
+package     wireplumber
+package     xdg-desktop-portal
+package     xdg-desktop-portal-kde
+package     power-profiles-daemon
diff --git a/lib/setup/profiles/plasma-full.profile b/lib/setup/profiles/plasma-full.profile
new file mode 100644
index 0000000..6a82ed0
--- /dev/null
+++ b/lib/setup/profiles/plasma-full.profile
@@ -0,0 +1,17 @@
+name        plasma-full
+title       Plasma Full
+description Complete Plasma desktop plus KDE applications.
+package     base-system
+package     dialog
+package     plasma-full
+package     sddm
+package     dejavu-fonts
+package     mesa
+package     pipewire
+package     wireplumber
+package     xdg-desktop-portal
+package     xdg-desktop-portal-kde
+package     power-profiles-daemon
+package     kde-apps
+package     firefox
+package     flatpak
diff --git a/lib/setup/profiles/server.profile b/lib/setup/profiles/server.profile
new file mode 100644
index 0000000..adb9a7c
--- /dev/null
+++ b/lib/setup/profiles/server.profile
@@ -0,0 +1,9 @@
+name        server
+title       Server
+description Minimal system plus OpenSSH, GnuPG, and rsync.
+package     base-system
+package     dialog
+package     openssh
+package     gnupg
+package     rsync
+package     sudo
diff --git a/lib/setup/sets/bluetooth.set b/lib/setup/sets/bluetooth.set
new file mode 100644
index 0000000..e4996cd
--- /dev/null
+++ b/lib/setup/sets/bluetooth.set
@@ -0,0 +1,5 @@
+name        bluetooth
+title       Bluetooth
+description BlueZ stack. Plasma Bluetooth UI comes with plasma-full.
+default     0
+package     bluez
diff --git a/lib/setup/sets/browsers.set b/lib/setup/sets/browsers.set
new file mode 100644
index 0000000..72ab03d
--- /dev/null
+++ b/lib/setup/sets/browsers.set
@@ -0,0 +1,5 @@
+name        browsers
+title       Web browsers
+description Firefox. Falkon is also pulled in by kde-apps.
+default     0
+package     firefox
diff --git a/lib/setup/sets/containers.set b/lib/setup/sets/containers.set
new file mode 100644
index 0000000..94ec15f
--- /dev/null
+++ b/lib/setup/sets/containers.set
@@ -0,0 +1,7 @@
+name        containers
+title       Containers
+description bubblewrap sandboxing. Docker/Podman are not part of the base set.
+default     0
+package     bubblewrap
+package     slirp4netns
+package     fuse-overlayfs
diff --git a/lib/setup/sets/devel-tools.set b/lib/setup/sets/devel-tools.set
new file mode 100644
index 0000000..abaa202
--- /dev/null
+++ b/lib/setup/sets/devel-tools.set
@@ -0,0 +1,5 @@
+name        devel-tools
+title       Development tools
+description Compilers' companions: cmake, meson, ninja, gdb, pkgconf, and autotools.
+default     0
+package     devel-tools
diff --git a/lib/setup/sets/editors-extra.set b/lib/setup/sets/editors-extra.set
new file mode 100644
index 0000000..3d8f61c
--- /dev/null
+++ b/lib/setup/sets/editors-extra.set
@@ -0,0 +1,7 @@
+name        editors-extra
+title       Extra editors
+description Emacs, Helix, and Kakoune in addition to nano/vim/neovim.
+default     0
+package     emacs
+package     helix
+package     kakoune
diff --git a/lib/setup/sets/firmware.set b/lib/setup/sets/firmware.set
new file mode 100644
index 0000000..7ac51fd
--- /dev/null
+++ b/lib/setup/sets/firmware.set
@@ -0,0 +1,5 @@
+name        firmware
+title       Firmware
+description linux-firmware blob collection. Needed for many NICs and GPUs.
+default     0
+package     linux-firmware
diff --git a/lib/setup/sets/flatpak.set b/lib/setup/sets/flatpak.set
new file mode 100644
index 0000000..0371803
--- /dev/null
+++ b/lib/setup/sets/flatpak.set
@@ -0,0 +1,9 @@
+name        flatpak
+title       Flatpak
+description Flatpak, bubblewrap, and the Flathub remote (added after install if selected).
+default     0
+package     flatpak
+package     bubblewrap
+package     xdg-dbus-proxy
+package     ostree
+package     xdg-desktop-portal
diff --git a/lib/setup/sets/fonts-extra.set b/lib/setup/sets/fonts-extra.set
new file mode 100644
index 0000000..9eaf652
--- /dev/null
+++ b/lib/setup/sets/fonts-extra.set
@@ -0,0 +1,8 @@
+name        fonts-extra
+title       Extra fonts
+description Additional fonts beyond the DejaVu set used by Plasma.
+default     0
+package     liberation-fonts
+package     noto-fonts
+package     noto-fonts-cjk
+package     noto-fonts-emoji
diff --git a/lib/setup/sets/kde-apps.set b/lib/setup/sets/kde-apps.set
new file mode 100644
index 0000000..e778882
--- /dev/null
+++ b/lib/setup/sets/kde-apps.set
@@ -0,0 +1,5 @@
+name        kde-apps
+title       KDE applications
+description Dolphin, Konsole, Kate, Ark, Okular, Gwenview, Spectacle, and other KDE apps.
+default     0
+package     kde-apps
diff --git a/lib/setup/sets/languages.set b/lib/setup/sets/languages.set
new file mode 100644
index 0000000..0056ed0
--- /dev/null
+++ b/lib/setup/sets/languages.set
@@ -0,0 +1,5 @@
+name        languages
+title       Language toolchains
+description Python, Lua, Go, Rust, Node.js, Perl, and Ruby interpreters and compilers.
+default     0
+package     languages
diff --git a/lib/setup/sets/multimedia.set b/lib/setup/sets/multimedia.set
new file mode 100644
index 0000000..97f9016
--- /dev/null
+++ b/lib/setup/sets/multimedia.set
@@ -0,0 +1,13 @@
+name        multimedia
+title       Multimedia
+description PipeWire, WirePlumber, FFmpeg, and desktop audio/video libraries.
+default     0
+package     pipewire
+package     wireplumber
+package     ffmpeg
+package     gstreamer
+package     gst-plugins-base
+package     gst-plugins-good
+package     alsa-lib
+package     alsa-utils
+package     vulkan-loader
diff --git a/lib/setup/sets/network-extra.set b/lib/setup/sets/network-extra.set
new file mode 100644
index 0000000..81d6f77
--- /dev/null
+++ b/lib/setup/sets/network-extra.set
@@ -0,0 +1,12 @@
+name        network-extra
+title       Extra networking
+description nmap, traceroute helpers, bind-tools, and wget/curl already in core.
+default     0
+package     nmap
+package     traceroute
+package     bind-tools
+package     iperf3
+package     wireshark-cli
+package     ethtool
+package     nftables
+package     wireguard-tools
diff --git a/lib/setup/sets/nvidia.set b/lib/setup/sets/nvidia.set
new file mode 100644
index 0000000..f24703f
--- /dev/null
+++ b/lib/setup/sets/nvidia.set
@@ -0,0 +1,7 @@
+name        nvidia
+title       NVIDIA drivers
+description NVIDIA open kernel modules and user-space driver. Requires a matching kernel.
+default     0
+package     nvidia-open
+package     nvidia-utils
+package     nvidia-settings
diff --git a/lib/setup/sets/office.set b/lib/setup/sets/office.set
new file mode 100644
index 0000000..aa813b4
--- /dev/null
+++ b/lib/setup/sets/office.set
@@ -0,0 +1,8 @@
+name        office
+title       Office and documents
+description Okular, Ark, and related document tools. LibreOffice is not packaged here.
+default     0
+package     okular
+package     ark
+package     gwenview
+package     spectacle
diff --git a/lib/setup/sets/power.set b/lib/setup/sets/power.set
new file mode 100644
index 0000000..e584422
--- /dev/null
+++ b/lib/setup/sets/power.set
@@ -0,0 +1,8 @@
+name        power
+title       Power management
+description UPower, power-profiles-daemon, and related session helpers.
+default     1
+package     upower
+package     power-profiles-daemon
+package     polkit
+package     elogind
diff --git a/lib/setup/sets/printing.set b/lib/setup/sets/printing.set
new file mode 100644
index 0000000..13e9912
--- /dev/null
+++ b/lib/setup/sets/printing.set
@@ -0,0 +1,7 @@
+name        printing
+title       Printing
+description CUPS and related printing support.
+default     0
+package     cups
+package     cups-filters
+package     print-manager
diff --git a/lib/setup/sets/qol-cli.set b/lib/setup/sets/qol-cli.set
new file mode 100644
index 0000000..d822105
--- /dev/null
+++ b/lib/setup/sets/qol-cli.set
@@ -0,0 +1,5 @@
+name        qol-cli
+title       Quality-of-life CLI
+description Fast search, listings, git helpers, and system monitors.
+default     1
+package     qol-cli
diff --git a/lib/setup/sets/shells-extra.set b/lib/setup/sets/shells-extra.set
new file mode 100644
index 0000000..80f02bb
--- /dev/null
+++ b/lib/setup/sets/shells-extra.set
@@ -0,0 +1,6 @@
+name        shells-extra
+title       Extra shells
+description zsh and fish in addition to bash.
+default     0
+package     zsh
+package     fish
diff --git a/lib/setup/sets/ssh.set b/lib/setup/sets/ssh.set
new file mode 100644
index 0000000..b208ac5
--- /dev/null
+++ b/lib/setup/sets/ssh.set
@@ -0,0 +1,5 @@
+name        ssh
+title       OpenSSH server
+description OpenSSH client and server. On by default for the server profile.
+default     0
+package     openssh
diff --git a/lib/setup/sets/storage.set b/lib/setup/sets/storage.set
new file mode 100644
index 0000000..8d4bd65
--- /dev/null
+++ b/lib/setup/sets/storage.set
@@ -0,0 +1,16 @@
+name        storage
+title       Disk tools
+description ext4, FAT, XFS, Btrfs, LVM, and cryptsetup.
+default     0
+package     e2fsprogs
+package     dosfstools
+package     xfsprogs
+package     btrfs-progs
+package     lvm2
+package     cryptsetup
+package     mdadm
+package     ntfs-3g
+package     exfatprogs
+package     parted
+package     gptfdisk
+package     smartmontools
diff --git a/lib/setup/sets/wifi.set b/lib/setup/sets/wifi.set
new file mode 100644
index 0000000..c7fb703
--- /dev/null
+++ b/lib/setup/sets/wifi.set
@@ -0,0 +1,7 @@
+name        wifi
+title       Wireless
+description iwd, wpa_supplicant, and iw.
+default     0
+package     iwd
+package     wpa_supplicant
+package     iw
diff --git a/lib/setup/shells b/lib/setup/shells
new file mode 100644
index 0000000..e4c43c8
--- /dev/null
+++ b/lib/setup/shells
@@ -0,0 +1,4 @@
+/bin/sh
+/bin/bash
+/usr/bin/zsh
+/usr/bin/fish
diff --git a/lib/setup/timezones b/lib/setup/timezones
new file mode 100644
index 0000000..ed2c5e3
--- /dev/null
+++ b/lib/setup/timezones
@@ -0,0 +1,28 @@
+UTC
+America/Los_Angeles
+America/Denver
+America/Chicago
+America/New_York
+America/Sao_Paulo
+America/Mexico_City
+America/Toronto
+Europe/London
+Europe/Berlin
+Europe/Paris
+Europe/Madrid
+Europe/Rome
+Europe/Amsterdam
+Europe/Warsaw
+Europe/Moscow
+Europe/Istanbul
+Africa/Cairo
+Africa/Johannesburg
+Asia/Tokyo
+Asia/Seoul
+Asia/Shanghai
+Asia/Singapore
+Asia/Kolkata
+Asia/Dubai
+Australia/Sydney
+Australia/Melbourne
+Pacific/Auckland
diff --git a/man/mkiso.1 b/man/mkiso.1
new file mode 100644
index 0000000..daedc93
--- /dev/null
+++ b/man/mkiso.1
@@ -0,0 +1,79 @@
+.TH MKISO 1 "2026-08-31" "SPS 1.0" "SPS Commands"
+.SH NAME
+mkiso \- build an SPS live TTY ISO
+.SH SYNOPSIS
+.nf
+mkiso --output FILE.iso [options]
+mkiso --layout DIR [options]
+.fi
+.SH DESCRIPTION
+.B mkiso
+builds a hybrid ISO that boots to a text console with
+.BR setup (1)
+on PATH. Flash it to USB, boot, mount a destination filesystem on
+.IR /mnt ,
+and run
+.B setup .
+.PP
+The image does not partition disks and never runs
+.B grub-install
+on a hard disk.
+.B --layout
+writes the live root only, for tests and inspection.
+.PP
+Host compiler tools can be copied into the image so
+.B setup
+can build packages from the bundled recipes. Pass
+.B --no-seed
+to omit them.
+.SH OPTIONS
+.TP
+.BI --output " file"
+Write a bootable ISO.
+.TP
+.BI --layout " dir"
+Populate a live root and exit.
+.TP
+.BI --busybox " file"
+Static busybox used for the initramfs and live
+.BR /bin/busybox .
+.TP
+.BI --kernel " file"
+Live kernel. Defaults to
+.IR /boot/vmlinuz .
+.TP
+.BI --core " dir"
+.TP
+.BI --extra " dir"
+Copy recipe trees to
+.I /usr/src/sps/core
+and
+.IR /usr/src/sps/extra .
+.TP
+.B --with-rust
+.TP
+.B --with-go
+Copy those host toolchains into the live image.
+.TP
+.B --with-firmware
+Copy
+.I /lib/firmware
+into the squashfs. This tree is often more than a gigabyte, so published
+ISOs omit it. Wireless adapters that need blobs will need firmware copied
+onto the target after install, or a locally built image with this flag.
+.TP
+.B --no-modules
+Do not copy the full
+.I /lib/modules
+tree into the squashfs. The initramfs still gets squashfs/overlay/USB
+modules so the disc can boot.
+.TP
+.B --no-seed
+Do not copy gcc, Python, and related host tools.
+.SH FILES
+.I $SPS_LIBDIR/mkiso
+holds the live init scripts, motd, and seed binary list.
+.SH SEE ALSO
+.BR setup (1),
+.BR sget (1),
+.BR src (1)
diff --git a/man/setup.1 b/man/setup.1
new file mode 100644
index 0000000..48a76a5
--- /dev/null
+++ b/man/setup.1
@@ -0,0 +1,173 @@
+.TH SETUP 1 "2026-08-31" "SPS 1.0" "SPS Commands"
+.SH NAME
+setup \- dialog-based SPS system installer
+.SH SYNOPSIS
+.nf
+setup [options]
+setup --list-profiles
+setup --list-sets
+setup --plan [options]
+.fi
+.SH DESCRIPTION
+.B setup
+installs an SPS system into a target root. Interactive use is a sequence of
+.BR dialog (1)
+screens in the same spirit as the Slackware and FreeBSD installers: profile,
+optional package sets, extra packages, locale, hostname, timezone, keymap,
+first user, login shell, then a visible package build.
+.PP
+Every screen has a command-line equivalent. Scripts and tests should pass
+.B --non-interactive
+or
+.B --plan .
+.B --plan
+does not start
+.BR dialog (1).
+.PP
+The installer does not partition disks, format filesystems, or run
+.B grub-install
+unless
+.B --install-bootloader
+is given, and even then it refuses to guess a disk. Bootloader instructions
+are written to
+.I /root/sps-bootloader.txt
+in the target.
+.SH PROFILES
+.TP
+.B minimal
+.B base-system
+and
+.BR dialog (1).
+.TP
+.B server
+Minimal plus OpenSSH, GnuPG, rsync, and sudo.
+.TP
+.B plasma-desktop
+KDE Plasma session without the full application suite.
+.TP
+.B plasma-full
+Plasma plus
+.BR kde-apps ,
+Firefox, and Flatpak.
+.SH SETS
+Optional sets are listed by
+.B --list-sets
+and stored as text files under
+.I $SPS_LIBDIR/setup/sets .
+They include quality-of-life CLI tools, development tools, language
+toolchains, KDE applications, NVIDIA drivers, Flatpak, power management,
+multimedia, extra network tools, extra editors, container helpers, printing,
+browsers, extra shells, extra fonts, office tools, and OpenSSH.
+.PP
+.B plasma-desktop
+and
+.B plasma-full
+turn the power and qol-cli sets on by default.
+.B --enable
+adds sets.
+.B --disable
+removes them.
+.SH OPTIONS
+.TP
+.BI --target " dir"
+Installation root. Must be an absolute path. Becomes
+.B SPS_ROOT
+for
+.BR src (1)
+and
+.BR sget (1).
+.TP
+.BI --profile " name"
+One of the profiles above.
+.TP
+.BI --enable " set[,set...]"
+.TP
+.BI --disable " set[,set...]"
+.TP
+.BI --hostname " name"
+.TP
+.BI --timezone " zone"
+.TP
+.BI --keymap " map"
+.TP
+.BI --locale " name"
+.B LANG
+written to
+.I /etc/locale.conf .
+Default
+.BR C.UTF-8 .
+.TP
+.BI --shell " path"
+Login shell for the first user. Default
+.IR /bin/sh .
+Choosing zsh or fish also enables the
+.B shells-extra
+set.
+.TP
+.BI --extra " pkg[,pkg...]"
+Individual packages in addition to the profile and sets.
+.TP
+.B --ssh yes|no
+Enable the OpenSSH set.
+.TP
+.B --printing yes|no
+Enable CUPS and related printing packages.
+.TP
+.BI --user " name"
+First unprivileged account (uid 1000). The password is left locked; see
+.I /root/sps-user.txt .
+.TP
+.B --flatpak yes|no
+Install the Flatpak set and attempt to add Flathub after packages land.
+.TP
+.B --nvidia yes|no
+Install NVIDIA open kernel modules and utilities. A matching kernel is
+required; this installer does not build one.
+.TP
+.B --bootloader none|grub
+Write notes only.
+.TP
+.B --install-bootloader
+Refuses to guess a disk. See the notes file.
+.TP
+.B --no-update
+Skip
+.B src update
+before
+.B sget install .
+.TP
+.B --non-interactive
+Never start
+.BR dialog (1).
+.TP
+.B --plan
+Print the resolved configuration and package list, then exit.
+.TP
+.BI --write-answers " file"
+.TP
+.BI --read-answers " file"
+Options override values from the file.
+.TP
+.B --list-profiles
+.TP
+.B --list-sets
+.SH FILES
+.TP
+.I $SPS_LIBDIR/setup
+Profiles, sets, keymaps, and timezones.
+.TP
+.I $SPS_SETUP_DIR
+Optional override of that directory, used by tests.
+.TP
+.I /etc/sps/setup-answers
+Copy of the resolved answers in the target root.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage errors, 3 when a profile or set is unknown,
+8 for permission problems, 9 when
+.B src update
+fails, and 10 when package installation fails.
+.SH SEE ALSO
+.BR dialog (1),
+.BR sget (1),
+.BR src (1),
+.BR pkin (1)
diff --git a/tests/setup-fixture/extras b/tests/setup-fixture/extras
new file mode 100644
index 0000000..d1cb581
--- /dev/null
+++ b/tests/setup-fixture/extras
@@ -0,0 +1 @@
+hello	Hello fixture
diff --git a/tests/setup-fixture/keymaps b/tests/setup-fixture/keymaps
new file mode 100644
index 0000000..2dc5cb6
--- /dev/null
+++ b/tests/setup-fixture/keymaps
@@ -0,0 +1,2 @@
+us
+de
diff --git a/tests/setup-fixture/locales b/tests/setup-fixture/locales
new file mode 100644
index 0000000..89c58c7
--- /dev/null
+++ b/tests/setup-fixture/locales
@@ -0,0 +1,2 @@
+C.UTF-8
+en_US.UTF-8
diff --git a/tests/setup-fixture/profiles/tiny.profile b/tests/setup-fixture/profiles/tiny.profile
new file mode 100644
index 0000000..fbdc10a
--- /dev/null
+++ b/tests/setup-fixture/profiles/tiny.profile
@@ -0,0 +1,4 @@
+name        tiny
+title       Tiny test profile
+description Fixture profile
+package     hello
diff --git a/tests/setup-fixture/sets/extra.set b/tests/setup-fixture/sets/extra.set
new file mode 100644
index 0000000..4ac2dc0
--- /dev/null
+++ b/tests/setup-fixture/sets/extra.set
@@ -0,0 +1,5 @@
+name        extra
+title       Extra fixture set
+description Fixture set
+default     0
+package     extra-pkg
diff --git a/tests/setup-fixture/shells b/tests/setup-fixture/shells
new file mode 100644
index 0000000..de0249b
--- /dev/null
+++ b/tests/setup-fixture/shells
@@ -0,0 +1,2 @@
+/bin/sh
+/bin/bash
diff --git a/tests/setup-fixture/timezones b/tests/setup-fixture/timezones
new file mode 100644
index 0000000..3578a16
--- /dev/null
+++ b/tests/setup-fixture/timezones
@@ -0,0 +1,2 @@
+UTC
+America/Los_Angeles
diff --git a/tests/test_mkiso.sh b/tests/test_mkiso.sh
new file mode 100755
index 0000000..c36c714
--- /dev/null
+++ b/tests/test_mkiso.sh
@@ -0,0 +1,52 @@
+#!/bin/sh
+# mkiso --help and --layout
+
+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_mkiso: %s\n' "$*" >&2; exit 1; }
+
+mkiso=$project_dir/bin/mkiso
+export SPS_LIBDIR=$project_dir/lib
+export PATH=$project_dir/bin:$PATH
+
+help=$("$mkiso" --help)
+case $help in
+	*'--output'*) ;;
+	*) fail 'help missing --output' ;;
+esac
+case $help in
+	*'--with-firmware'*) ;;
+	*) fail 'help missing --with-firmware' ;;
+esac
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-mkiso.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+layout=$tmp/live
+bb=
+if [ -x /tmp/sps-wave/busybox.static ]; then
+	bb=/tmp/sps-wave/busybox.static
+fi
+
+if [ -n "$bb" ]; then
+	"$mkiso" --layout "$layout" --no-seed --no-modules --busybox "$bb" \
+		>/dev/null
+else
+	"$mkiso" --layout "$layout" --no-seed --no-modules >/dev/null
+fi
+
+[ -f "$layout/etc/sps/live" ] || fail 'live marker missing'
+[ -f "$layout/etc/motd" ] || fail 'motd missing'
+[ -x "$layout/sbin/init" ] || fail 'live init missing'
+[ -x "$layout/usr/bin/setup" ] || fail 'setup not installed into layout'
+grep -q 'setup' "$layout/etc/motd" || fail 'motd does not mention setup'
+[ -f "$layout/etc/sps/repos.conf" ] || fail 'repos.conf missing'
+if [ -x "$layout/bin/busybox" ] && [ -L "$layout/bin/mkdir" ]; then
+	target=$(readlink "$layout/bin/mkdir")
+	case $target in
+		busybox|./busybox) ;;
+		*) fail "busybox applet mkdir should be a relative link, got $target" ;;
+	esac
+fi
diff --git a/tests/test_setup.sh b/tests/test_setup.sh
new file mode 100755
index 0000000..c9db617
--- /dev/null
+++ b/tests/test_setup.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+# setup: plan, answers, validation, and a fixture install.
+
+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_setup: %s\n' "$*" >&2; exit 1; }
+contains() { case $1 in *"$2"*) : ;; *) fail "expected output to contain '$2'" ;; esac; }
+
+setup=$project_dir/bin/setup
+export SPS_LIBDIR=$project_dir/lib
+export PATH=$project_dir/bin:$PATH
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-setup.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+profiles=$("$setup" --list-profiles)
+contains "$profiles" 'minimal'
+contains "$profiles" 'plasma-desktop'
+contains "$profiles" 'plasma-full'
+contains "$profiles" 'server'
+
+sets=$("$setup" --list-sets)
+contains "$sets" 'printing'
+contains "$sets" 'browsers'
+contains "$sets" 'shells-extra'
+contains "$sets" 'ssh'
+contains "$sets" 'storage'
+contains "$sets" 'wifi'
+contains "$sets" 'firmware'
+contains "$sets" 'bluetooth'
+
+plan=$("$setup" --plan --target /mnt --profile minimal --disable qol-cli)
+contains "$plan" 'Profile:    minimal'
+contains "$plan" 'base-system'
+contains "$plan" 'dialog'
+contains "$plan" 'Locale:     C.UTF-8'
+
+plan=$("$setup" --plan --target /mnt --profile plasma-desktop --user alex \
+	--hostname darkstar --timezone UTC --keymap us --nvidia yes \
+	--locale en_US.UTF-8 --shell /bin/bash --extra neovim,htop \
+	--ssh yes --printing yes)
+contains "$plan" 'Hostname:   darkstar'
+contains "$plan" 'User:       alex'
+contains "$plan" 'Locale:     en_US.UTF-8'
+contains "$plan" 'Shell:      /bin/bash'
+contains "$plan" 'plasma-desktop'
+contains "$plan" 'nvidia-open'
+contains "$plan" 'qol-cli'
+contains "$plan" 'neovim'
+contains "$plan" 'htop'
+contains "$plan" 'openssh'
+contains "$plan" 'cups'
+
+"$setup" --plan --target /mnt --profile server \
+	--write-answers "$tmp/answers" >/dev/null
+grep -qx 'profile server' "$tmp/answers" || fail 'answers missing profile'
+grep -qx 'target /mnt' "$tmp/answers" || fail 'answers missing target'
+
+plan=$("$setup" --plan --read-answers "$tmp/answers" --hostname other)
+contains "$plan" 'Hostname:   other'
+contains "$plan" 'Profile:    server'
+
+set +e
+"$setup" --plan --target /mnt --profile no-such-profile >/dev/null 2>"$tmp/err"
+st=$?
+set -e
+[ "$st" -eq 3 ] || fail "unknown profile returned $st"
+contains "$(cat "$tmp/err")" 'unknown profile'
+
+set +e
+"$setup" --plan --target mnt --profile minimal >/dev/null 2>"$tmp/err"
+st=$?
+set -e
+[ "$st" -eq 2 ] || fail "relative target returned $st"
+
+set +e
+"$setup" --plan --target /mnt --profile minimal --hostname 'bad host' \
+	>/dev/null 2>"$tmp/err"
+st=$?
+set -e
+[ "$st" -eq 2 ] || fail "bad hostname returned $st"
+
+# Fixture profile install into a disposable root.
+export SPS_SETUP_DIR=$project_dir/tests/setup-fixture
+root=$tmp/root
+db=$tmp/db
+cache=$tmp/cache
+build=$tmp/build
+repo=$tmp/repo
+mkdir -p "$root" "$db" "$cache" "$build" "$repo/hello" "$repo/extra-pkg"
+: >"$tmp/sps.conf"
+printf 'dir test %s 10\n' "$repo" >"$tmp/repos.conf"
+export SPS_ROOT=$root SPS_DB=$db SPS_CACHE=$cache SPS_BUILD=$build
+export SPS_CONFIG=$tmp/sps.conf SPS_REPOS_CONFIG=$tmp/repos.conf
+
+cat >"$repo/hello/recipe" <<'EOF'
+name        hello
+version     1.0
+release     1
+arch        any
+description fixture hello
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' hello >"$PKG/usr/share/sps-test/hello"
+EOF
+cat >"$repo/extra-pkg/recipe" <<'EOF'
+name        extra-pkg
+version     1.0
+release     1
+arch        any
+description fixture extra
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' extra >"$PKG/usr/share/sps-test/extra"
+EOF
+
+src update >/dev/null
+"$setup" --non-interactive --no-update --target "$root" --profile tiny \
+	--hostname fixture --timezone UTC --keymap us --user tester \
+	--enable extra --user-gecos 'Test User' >/dev/null
+
+[ "$(cat "$root/usr/share/sps-test/hello")" = hello ] || fail 'hello missing'
+[ "$(cat "$root/usr/share/sps-test/extra")" = extra ] || fail 'extra missing'
+[ "$(cat "$root/etc/hostname")" = fixture ] || fail 'hostname not written'
+[ "$(cat "$root/etc/locale.conf")" = 'LANG=C.UTF-8' ] || fail 'locale not written'
+grep -q '^tester:x:1000:1000:Test User:/home/tester:/bin/sh$' \
+	"$root/etc/passwd" || fail 'user not recorded'
+[ -f "$root/etc/sps/setup-answers" ] || fail 'setup-answers missing'
+[ -f "$root/root/sps-bootloader.txt" ] || fail 'bootloader notes missing'
+[ -f "$root/root/sps-network.txt" ] || fail 'network notes missing'
+[ -f "$root/root/sps-fstab.txt" ] || fail 'fstab notes missing'
+grep -q 'grub-install' "$root/root/sps-bootloader.txt" ||
+	fail 'bootloader notes omitted grub-install warning'