home · contact · privacy
Add AI accounting of code changes and their meanings. master
authorPlom Heller <plom@plomlompom.com>
Mon, 21 Sep 2026 11:45:32 +0000 (13:45 +0200)
committerPlom Heller <plom@plomlompom.com>
Mon, 21 Sep 2026 11:45:32 +0000 (13:45 +0200)
CLAUDE.md [new file with mode: 0644]

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644 (file)
index 0000000..192a9b0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,703 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with
+code in this repository.
+
+Organization principle: each mechanism is described in exactly one place
+(usually its own script's or file's section); everywhere else gets a pointer.
+When changing behavior, update that one place. Git history covers "what used
+to be there" — this file only says how things are and why.
+
+## Repository overview
+
+POSIX shell scripts for two related but distinct kinds of Debian setup, plus
+the plain data/skeleton files they install. No build system, package manager
+or test suite — the scripts and files are the entire deliverable.
+
+1. **Local, LUKS-encrypted multi-boot**: several mostly-isolated Debian
+   installs on one physical machine, all inside a single LUKS-encrypted LVM
+   volume group, sharing one large `data` LV. Driven from a rescue
+   environment on that machine by `setup_luksvg.sh`, `install_debian.sh`,
+   `chrooted_command.sh`. Installed systems refresh their own EFI boot files
+   after kernel upgrades via `update_efi.sh` — a deliberate, narrow exception
+   to the rescue environment's otherwise sole ownership of the EFI partition.
+2. **Remote server bootstrap**: pushing this whole repo over SSH onto an
+   already-installed, root-SSH-reachable Debian server unrelated to the local
+   LUKS+VG (e.g. a fresh cloud VM), then remotely triggering its first-login
+   setup. Driven from the admin's own workstation by `install_server.sh`.
+
+Both tracks converge on the same first-login family — `start_root_t490s.sh`
+(local installs) or `start_root_server.sh` (remote servers), then
+`start_user.sh` — sharing plumbing via `_lib.sh` and `scripts/lib/`.
+
+### Directories
+
+- `scripts/` — every shell script, plus `_lib.sh` and `lib/` (below). All
+  scripts source `_lib.sh` via `. "$(dirname "$0")/_lib.sh"` and expect it,
+  and `lib/`, alongside themselves.
+  - rescue environment: `setup_luksvg.sh`, `install_debian.sh`,
+    `chrooted_command.sh`
+  - admin workstation: `install_server.sh`
+  - inside a target system: `start_root_t490s.sh`, `start_root_server.sh`,
+    `start_user.sh`, `update_efi.sh` (local systems only, see below)
+  - optional, by hand on an already-set-up target: `setup_git_mirror.sh`
+- `to_install/` — per-target package lists (`t490s`, `server`), read in place
+  by `start_root` from `${PATH_REPO}/to_install/${TARGET}`.
+- `to_copy/` — plain files installed verbatim by a target-side script.
+  Currently `90-backlight.rules` (see "`start_root_t490s.sh`").
+- `templates/` — files rendered by `render_template` with `${VAR}`/`$(cmd)`
+  expansion, instead of inline heredocs: `fstab`, `wifi.nmconnection`
+  (rendered by `install_debian.sh`), `git-daemon.service`
+  (`setup_git_mirror.sh`), `update-efi-hook` (`start_root_t490s.sh`).
+- `home/` — dotfile skeletons `home/any/`, `home/root/`, `home/user/`,
+  symlinked file-by-file into an account's home by `link_home` (see below).
+  `link_home <target>` always links `home/any/` (what every account wants)
+  plus `home/<target>/`.
+
+The rescue-environment scripts only need `scripts/`, `to_copy/`, `templates/`
+(the latter because `install_debian.sh` renders two of its files from it);
+`to_install/` and `home/` matter only once a target's own first-login scripts
+run. `setup_luksvg.sh` and `install_server.sh` both copy the *whole* repo
+root, so all of it is present on every target.
+
+## Commands
+
+- Syntax check: `sh -n scripts/<script>.sh`
+- Lint (run from `scripts/`, so `./_lib.sh` resolves):
+  `shellcheck -s dash -x <script>.sh` (also fine on `_lib.sh`). `-x` follows
+  only real `.` sources (`_lib.sh`, its `. lib/include.sh`), not the
+  `include <NAME>` function calls, so lint `lib/` modules separately:
+  `shellcheck -s dash -x lib/<NAME>.sh` (expect SC2034 noise for constants
+  used by other files).
+- One-time volume setup (destructive): `sh scripts/setup_luksvg.sh`
+- New local OS instance (destructive):
+  `sh scripts/install_debian.sh <boot-name>`
+- Command inside an installed local instance:
+  `sh scripts/chrooted_command.sh <root-name> <command>`
+- Refresh an installed local system's EFI boot files (run there, as root;
+  normally unnecessary, since the `update-initramfs` hook installed by
+  `start_root_t490s.sh` already does it): `sh scripts/update_efi.sh`
+- Push repo to a remote server and start its first-login setup (from the
+  admin's workstation): `sh scripts/install_server.sh <server>`
+- First login on a new local system: nothing to type — root's first console
+  login runs `start_root_t490s.sh` via a hook left by `install_debian.sh`.
+- First login on a new remote server: triggered by `install_server.sh`.
+- Both first-login scripts can be rerun by hand later (e.g.
+  `/data/confplom/scripts/start_root_t490s.sh`, or
+  `~/confplom/scripts/start_root_server.sh`); so can `start_user.sh`.
+- Make an already-set-up target a distribution point for fresh systems:
+  `sh scripts/setup_git_mirror.sh`; a fresh system then runs
+  `git clone git://<that-server>/<repo-dirname>.git`.
+
+There is no test runner. For logic changes prefer a dry read-through plus
+`sh -n`/`shellcheck -s dash`; never execute the scripts against real block
+devices or real remote servers to "test" them.
+
+## Hardcoded machine assumptions
+
+- **One LUKS+VG per machine.** The VG/LUKS-mapper name is the constant
+  `NAME_LUKSVG` (`cryptolvm`), not an argument of any script. Don't
+  reintroduce it as one without deliberately deciding to support more than
+  one such construct.
+- **One NVMe drive, three partitions.** `PATH_BOOT_DEVICE` bakes in
+  `/dev/nvme0n1`; partition 1 is EFI (`IDX_PARTITION_EFI`, its own constant
+  because two call sites need the bare index), 2 is the rescue environment's
+  root (hardcoded inside `check_boot_device_layout` only), 3 is the LUKS+VG
+  (hardcoded in `PATH_PARTITION_LUKSVG` only). No script takes a partition
+  argument. Changes to these are environment-specific, not bug fixes; a
+  second NVMe controller would need e.g. UUID-based lookup instead.
+  The bet is *checked*, not just assumed: `setup_luksvg.sh` and
+  `install_debian.sh` (the two that create/format things) start with
+  `check_boot_device_layout` — exactly one `nvme*n[0-9]` device under
+  `${PATH_DEV}` and it's `${PATH_BOOT_DEVICE}`; exactly three `pN`
+  partitions, each a block device; `p2` currently mounted as `/` (i.e. we
+  really are in the rescue environment); `p1` mounted at `${PATH_EFI}`.
+  `chrooted_command.sh` skips it: it only opens an LV inside an existing VG.
+- `start_root_t490s.sh` is named after this particular laptop for the same
+  reason: it's this machine's setup, not a generic "desktop" variant.
+- **`NAME_BOOT` and `NAME_ROOT` are one input** in `install_debian.sh`: the
+  boot name is the EFI boot label, the root LV name *and* the new system's
+  hostname. `update_efi.sh` depends on that last part. Don't conflate this
+  with `NAME_LUKSVG`.
+- **Don't hardcode this checkout's directory name (`confplom`) or `scripts`.**
+  Use `DIRNAME_REPO` and `DIRNAME_SCRIPTS` (below), so a renamed checkout or
+  fork still works.
+- **No secrets in scripts** (all git-tracked). E.g. the wifi passphrase is
+  parsed at runtime out of the rescue environment's
+  `/etc/network/interfaces` and re-emitted into a NetworkManager keyfile in
+  the target. The repo therefore carries nothing secret, which is what makes
+  serving it anonymously via `setup_git_mirror.sh` acceptable.
+
+## Shared library
+
+### Bootstrap: `_lib.sh` and `include`
+
+`_lib.sh` (never run directly) does only the things that must happen first:
+`set -Ceu` (errexit/nounset/noclobber, so everything after is covered);
+`SCRIPT_NAME=$0` (sourcing doesn't change `$0`, so `msg`/`die` are
+attributed to the executable script); `cd "$(dirname "${SCRIPT_NAME}")"`
+(so every script's relative references resolve regardless of the caller's
+cwd, and every script must live in `scripts/`); and `. lib/include.sh` — the
+one raw dot-source in the repo, since it defines `include`.
+
+Everything else shared lives in one file per constant/helper under
+`scripts/lib/`, named exactly after what it declares. Both modules and
+executable scripts pull dependencies in with `include <NAME>` (bare name;
+`include` builds `lib/<NAME>.sh`). `include` records loaded names in
+`INCLUDED` and makes repeat calls no-ops, so a file only ever runs once per
+run however many places name it. Convention: every module and script
+`include`s every module it *directly* references, even ones that would
+arrive transitively — each file's header is then a complete manifest of its
+real dependencies. Constants/helpers used by only one script stay inline in
+it; one needed by two or more gets its own `lib/` file.
+
+`check_tools` is called up front by each script that runs external commands,
+with exactly the less-ubiquitous tools *that script* uses (not copied from
+another script; core utilities like `mkdir`/`sed`/`mktemp` aren't listed).
+
+### Constants (each in its own `lib/` file)
+
+- Repo/dir names: `PATH_REPO=$(cd .. && pwd)` (repo root, derived from
+  `_lib.sh`'s own location so it works in a checkout, on the data LV and on
+  a remote server alike); `DIRNAME_REPO=$(basename "${PATH_REPO}")`;
+  `DIRNAME_SCRIPTS=$(basename "$(pwd)")`. The latter two name paths in a
+  *different* landed copy of the repo (target-side or remote).
+- Layout: `PATH_BOOT_DEVICE`, `IDX_PARTITION_EFI`, `PATH_PARTITION_EFI`,
+  `PATH_PARTITION_LUKSVG` (see above); `PATH_DEV` (`/dev`) with
+  `DIRNAME_DEV` (separate only so `TO_RBIND` can list bare `dev`/`proc`/
+  `sys`); `PATH_EFI` (`/boot/efi`); `PATH_ETC_APT`; `PATH_MNT` (`/mnt`).
+- Names: `NAME_DATA` (`data`), `NAME_SWAP` (`swap`), `NAME_LUKSVG`,
+  `USERNAME` (`plom`, shared so both `start_root_*` targets agree on the one
+  account name).
+- Chroot: `DIRNAME_CHROOT`, `PATH_MNT_CHROOT` (`/mnt/chroot`) — the generic
+  chroot mountpoint that `rbind_mnt`, `unmount_unrbind` and `chroot_sh` all
+  assume. Mounts that aren't a chroot root (e.g. `setup_luksvg.sh` mounting
+  `data` at its own `PATH_MNT_DATA`) pass their own destination to
+  `mount_privately` instead.
+- `PATH_DATA` (`/data`) — the `data` LV's mount point *inside an installed
+  system* (used by `templates/fstab` and the first-boot hook path).
+- `PATH_HOME_ROOT` (`/root`), `FNAME_FIRST_BOOT_PENDING` /
+  `PATH_FIRST_BOOT_PENDING` — the first-boot marker (see "`install_debian.sh`"
+  phase 6).
+- `PATH_MY_SSH` (`${HOME}/${DIRNAME_SSH}`) — **means different things per
+  script**, because each script's `$HOME` differs: `install_server.sh` reads
+  the *admin's* `~/.ssh/known_hosts`; `start_root_server.sh` reads *root's*
+  `~/.ssh` on the freshly provisioned target.
+- `FNAME_INITRD`/`FNAME_VMLINUZ`, `FNAME_PROFILE`, `DIRNAME_SSH`, `TO_RBIND`:
+  the remaining constants shared by two or more scripts.
+
+Anything derived from values only known after argument parsing is *not* a
+shared constant: build it from `path_luks_mapper`/`path_vg` (each taking the
+VG name as explicit `$1`) and compose in the caller, e.g.
+`PATH_VG_ROOT=$(path_vg "${NAME_LUKSVG}")/${NAME_ROOT}`. Keep new
+path-building logic on this pattern (explicit parameters, composed by the
+caller) rather than functions reaching for same-named globals.
+
+### Helpers
+
+- Narration/failure: `msg`/`msg_nonl`, `die`/`error`, `try_quiet`.
+- Validation: `check_tools`, `check_boot_device_layout`,
+  `check_partition_mountable` (takes the partition as explicit `$1`; callers
+  now always pass `PATH_PARTITION_LUKSVG`/`PATH_PARTITION_EFI`),
+  `check_new_luksvg`, `check_openable_luksvg`, `usage` (arg-count check with
+  auto-built usage message). The boot-label legality check in
+  `install_debian.sh` is an inline `case`, not a helper, since it's the only
+  caller left.
+- LUKS lifecycle: `open_luksvg`/`close_luksvg` operate on the global
+  `NAME_LUKSVG`/`PATH_PARTITION_LUKSVG`. `close_luksvg` does a blanket
+  `vgchange -an` on the whole VG, then `cryptsetup luksClose` — intentional,
+  see "Concurrency".
+- Mounts: `mount_privately <source> <dest>` does
+  `mount --make-rprivate /` (namespace isolation) then the mount. The
+  privatization is required, not optional cleanup: mounts otherwise silently
+  propagate into other services' private mount namespaces (e.g.
+  `systemd-udevd` with `PrivateMounts=yes`), leaving a stale reference that
+  later blocks `vgchange -an` during cleanup with no mount or process
+  visible anywhere a normal check looks. Every `mount` in the LUKS/VG-facing
+  scripts goes through it (or does `--make-rprivate /` itself); don't
+  reorder around that. `rbind_mnt`/`unmount_unrbind` bind `/dev`, `/proc`,
+  `/sys` into `PATH_MNT_CHROOT` (only usable after a `mount_privately` to
+  that destination). `await_path` polls for a just-created LV's device node
+  (`lvcreate` returns before udev/lvm2 necessarily created it).
+  `install_server.sh` and the target-side `start_*.sh` never mount anything;
+  `update_efi.sh` is the one exception and deliberately skips
+  `mount_privately` (see its section).
+- `chroot_sh` runs a command string in `${PATH_MNT_CHROOT}` via
+  `LANG=C.UTF-8 chroot`. `retry_until_success` re-runs a command until it
+  succeeds — wraps every `passwd` call so mismatched passwords re-prompt
+  instead of aborting via errexit. `create_lv` wraps
+  `lvcreate -L <size> -n <lv> <vg>` with narration.
+- `efi_copy_kernel_vmlinuz <efi-dir> [<root-prefix>]` copies
+  `FNAME_VMLINUZ`/`FNAME_INITRD` into an EFI boot directory; empty prefix
+  means the running system's own `/`. `install_debian.sh` passes
+  `${PATH_MNT_CHROOT}`; `update_efi.sh` passes none.
+- `disable_apt_recommends [<root-prefix>]` writes
+  `APT::Install-Recommends "false";` to
+  `${PREFIX}${PATH_ETC_APT}/apt.conf.d/90-no-recommends` — prefixed by
+  `install_debian.sh`, unprefixed by `start_root_server.sh` (a remote server
+  never went through `install_debian.sh`).
+- `render_template <bare-filename>` builds `${PATH_REPO}/templates/$1` and
+  `eval`s a `cat <<EOF …` heredoc from its content, so `${VAR}`/`$(cmd)`
+  expand against the *calling* script's variables at render time, exactly
+  as if the text were still an inline heredoc. A template may therefore only
+  reference variables the rendering script has in scope.
+- `link_home <root|user>`:
+  - Walks `home/any/` and `home/<target>/` via `find … -type f`, and
+    symlinks each *plain file* (not its directory) to the same relative path
+    under `${HOME}`, `mkdir -p`ing the parent first. So `~/.config`,
+    `~/.local`, `~/.local/bin` etc. remain real directories the account
+    owner can freely add unrelated files to.
+  - Skips (narrated) any destination that already exists, as file,
+    directory or symlink — deliberately not `ln -sf`, so a link the owner
+    repointed on purpose isn't clobbered. Consequence: rerunning after
+    changing the skeleton does *not* refresh existing links; remove a stale
+    link by hand first. The parent `mkdir -p` still runs regardless, so a
+    *new* skeleton file in an already-populated directory still works.
+  - Appends `for FILE in ${HOME}/.profile.d/*; do . "${FILE}"; done` to
+    `${HOME}/.profile`, guarded by `grep -qxF`. A loop, because a bare
+    `. ${HOME}/.profile.*` would source only the first match (further words
+    become positional parameters); this way a new fragment in `home/any/` or
+    `home/<target>/` never needs a `link_home` edit.
+  - Both halves are safe to call repeatedly; `${HOME}` at run time decides
+    whose home is touched.
+- `start_root <t490s|server>` — shared first-login helper; see
+  "First-login scripts".
+
+## Concurrency
+
+The three rescue-environment scripts that touch the LUKS+VG
+(`setup_luksvg.sh`, `install_debian.sh`, `chrooted_command.sh`) each open the
+*entire* LUKS container/VG at start and close the *entire* thing at the end
+(not just one LV). So only one of them may run against the partition at a
+time — e.g. no `chrooted_command.sh` while `install_debian.sh` is mid-
+debootstrap; the second's `close_luksvg` would tear the VG down under the
+first. Run them serially.
+
+Exempt: `start_root_*.sh`, `start_user.sh`, `setup_git_mirror.sh` (already
+booted into a target, never touch the LUKS+VG), `install_server.sh` (only
+talks to a remote server over SSH; can run concurrently with the others or
+with itself against another server), and `update_efi.sh` (only mounts the
+plain EFI partition).
+
+## `setup_luksvg.sh`
+
+One-time setup of the fixed `PATH_PARTITION_LUKSVG`; no arguments. Checks:
+tools; `check_boot_device_layout`; partition is a mountable, not-yet-LUKS
+block device; VG name unused. Asks for `YES!` before erasing anything. Then:
+`cryptsetup luksFormat --batch-mode`, open, `vgcreate`; 64G `swap` LV +
+`mkswap`; 256G `data` LV + ext4. Mounts `data` (`mount_privately`, at its own
+`PATH_MNT_DATA`), `cp -a`s **`${PATH_REPO}`** (repo root, not `scripts/`)
+onto it as `/data/<repo-dirname>` (named after whatever the checkout is
+called), unmounts, closes LUKS. Doesn't touch EFI or install anything. So it
+must be run from a checkout whose root holds `scripts/`, `to_install/`,
+`to_copy/`, `templates/`, `home/`; the `data` LV is what every installed
+system mounts at `/data`, and this copy is where it finds its first-login
+scripts.
+
+## `install_debian.sh`
+
+Repeatable per OS instance. One argument: the boot name (see "Hardcoded
+machine assumptions"). The LUKS+VG must already exist (`check_openable_luksvg`
+expects it *closed*, not absent).
+
+1. **Sanity checks**: `check_boot_device_layout`; partition mountable and
+   already LUKS; boot name legal (inline `case`) and collision-free (no
+   existing `/boot/efi/<name>`, no EFI boot entry with that label).
+2. **Confirmation**: prints partition and boot name back. No `YES!` needed
+   (unlike `setup_luksvg.sh`): the hard-to-reverse step already happened
+   there; a new LV inside an already-confirmed VG is low stakes.
+3. **LUKS/LVM/fs**: `open_luksvg`, 10G root LV (`create_lv`), `await_path`,
+   `mkfs.ext4`.
+4. **Mount**: `mount_privately` onto `${PATH_MNT_CHROOT}`.
+5. **Base install**: `debootstrap` (`${DEB_SUITE}`, currently `trixie`), then
+   `rbind_mnt`.
+6. **fstab, crypttab, hostname, first-boot hook** — all before installing
+   packages, so the package install's auto-triggered `update-initramfs`
+   bakes the LUKS passphrase prompt in:
+   - renders `templates/fstab` (root, shared `data` at `/data`, swap);
+   - `/etc/crypttab` keyed on the raw `PATH_PARTITION_LUKSVG` device path
+     (no `luksUUID` lookup needed now that the partition is a fixed
+     constant);
+   - `${NAME_BOOT}` into `/etc/hostname` and a `127.0.1.1` line in
+     `/etc/hosts`;
+   - **first-boot hook**: `touch`es `PATH_MNT_FIRST_BOOT_PENDING` (the new
+     install's `/root/.first-boot-pending`) and appends one `printf`-written
+     line to its `/root/.profile`:
+     `[ -e "${PATH_FIRST_BOOT_PENDING}" ] && sh "${PATH_FIRST_BOOT_SCRIPT}"`.
+     `PATH_FIRST_BOOT_SCRIPT` is
+     `/data/<repo-dirname>/scripts/start_root_t490s.sh`, built from
+     `PATH_DATA`/`DIRNAME_REPO`/`DIRNAME_SCRIPTS` at write time. So root's
+     first console login runs first-login setup automatically. Design notes:
+     marker *presence* means "pending", so the "am I done" bookkeeping stays
+     in the one script that knows (`start_root_t490s.sh`'s last action is
+     `rm -f` of it); the hook line never self-edits `.profile` while a shell
+     is mid-`source` of it. If `start_root_t490s.sh` dies partway (say a
+     flaky network in `apt-get`), errexit stops it before the `rm -f`, the
+     marker survives and the next login retries; a failing `.profile`
+     command doesn't abort the interactive shell, so root still gets a
+     prompt. `link_home` later appends its own line to the same `.profile`;
+     the two appends just accumulate. Deliberately not a `templates/` entry
+     (single substituted line, like the crypttab/hostname lines). Only for
+     local installs — `install_server.sh` runs its first-login script
+     directly over `ssh`, so there's no console-login moment to hook.
+7. **Kernel, networking, password**:
+   - enables `non-free-firmware` in the target's `sources.list`;
+     `disable_apt_recommends` with the chroot prefix (persists, so it covers
+     `start_root_t490s.sh`'s installs too);
+   - writes `RESUME=${PATH_VG_SWAP}` (`/dev/cryptolvm/swap`) to the target's
+     `/etc/initramfs-tools/conf.d/resume`, *before* installing the kernel.
+     Why: `initramfs-tools`' `hooks/resume` only auto-detects a resume
+     device by scanning `/proc/swaps`, skips that entirely inside a chroot
+     (`ischroot`) — which is how `chroot_sh` runs `apt-get` — and would see
+     the *rescue* environment's swaps anyway. Left alone, the kernel could
+     hibernate but silently never resume the image (indistinguishable from a
+     cold boot unless you know to look). An explicit `RESUME` is checked
+     first by `hooks/resume` via `resolve_device`/`blkid` against the live
+     device node (already `mkswap`'d by `setup_luksvg.sh`, no `swapon`
+     needed) and written into the initrd's `/conf/conf.d/resume`; no
+     `resume=` cmdline is needed since the initramfs `/init` takes it from
+     there and there's no bootloader to inject one anyway (EFI stub boot);
+   - chroot-installs `linux-image-amd64`, `cryptsetup`,
+     `cryptsetup-initramfs`, `lvm2`, `firmware-iwlwifi`, `network-manager`,
+     `wpasupplicant` (NetworkManager enables its own unit); this is the
+     install whose `update-initramfs` bakes in the `RESUME=` line;
+   - pre-seeds wifi: SSID/PSK parsed from the rescue environment's
+     `/etc/network/interfaces` into `NAME_WIFI_SSID`/`NAME_WIFI_PSK`,
+     `templates/wifi.nmconnection` rendered into
+     `/etc/NetworkManager/system-connections/wifi.nmconnection` (`chmod 600`;
+     the template generates a fresh connection `uuid` per render);
+   - root password via `retry_until_success chroot_sh "passwd"`.
+8. **EFI boot entry**: `efi_copy_kernel_vmlinuz` (prefixed) into
+   `/boot/efi/<boot-name>/`, then `efibootmgr` on
+   `${PATH_BOOT_DEVICE}`/`${IDX_PARTITION_EFI}`. The kernel boots directly
+   via its EFI stub; no GRUB.
+9. **Cleanup**: `unmount_unrbind`, `close_luksvg` (whole VG, see
+   "Concurrency").
+
+## `chrooted_command.sh`
+
+Args: an existing install's root LV name (its boot name) and a command
+string. Opens LUKS+VG, `await_path`s the LV, `mount_privately` + `rbind_mnt`
+as in `install_debian.sh`, runs `chroot_sh "${COMMAND}"` with errexit relaxed
+around just that call, then `unmount_unrbind` + `close_luksvg` even if the
+command failed.
+
+## `update_efi.sh`
+
+Runs directly inside an already-installed local system (copied there like the
+rest of the repo via the `data` LV), no arguments, no LUKS/VG involvement.
+
+**Why it exists:** a kernel package's postinst keeps the installed system's
+own `/vmlinuz` and `/initrd.img` symlinks current, but `install_debian.sh`
+copies them onto the EFI partition only once at install time. Nothing
+re-copies them on kernel change, and writing `/boot/efi` is otherwise reserved
+for the rescue environment. This script is the deliberate exception.
+
+**What it does:** `check_partition_mountable` on `PATH_PARTITION_EFI`;
+`mktemp --directory` mountpoint; plain `mount` (no `mount_privately`: that
+guards against a stale mount reference blocking a *later* `vgchange`/
+`luksClose`, and nothing here is ever deactivated); `efi_copy_kernel_vmlinuz`
+with no prefix into `<mountpoint>/$(hostname)/` — which is why the boot name
+doubles as hostname; then `umount` and `rmdir`. Never touches `efibootmgr`:
+the boot entry already references the fixed `vmlinuz`/`initrd.img` filenames,
+so overwriting their contents suffices.
+
+**Automatic invocation:** `start_root_t490s.sh` installs
+`/etc/initramfs/post-update.d/update-efi` (from `templates/update-efi-hook`)
+which `exec`s this script whenever `update-initramfs` finishes — i.e. on
+every kernel install/upgrade. `post-update.d` rather than the more commonly
+documented `/etc/kernel/postinst.d/` because `update-initramfs` runs it only
+*after* the new initrd and the `/vmlinuz`/`/initrd.img` symlinks are in
+place; `postinst.d` fires earlier with less certain ordering. A failure is
+deliberately left to propagate, failing that `update-initramfs` run and the
+`apt` transaction that triggered it: a loud failure beats an EFI copy that
+silently drifts out of sync. This is the one place a target-side script may
+fail an unrelated `apt` transaction over housekeeping. Running it by hand is
+only needed for kernel installs the hook doesn't cover, e.g. one driven
+through `chrooted_command.sh`.
+
+## `install_server.sh`
+
+Run from the admin's workstation; one argument, `server`
+(`LOGIN="root@${SERVER}"`). Targets a server assumed reachable over SSH as
+root (e.g. a provider-handed fresh VM); together with `start_root_server.sh`
+it exists to lock that root access back down. `check_tools scp ssh ssh-keygen
+tar`. Constants: `FNAME_REPO_TAR=repo.tar`;
+`OPTS_SSH_NEW_HOST="-o StrictHostKeyChecking=accept-new"` on every
+`scp`/`ssh` (a first-ever connection would otherwise block on a prompt);
+`PATH_REPO_PARENT`; `RELPATH_SETUP_SCRIPT` (remote path to
+`start_root_server.sh`, built from `DIRNAME_REPO`/`DIRNAME_SCRIPTS`).
+
+1. `mktemp --directory` locally; `tar cf` the whole repo checkout (same
+   "whole repo, not just `scripts/`" principle as `setup_luksvg.sh`).
+2. If the admin's `~/.ssh/known_hosts` (`PATH_MY_KNOWN_HOSTS`) exists,
+   `ssh-keygen -R "${SERVER}"` — so a wiped/reinstalled server's changed
+   host key doesn't trip verification. The `[ -e ]` guard is needed because
+   `ssh-keygen -R` on a nonexistent file exits non-zero, which would abort
+   under errexit on a from-scratch admin account.
+3. `scp` the tar to `${LOGIN}:~`; remove the local temp dir.
+4. `ssh` in to `tar xf --no-same-owner` (lands at `~/<repo-dirname>`). The
+   flag matters: GNU `tar` as root defaults to `--same-owner`, which would
+   restore the *workstation's* numeric UID onto files under `/root`; that
+   silent mismatch later makes root-run `git` (e.g. `setup_git_mirror.sh`'s
+   `git clone --bare`) refuse the tree as "dubious ownership". Then `ssh -t`
+   (pseudo-tty, since the script ends prompting for a password) to run
+   `./${RELPATH_SETUP_SCRIPT}`.
+
+No `YES!` gate: nothing here is destructive; the point of no easy return
+(locking root SSH) happens on the remote end inside `start_root_server.sh`.
+
+## First-login scripts
+
+`start_root_t490s.sh`, `start_root_server.sh`, `start_user.sh` live in
+`scripts/` only so they ride along on the `data` LV / the pushed tar. They
+run *inside* a target, invoked by absolute or `~`-relative path (e.g.
+`/data/confplom/scripts/...`, `~/confplom/scripts/...`), never against the
+rescue environment's LUKS/LVM state; that's why `_lib.sh`'s `cd` and
+`${PATH_REPO}` matter more for them than for the LUKS-facing scripts. They
+use only narration, basics, `PATH_REPO`, `link_home` and `start_root`.
+
+### `start_root <target>` (`scripts/lib/start_root.sh`)
+
+Shared by both `start_root_*` scripts; `<target>` only selects
+`to_install/<target>`. Steps:
+
+1. Read that file into `TO_INSTALL`, skipping `#` lines; `apt-get -y update`,
+   `apt-get -y install`. **File format** (assumed, not defended against):
+   file ends with a newline, no blank lines, package lines contain no
+   whitespace — keep edits consistent with that rather than adding
+   robustness to `start_root`.
+2. Create `${USERNAME}` if `id -u` says it doesn't exist: `adduser
+   --disabled-password`, `retry_until_success passwd`, then run
+   `start_user.sh` as that account (`su - "${USERNAME}" -c "sh
+   ${PATH_SCRIPT_USER}"`; login invocation so `$HOME` is the user's, not
+   root's; `su`, not `sudo`, since the account isn't in `sudo` yet — root
+   can switch to any local account). Deliberately gated on *creation*: a
+   rerun against an existing account leaves its home alone, the same "leave
+   it alone" choice as `link_home`'s skip-if-exists. `start_user.sh` can
+   still be run by hand later, as either account.
+3. Unconditionally: `usermod -a -G sudo` (not `video`; that's desktop-only,
+   added by `start_root_t490s.sh`); `timedatectl set-timezone` to the local
+   `TIMEZONE` (`Europe/Berlin`); `link_home root` (root gets `home/any/` +
+   `home/root/`; the user gets `home/user/` only via the nested
+   `start_user.sh`).
+
+Safe to rerun against an already-set-up target: packages, sudo, timezone and
+root's links are re-checked idempotently; account creation and the one-time
+`start_user.sh` don't fire again.
+
+### `start_root_t490s.sh`
+
+In order:
+- **Before** `start_root t490s`, so no kernel update from its `apt-get` can
+  land before the hook exists: render `templates/update-efi-hook` into
+  `PATH_HOOK_EFI` (`/etc/initramfs/post-update.d/update-efi`) and `chmod +x`
+  (non-executable files there are skipped). It `exec`s `update_efi.sh` via
+  `PATH_SCRIPT_EFI`, which is only referenced from the template, hence its
+  `# shellcheck disable=SC2034`.
+- `start_root t490s`.
+- `LANG=C.UTF-8` into `/etc/default/locale` (a symlink to `../locale.conf`
+  on current Debian; the redirect follows it). `C.UTF-8` is built into
+  glibc, so no `locales`/`locale-gen`.
+- `kernel.printk` sysctl drop-in quieting console messages (`sysctl -p`).
+- TLP charge-threshold drop-in, then `tlp start` so it applies immediately.
+- `systemd-hibernate.service.d/60-poweroff-on-failure.conf`
+  (`OnFailure=poweroff.target`, `daemon-reload`): failed hibernate falls
+  back to poweroff. Complemented by the `hibernate_safe` aliases, see
+  "`home/`".
+- `logind.conf.d/60-poweroff-on-lid-close.conf`: `poweroff` on lid close in
+  every dock/power state (`systemctl restart systemd-logind`, since logind
+  doesn't re-read config on `daemon-reload`).
+- Copy `to_copy/90-backlight.rules` to `/etc/udev/rules.d/`, reload rules,
+  and replay `add` for the backlight subsystem (explicit `--action=add`:
+  `udevadm trigger` defaults to `change`, which the rule's `ACTION=="add"`
+  wouldn't match). The rule `chgrp`s and `chmod g+w`s each backlight's
+  `brightness` to group `video`; hence `usermod -a -G video "${USERNAME}"`.
+- Clock sync needs no step: `chrony` (in `to_install/t490s`) enables itself
+  on install and keeps syncing, including across frequent suspend/resume.
+- Last: `rm -f "${PATH_FIRST_BOOT_PENDING}"` (`-f` so a by-hand run without a
+  marker is a silent no-op).
+
+### `start_root_server.sh`
+
+Before `start_root server`: `disable_apt_recommends` (unprefixed). Then:
+move root's `~/.ssh` (`PATH_MY_SSH`) to the new user's home and `chown` it,
+so the provisioned key now logs in as the unprivileged user; write an
+`sshd_config.d` drop-in with `PermitRootLogin no` and
+`PasswordAuthentication no` (the latter also closes password login for the
+normal user, since leaving it open would undermine key-only access) and
+reload `ssh`; finally `retry_until_success passwd` for root — strictly a
+console/out-of-band fallback, since password auth over SSH is off. `chrony`
+is in `to_install/server`.
+
+### `start_user.sh`
+
+No arguments; just `include link_home; link_home user`. Safe to run again by
+hand as any account: as root it additionally picks up `home/user/` into
+root's home; as the user it picks up entries added after provisioning.
+
+## `setup_git_mirror.sh`
+
+Optional, run by hand on an already-set-up target to make it a distribution
+point. Motivation: a fresh system with no SSH keys exchanged can't use
+`install_server.sh`; it can instead anonymously
+`git clone git://<server>/<repo-dirname>.git`. No arguments; refuses to run
+if `${PATH_GIT_MIRROR}` (`/srv/git/${DIRNAME_REPO}.git`) exists — one-time
+setup, not a sync step.
+
+1. `apt-get -y update` + install `git` (a bare target may lack it).
+2. `git clone --bare "${PATH_REPO}"` into the mirror path — a snapshot, not
+   a live link.
+3. `touch git-daemon-export-ok` in it, rather than `--export-all`, so other
+   repos later placed under `/srv/git` aren't served automatically.
+4. `chown -R "${USERNAME}:${USERNAME}"`: `git://` is read-only, so keeping
+   the mirror current means the admin `git push`ing over `ssh` as
+   `${USERNAME}` (root SSH is already disabled), so that account must own it.
+   Consequence, not a bug: root running `git` against the mirror trips
+   "dubious ownership"; inspect via
+   `sudo -u "${USERNAME}" git -C "${PATH_GIT_MIRROR}" log`, or for a
+   one-off `git config --global --add safe.directory "${PATH_GIT_MIRROR}"`.
+5. Render `templates/git-daemon.service` (`User=${USERNAME}`; never run a
+   network-facing daemon as root needlessly) running
+   `git daemon --reuseaddr --base-path=/srv/git /srv/git`;
+   `systemctl daemon-reload` + `enable --now git-daemon` (systemd is already
+   present; no inetd needed).
+6. Print the anonymous clone URL, the `ssh` push URL, and a reminder that
+   git daemon listens on 9418/tcp in case a firewall is added later.
+
+## `home/` skeletons
+
+All symlinked by `link_home` (see "Shared library"). Profile fragments live
+in `.profile.d/` with bare names, sourced by the loop line in `~/.profile`.
+
+### `home/any/` (every account)
+
+- `.profile.d/aliases` — the `ls --color=auto` alias, plus `LS_COLORS` filled
+  via `dircolors -b` (forcing `COLORTERM=truecolor` if unset) when
+  `LS_COLORS` is empty: a shell reached via `su -l` (root's, from the user)
+  loses foot's `COLORTERM`, and `dircolors` has no database entry for foot's
+  `TERM=foot`, which would otherwise leave `ls` uncolored.
+
+### `home/root/` and `home/user/`
+
+- `.profile.d/hibernation` (both, differing) — defines a `hibernate_safe`
+  alias (`command systemctl hibernate || systemctl poweroff`) and shadows a
+  command to steer plain `systemctl hibernate` towards it: `systemctl` itself
+  for root, `sudo` for the user (who only reaches `systemctl` through it).
+  Why: the `60-poweroff-on-failure.conf` drop-in only guards a hibernate
+  failure reached through systemd's own unit, not a direct
+  `systemctl hibernate`; `hibernate_safe` closes that gap.
+
+### `home/user/` only
+
+- `.profile.d/ssh-agent` — starts `ssh-agent` and `ssh-add -q` once per
+  login, guarded by `[ -z "${SSH_AGENT_PID}" ]` (so e.g. a second `foot`
+  window doesn't start another). Root needs no agent.
+- `.profile.d/prompt` — colored (bold, `COLOR_NUMBER`, default green)
+  `[<date>/<time>/<zone> user@host:path]$` `PS1`, with `PS2`/`PS3`/`PS4`
+  matching. Colors from `tput`; `\D{...}`/`\u`/`\h`/`\w` instead of forked
+  `date`/`pwd`. In bash the colors are wrapped in `\[ \]` for readline's
+  width counting, except `PS3` (printed verbatim by `select`; bare codes).
+  Helpers are `local`s of a throwaway `_dot_profile_dot_prompt` function,
+  unset after the call. Takes effect because the loop line sits at the very
+  end of `~/.profile`, after Debian's stock one has sourced `~/.bashrc`
+  (which sets its own `PS1`), so this fragment gets the last word.
+- `.profile.d/kb` — `kb` alias: `swaymsg 'input * xkb_switch_layout next'`.
+- `.gitconfig` — minimal `[user]` section.
+- `.config/foot/foot.ini` — `font=monospace:size=14.5` and
+  `login-shell=yes`. The latter is needed because Sway spawns foot with no
+  arguments, foot spawns `${SHELL}` as a plain interactive shell, and a
+  non-login shell never reads `~/.profile` — which is where the
+  `.profile.d/*` loop lives. No tracked `~/.bashrc` exists to also keep in
+  sync.
+- `.config/sway/config` — Sway config. Notably `input * xkb_layout "us,de"`
+  (toggled via `kb`; must stay in sync with `KB_LAYOUTS` in `status.py`) and
+  `status_command ~/.config/sway/status.py`.
+- `.config/sway/status.py` — status bar, invoked by Sway. Once a second
+  emits seven groups of blocks, one `blocks_<name>` function each: clipboard,
+  network, battery, thermal, datetime, keyboard layout, audio; entries in
+  Sway's JSON protocol, each group's last block getting a visible separator.
+  `Block` is a `NamedTuple` (text, color, `pango` markup or none, separator
+  width); colors are `COL_*` constants; numeric readings (battery charge/
+  health, temperature, wifi signal) escalate green/yellow/red against
+  per-reading limit tuples (`*_ESCALATIONS`) via `Block.color_escalated`.
+  - clipboard: `wl-paste` regular and `--primary`, truncated to
+    `CLIP_LEN_MAX` with an ellipsis, or `empty`/`binary`/`error` (hence
+    `wl-clipboard` in `to_install/t490s`).
+  - network: each connected ethernet/wifi device's IP (IPv4 preferred; IPv6
+    link-local only as last resort) and, for wifi, SSID and signal, via
+    `nmcli -t`; `offline` if none.
+  - battery: globs `/sys/class/power_supply/BAT*` for the first battery;
+    charge %, a one-character charging/discharging/not-charging symbol
+    (`BATTERY_STATUS_SYMBOLS`), and health % (current vs design capacity);
+    tries `charge_*` sysfs attributes first, falls back to `energy_*`, since
+    not every driver exposes both.
+  - thermal: max across *all* `/sys/class/thermal/thermal_zone*/temp`, since
+    which zone is which sensor isn't stable across kernels/hardware.
+    Battery and thermal share `int_at_path` (stripped, tolerates a leading
+    `-`).
+  - datetime: date, time, UTC offset and zone name.
+  - layout: active `xkb_active_layout_index` from
+    `swaymsg -t get_inputs`, mapped through `KB_LAYOUTS`.
+  - audio: default sink ("vol") and source ("--mic") volume percentages,
+    struck through (`<s>`) when muted, via `pactl_info_default_dev`.
+- `.local/bin/backlight` — sets backlight brightness by percentage, writing
+  `/sys/class/backlight/*/brightness` directly (no `sudo`, thanks to the udev
+  rule). No `_lib.sh` dependency and no filename extension. Uses `ArgParser`.
+- `.local/bin/vol` — sets or toggles PulseAudio volume/muteness through
+  `pactl` (from the `pulseaudio` package in `to_install/t490s`, which depends
+  on `pulseaudio-utils` directly, so it installs despite the repo-wide
+  `Install-Recommends "false"`). Optional percentage argument (max
+  `PERCENTAGE_MAX`=150, boosting past `VOL_AT_100`) doubles as mode switch:
+  given, sets volume; omitted, toggles mute. `--mic` targets the default
+  source instead of the sink. The device is resolved anew each invocation via
+  `pactl_info_default_dev` by PulseAudio's *default device name*, never a
+  fixed numeric index (an index is stable only by accident of enumeration
+  order; a Bluetooth headset, HDMI output or monitor source would silently
+  retarget it). Only the index that lookup returns is passed to
+  `pactl set-{sink,source}-{volume,mute}`.
+- `.local/bin/lib/` — shared Python package: `argparsing.py`, `pactl.py`,
+  `__init__.py`. `ArgParser` wraps `argparse.ArgumentParser`:
+  `add_arg_ranged_int` adds an optional positional integer range-checked
+  `0..max` at parse time (via `ArgumentError`); parsed result via the lazily
+  parsing `args` property; plain `add_arg` passthrough for anything else.
+  `pactl.py`: `pactl` is a thin `subprocess.run(('pactl', ...), check=True)`
+  wrapper; `pactl_info_default_dev` returns `pactl -f json list`'s entry for
+  the current default sink/source (found via `pactl -f json info`'s
+  `default_{sink,source}_name`).
+  **Import resolution:** `backlight`/`vol` are reached as symlinks, and Python
+  resolves the real path before computing `sys.path[0]`, so
+  `from lib.argparsing import ArgParser` loads *this repo's own*
+  `home/user/.local/bin/lib/` (`/data/confplom/...` locally,
+  `~/confplom/...` remotely), never the symlinked copy `link_home` places
+  under `~/.local/bin/lib/`. Harmless (identical content) but worth knowing.
+  `status.py` reaches the same directory explicitly by appending
+  `Path(__file__).resolve().parents[2] / '.local' / 'bin' / 'lib'` to
+  `sys.path` and importing `pactl` from there.
+
+## Editing conventions
+
+- Prefer a new `scripts/lib/` file over duplicating logic across scripts.
+  Constants/helpers only relevant to one script stay inline in it.
+- File placement: a file meant only to be installed onto a target belongs
+  under `to_copy/` (copied verbatim), `templates/` (rendered), or
+  `home/any/`/`home/<target>/` (symlinked) — not loose in `scripts/`, which
+  is for scripts, `_lib.sh` and `lib/`. A non-script config file a script
+  reads *in place* from a fixed repo-relative location (like
+  `to_install/<target>`) gets its own top-level directory.
+- Prefer `templates/` over a new inline heredoc for any file written with
+  variable substitution; a heredoc long or numerous enough to clutter its
+  script is the signal. One- or two-line writes with little substitution
+  (crypttab/hostname/first-boot lines) stay plain `printf`.
+- A variable a script sets *only* for a template to consume needs a
+  `# shellcheck disable=SC2034` comment above it (as `PATH_VG_DATA` in
+  `install_debian.sh` and `PATH_SCRIPT_EFI` in `start_root_t490s.sh`), since
+  shellcheck can't see into `eval`-rendered templates.
+- All shell variables are `${UPPER_SNAKE_CASE}`, always brace-quoted, even
+  single-word names; function-local variables use `local`.
+- Line length 80 columns; wrap with backslash-newline (inside double-quoted
+  strings it's elided rather than becoming a literal newline) or, where it
+  reads as clearly, factor the long expression into a named constant first
+  (as `install_server.sh` does with `RELPATH_SETUP_SCRIPT`).
+- New `mount` calls in the LUKS/VG-facing scripts must go through
+  `mount_privately` (see "Shared library").