josie / alder-tools

installer: one-stop aldermon-install.sh (check/plan/confirm/escalate) + uninstall

Replaces aldermon-setup.sh (git mv). From the source tree:

- recon (no changes) prints state + a per-stage plan, then confirm, then ONE
  doas/sudo escalation, per-stage status, verify block. Sectioned output
  ('== recon/install/verify =='); no per-line prefix.
- [1/4] binary+helper version-gated (missing/older install, same skip,
  newer keep; --clean forces); [2/4] /etc conf only-if-missing (--clean
  resets to stock); [3/4] watts: powercap udev RUN-chmod helper (always,
  write-if-changed); [4/4] VID OPTIONAL: y/n at the root stage -> msr udev
  rule 0440 adm + cap_sys_rawio+ep on aldermon-msr only. Two-gate rationale
  in header/README.
- manages group adm: install adds the user if missing (warning if no adm
  group); uninstall asks whether to remove (--del-adm/--keep-adm).
- uninstall/--purge: drops binaries, capability, both udev rules, RUN
  helper, installer copy; restores /dev/cpu/*/msr 0600 root:root and
  energy_uj 0400 root:root; reloads udev; keeps /etc conf unless --purge.
- flags: --check/--setup/--clean(-c)/--uninstall/--purge/--del-adm/
  --keep-adm/--yes(-y)/--vid/--no-vid/-h; PREFIX/SYSCONF/DATADIR env;
  internal _root/_setup/_unroot get resolved dirs as args (doas resets env).
- fixes from UAT: udev reload BEFORE the msr trigger (write_powercap reloaded
  before the msr rule file existed -> first-install msr nodes stayed 0600);
  powercap helper says kept when byte-identical; --clean no longer forces a
  cargo rebuild; clearer VID-decline wording.
- main.rs: --version/-V (CARGO_PKG_VERSION), needed by the version gate.
- Makefile: only-if-missing conf, installs aldermon-install.sh, legacy
  uninstall paths.

make check (28 tests) green; full fake-root stub-doas matrix + live
install/clean/uninstall/check UAT on the real box.

d7832fa9ebbe5d90d271a0476963d120b63755b1
josie <josie@example.com> · 2026-10-08T20:43 · browse files at this commit

parents: 7b77caf

diff --git a/aldermon/Makefile b/aldermon/Makefile
index 75738f1..3abad17 100644
--- a/aldermon/Makefile
+++ b/aldermon/Makefile
@@ -7,7 +7,7 @@ DATADIR ?= /usr/share
 BIN      := $(DESTDIR)$(PREFIX)/bin/aldermon
 MSRBIN   := $(DESTDIR)$(PREFIX)/bin/aldermon-msr
 CONF     := $(DESTDIR)$(SYSCONF)/aldermon/aldermon.conf
-SETUP    := $(DESTDIR)$(DATADIR)/aldermon/aldermon-setup.sh
+SETUP    := $(DESTDIR)$(DATADIR)/aldermon/aldermon-install.sh
 
 .PHONY: all build check install uninstall clean
 
@@ -24,18 +24,22 @@ check:
 install: build
 	install -Dm755 target/release/aldermon $(BIN)
 	install -Dm755 target/release/aldermon-msr $(MSRBIN)
-	install -Dm644 aldermon.conf $(CONF)
-	install -Dm755 aldermon-setup.sh $(SETUP)
+	# never clobber a hand-edited system conf; aldermon-install.sh owns
+	# force-replacing it (--clean)
+	[ -e $(CONF) ] || install -Dm644 aldermon.conf $(CONF)
+	install -Dm755 aldermon-install.sh $(SETUP)
 ifeq ($(DESTDIR),)
 	# VID needs the cap half of a two-gate check (msr nodes also go 0440 adm
-	# via aldermon-setup.sh); grant cap_sys_rawio to the read-only helper
-	# only. Re-applied on every install since rebuilding drops file caps.
+	# via the rules installed by aldermon-install.sh); grant cap_sys_rawio
+	# to the read-only helper only. Re-applied on every install since
+	# rebuilding drops file caps.
 	setcap cap_sys_rawio+ep $(MSRBIN)
 endif
 
 uninstall:
 	rm -f $(BIN) $(MSRBIN) $(CONF) $(SETUP)
 	rm -f $(DESTDIR)$(DATADIR)/aldermon/powercap-pl-rules.sh
+	rm -f $(DESTDIR)$(DATADIR)/aldermon/aldermon-setup.sh
 	rmdir -p --ignore-fail-on-non-empty $(CONF) 2>/dev/null || true
 	rmdir -p --ignore-fail-on-non-empty $(SETUP) 2>/dev/null || true
 
diff --git a/aldermon/README.md b/aldermon/README.md
index f0ac840..49ddebc 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -11,6 +11,7 @@ aldermon --vid          # VID spike tool (debug; needs MSR access, see below)
 aldermon --log          # append CSV samples to ./aldermon-vid.log
 aldermon                # one-shot sensor dump
 aldermon --help         # options
+aldermon --version      # aldermon x.y.z
 ```
 
 The TUI always shows the delivered vCore (SIO in0), the CPU's requested SVID
@@ -66,33 +67,49 @@ layout          = auto     # auto | single | dual
 
 ## Install
 
-```sh
-make            # release build
-make check      # fmt + clippy + tests
-doas make install   # PREFIX=/usr/local by default
-```
-
-Installs `aldermon` + `aldermon-msr` (VID helper) to `$(PREFIX)/bin`, this
-repo's `aldermon.conf` to `/etc/aldermon/aldermon.conf` (system default —
-override per-user via `~/.config/aldermon/`), and the setup script to
-`/usr/share/aldermon/`. `make install` also setcaps the helper (skipped for
-staged `DESTDIR=` installs — `aldermon-setup.sh` applies it). `make
-uninstall` reverses it (leaves the system conf dir if non-empty).
-
-## Privileged setup — one-time (RAPL watts + VID)
+One command, from this directory:
 
 ```sh
-doas /usr/share/aldermon/aldermon-setup.sh
-doas usermod -aG adm $USER   # then re-login
+./aldermon-install.sh   # recon -> printed plan -> confirm -> ONE doas/sudo
+                        # prompt -> install only what's needed -> verify
 ```
 
-1. RAPL: `energy_uj` is root-only, so the script installs a udev rule that
-   chgrps it 0440 to group `adm` — watts then work unprivileged.
-2. VID: opening `/dev/cpu/*/msr` passes two gates — the file-mode check
-   (node is 0600; `CAP_SYS_RAWIO` does NOT override DAC) and `msr_open()`'s
-   capability check. The script installs a udev rule making the nodes
-   `0440 adm` and sets `cap_sys_rawio+ep` on `aldermon-msr`, a read-only
-   helper that touches exactly one MSR; the app never holds the capability.
+Stages: **[1/4]** binary+helper — version-gated (installs if missing or
+older, skips if current); **[2/4]** `/etc` config — only if missing (your
+system-conf edits survive; per-user `~/.config/aldermon/` never touched);
+**[3/4]** watts — powercap udev rule + chmod helper (group `adm`);
+**[4/4] VID is OPTIONAL**: after the root prompt the installer asks whether
+to add the msr udev rule + helper capability. Declining leaves vCore/temps/
+freqs untouched (already unprivileged) — the panel just keeps showing
+`VID n/a (no msr access)`.
+
+Modes: `--check` status report, changes nothing; `--setup` privileged parts
+only (for staged/package installs); `--clean`/`-c` force-reinstall binary,
+helper, permissions and STOCK config; `--vid`/`--no-vid` pre-answer stage 4;
+`--yes`/`-y` accept the plan without prompting; `--uninstall` removes the
+binaries, capability, udev rules and RUN helper and restores the device
+nodes' stock permissions (keeps your `/etc` config); `--purge` also deletes
+the config. On uninstall it asks whether to drop your user from group `adm`
+(`--del-adm`/`--keep-adm` pre-answer).
+
+Low-level: `make check`, `doas make install` (honors PREFIX/DESTDIR; keeps
+an existing system conf; setcaps when unstaged — run
+`aldermon-install.sh --setup` after staged installs), `make uninstall`
+reverses (leaves the system conf dir if non-empty).
+
+## Why the privileges look like this (RAPL watts + VID)
+
+- RAPL `energy_uj` is `0400` root-only; powercap has NO devnode, so udev's
+  `RUN+=` helper chgrp/chmods it to `0440 adm`.
+- `/dev/cpu/*/msr` (VID) passes TWO gates on open: the DAC file-mode check
+  first (nodes are `0600` — `CAP_SYS_RAWIO` is not `CAP_DAC_OVERRIDE`, so
+  setcap alone gets `EACCES`), then `msr_open()`'s `capable(CAP_SYS_RAWIO)`
+  (so chmod alone gets `EPERM`). The installer opens both: udev
+  `MODE/GROUP 0440 adm` for the nodes, `cap_sys_rawio+ep` on the read-only
+  `aldermon-msr` helper only — the app never holds the capability.
+- Once per user: the installer adds you to group `adm` if missing; activate
+  it with a re-login (or `newgrp adm` for the current shell). Uninstall asks
+  whether to remove you from `adm` (`adm` also grants log access).
 
 ## Status
 
diff --git a/aldermon/aldermon-install.sh b/aldermon/aldermon-install.sh
new file mode 100755
index 0000000..0f43899
--- /dev/null
+++ b/aldermon/aldermon-install.sh
@@ -0,0 +1,543 @@
+#!/bin/sh
+# aldermon-install.sh - one-stop installer / uninstaller for aldermon.
+#
+# Flow: RECON (checks, nothing changed) -> printed plan -> confirm ->
+# ONE root escalation (doas/sudo) -> per-component install with status
+# lines -> verify.
+#
+# Install components (each checked first; skipped when already right, except
+# watts + the optional VID answer):
+#   [1/4] binary+helper  install if missing or older than the built
+#                        version; identical/newer left alone (--clean forces).
+#                        The helper FILE is inert without its capability.
+#   [2/4] /etc config    installed only if missing (system-conf edits are
+#                        never clobbered unless --clean).
+#   [3/4] watts          powercap udev rule + RUN chmod helper (energy_uj
+#                        is 0400 root-only and powercap has no devnode).
+#                        Always done - the TUI power panel needs it.
+#   [4/4] VID (optional) asked at the root stage (y/n): msr udev rule
+#                        (nodes 0440 adm) + cap_sys_rawio+ep on aldermon-msr
+#                        only. open /dev/cpu/N/msr takes TWO gates: DAC
+#                        file-mode first (setcap alone gets EACCES - the cap
+#                        is not CAP_DAC_OVERRIDE), then msr_open()'s
+#                        capable(CAP_SYS_RAWIO) (chmod alone gets EPERM).
+#                        Declining leaves VID showing "no msr access";
+#                        vCore/temps/freqs stay fully unprivileged either
+#                        way.
+# Install also removes pre-rename leftovers (60-adlermon-powercap.rules,
+# sbin/adlermon-powercap-chmod) and superseded installer scripts.
+#
+# Uninstall removes what install created (binaries, capability, udev rules,
+# RUN helper) and restores the device nodes' stock permissions, so recon
+# reports everything missing again. The /etc config is KEPT (your edits)
+# unless --purge.
+#
+# usage: aldermon-install.sh [MODE|OPTION]
+#   (default)    recon -> plan -> confirm -> install
+#   --clean, -c  force-reinstall every component (resets /etc config to stock)
+#   --vid        plan VID as ON; no prompt
+#   --no-vid     plan VID as SKIP; no prompt
+#   --setup      privileged parts only (rules, setcap) for staged/package
+#                installs; still plan+confirm
+#   --uninstall  remove binaries, capability, rules, RUN helper; restore node
+#                perms (keeps config)
+#   --purge      uninstall AND delete the /etc config
+#   --del-adm    on uninstall, also remove the original user from group adm
+#                (default: ask at the root stage)
+#   --keep-adm   on uninstall, leave the adm group alone; don't ask
+# Install adds the original user to group adm if missing; activate it with a
+# re-login or `newgrp adm` (current shell only).
+#   --check      status report; changes nothing
+#   --yes, -y    accept the plan without prompting
+#   --help, -h   this text
+#
+# env: PREFIX (default /usr/local), SYSCONF (/etc), DATADIR (/usr/share)
+
+set -eu
+
+SRCDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+PREFIX=${PREFIX:-/usr/local}
+SYSCONF=${SYSCONF:-/etc}
+DATADIR=${DATADIR:-/usr/share}
+
+BIN=$PREFIX/bin/aldermon
+MSRBIN=$PREFIX/bin/aldermon-msr
+CONF=$SYSCONF/aldermon/aldermon.conf
+STOCK_CONF=$SRCDIR/aldermon.conf
+HELPER=$PREFIX/sbin/aldermon-powercap-chmod
+RULE=$SYSCONF/udev/rules.d/60-aldermon-powercap.rules
+MSRRULE=$SYSCONF/udev/rules.d/60-aldermon-msr.rules
+REL=$SRCDIR/target/release
+
+_seen_section=0
+section() { if [ "$_seen_section" = 1 ]; then printf '\n'; fi; printf '== %s ==\n' "$1"; _seen_section=1; }
+item() { printf '  %s\n' "$*"; }
+sub() { printf '    %s\n' "$*"; }
+say() { printf '%s\n' "$*"; }
+die() { printf 'aldermon-install: error: %s\n' "$*" >&2; exit 1; }
+
+usage() { sed -n '/^# usage:/,/^# env:/p' "$0" | sed 's/^# //;s/^#$//'; }
+
+ver_older() { # a b -> true if a is a strictly older dotted version
+    [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]
+}
+
+installed_ver() { # echoes "" | version | 0.0.0 (pre-version build)
+    [ -x "$BIN" ] || return 0
+    _v=$("$BIN" --version 2>/dev/null | sed -n 's/^aldermon //p') || true
+    echo "${_v:-0.0.0}"
+}
+
+src_ver() { # version the tree builds (from binary if built, else Cargo.toml)
+    if [ -x "$REL/aldermon" ]; then
+        "$REL/aldermon" --version | sed -n 's/^aldermon //p'
+    else
+        sed -n 's/^version = "\([^"]*\)"/\1/p' "$SRCDIR/Cargo.toml" 2>/dev/null || echo ""
+    fi
+}
+
+helper_state() { # "missing" | "installed, no capability" | "installed + capable"
+    if [ ! -x "$MSRBIN" ]; then echo missing
+    elif getcap "$MSRBIN" 2>/dev/null | grep -q cap_sys_rawio; then echo "installed + capable"
+    else echo "installed, no capability"; fi
+}
+
+# echoes the [1/4] gate action for FORCE INST SRC
+bin_action() {
+    if [ "$1" = clean ]; then echo "install (forced)"
+    elif [ -z "$2" ]; then echo "install (missing)"
+    elif ver_older "$2" "$3"; then echo "upgrade $2 -> $3"
+    elif [ "$2" = "$3" ]; then echo "skip (up to date)"
+    else echo "keep (installed $2 newer than source $3)"
+    fi
+}
+
+write_if_changed() { # FILE MODE, content on stdin
+    _f=$1 _m=$2 _t="$_f.tmp-aldermon-install"
+    install -d "$(dirname "$_f")"
+    cat >"$_t"
+    if [ -f "$_f" ] && cmp -s "$_t" "$_f"; then
+        sub "kept (unchanged): $_f"
+        rm -f "$_t"
+    else
+        install -D -m "$_m" "$_t" "$_f"
+        sub "installed: $_f"
+        rm -f "$_t"
+    fi
+}
+
+write_powercap() { # watts: RUN-chmod helper + rule, applied now
+    install -d "$(dirname "$HELPER")"
+    cat <<EOF | write_if_changed "$HELPER" 755
+#!/bin/sh
+for d in /sys/class/powercap/intel-rapl*; do
+    [ -e "\$d/energy_uj" ] && chgrp adm "\$d/energy_uj" && chmod 0440 "\$d/energy_uj"
+done
+exit 0
+EOF
+    printf '%s\n' 'SUBSYSTEM=="powercap", ACTION=="add", RUN+="'$HELPER'"' |
+        write_if_changed "$RULE" 644
+    "$HELPER" || sub "warning: powercap chmod failed (watts may stay unreadable)"
+    udevadm control --reload-rules
+    udevadm trigger --subsystem-match=powercap
+    sub "udev reloaded + powercap triggered"
+    clean_legacy
+}
+
+write_msr_rule() { # VID gate 1: msr IS a devnode -> plain MODE/GROUP
+    printf '%s\n' 'KERNEL=="msr[0-9]*", SUBSYSTEM=="msr", MODE="0440", GROUP="adm"' |
+        write_if_changed "$MSRRULE" 644
+    udevadm control --reload-rules # required: write_powercap reloaded BEFORE this file existed
+    udevadm trigger --subsystem-match=msr
+    sub "udev msr nodes triggered (0440 adm)"
+}
+
+# adm group membership: watts (energy_uj 0440 adm) and VID msr nodes both
+# need it. Adds USER if missing so a fresh install works after one re-login.
+ensure_adm() { # USER
+    _user=${1:-}
+    [ -n "$_user" ] && [ "$_user" != root ] || return 0
+    if ! getent group adm >/dev/null 2>&1; then
+        sub "warning: no group adm on this system; grant node access to $_user's group instead"
+    elif id -nG "$_user" 2>/dev/null | tr ' ' '\n' | grep -qx adm; then
+        sub "user $_user: already in group adm"
+    elif usermod -aG adm "$_user"; then
+        sub "added $_user to group adm - re-login (or 'newgrp adm') to activate"
+    else
+        sub "warning: could not add $_user to adm; do it manually: doas usermod -aG adm $_user"
+    fi
+}
+
+# Pre-rename (adlermon) and superseded installer leftovers - the old rule
+# still RUNs its old chmod helper, so remove the pair once ours is in.
+clean_legacy() {
+    for _l in "$SYSCONF/udev/rules.d/60-adlermon-powercap.rules" \
+              "$PREFIX/sbin/adlermon-powercap-chmod" \
+              "$DATADIR/aldermon/powercap-pl-rules.sh" \
+              "$DATADIR/aldermon/aldermon-setup.sh"; do
+        if [ -e "$_l" ]; then
+            rm -f "$_l"
+            sub "removed legacy leftover: $_l"
+        fi
+    done
+}
+
+setcap_helper() { # FORCE - VID gate 2: capability on the minimal helper only
+    [ -x "$MSRBIN" ] || die "$MSRBIN not found - install the binary first (or use 'aldermon --vid' under root)"
+    if [ "$1" != clean ] && getcap "$MSRBIN" 2>/dev/null | grep -q 'cap_sys_rawio'; then
+        sub "kept (already capable): $MSRBIN"
+    else
+        setcap cap_sys_rawio+ep "$MSRBIN"
+        sub "setcap cap_sys_rawio+ep: $MSRBIN"
+    fi
+}
+
+vid_choice() { # root side: ask Y/n unless pre-answered
+    case "$VIDMODE" in
+        on) return 0 ;;
+        off) sub "skipped (declined): VID stays unreadable - the TUI shows 'VID n/a (no msr access)'. Re-run with --vid to add it."; return 1 ;;
+    esac
+    printf 'enable VID extras (msr rule + helper setcap)? [Y/n] '
+    read -r _ans || _ans=y
+    case "$_ans" in
+        [nN]*) sub "skipped (declined): VID stays unreadable - the TUI shows 'VID n/a (no msr access)'. Re-run with --vid to add it."; return 1 ;;
+        *) return 0 ;;
+    esac
+}
+
+verify() { # ORIGUSER VIDENABLED
+    _u=${1:-$(id -un)} _vid=${2:-yes}
+    section verify
+    if [ -x "$BIN" ]; then item "binary: $("$BIN" --version 2>/dev/null || echo 'installed, pre-version')"; fi
+    _c=$(getcap "$MSRBIN" 2>/dev/null || true)
+    if [ "$_vid" = yes ]; then
+        item "helper caps: ${_c:-NONE (VID stays unreadable - run --setup)}"
+    else
+        item "VID: not wanted this run - TUI shows 'VID n/a (no msr access)'"
+        if [ -f "$MSRRULE" ]; then
+            item "note: an msr rule from a previous install is still present (left alone; VID still off until the helper is setcap'd)"
+        fi
+    fi
+    if [ -e /dev/cpu/0/msr ]; then
+        item "msr nodes: $(ls -l /dev/cpu/0/msr | awk '{print $1, $3, $4}')"
+    else
+        item "warning: /dev/cpu/0/msr absent - msr module not loaded"
+    fi
+    _e=$(ls /sys/class/powercap/intel-rapl:0/energy_uj 2>/dev/null || true)
+    if [ -n "$_e" ]; then item "energy_uj: $(ls -l "$_e" | awk '{print $1, $3, $4}')"; fi
+    if [ -f "$CONF" ]; then item "config: $CONF present"; else item "config: $CONF MISSING"; fi
+    if id -nG "$_u" 2>/dev/null | tr ' ' '\n' | grep -qx adm; then
+        item "user $_u: in group adm (existing shells need re-login or 'newgrp adm')"
+    else
+        item "user $_u: NOT in group adm"
+    fi
+}
+
+recon() { # install/setup: status + planned actions, BEFORE any root action
+    _src=$(src_ver) _inst=$(installed_ver)
+    section recon
+    item "installed: ${_inst:-none} | source: ${_src:-unknown}"
+    item "helper: $(helper_state)"
+    item "rules: powercap $([ -f "$RULE" ] && echo present || echo missing), msr $([ -f "$MSRRULE" ] && echo present || echo missing)"
+    item "config: $CONF $([ -f "$CONF" ] && echo present || echo missing)"
+    _e=$(ls -l /sys/class/powercap/intel-rapl:0/energy_uj 2>/dev/null | awk '{print $1, $3, $4}')
+    item "watts source: energy_uj ${_e:-unreadable}"
+    item "planned actions:"
+    if [ "$MODE" != setup ]; then
+        _act=$(bin_action "$FORCE" "$_inst" "$_src")
+        if [ "$MODE" = install ] && [ ! -x "$REL/aldermon" ]; then _act="$_act (after release build)"; fi
+        sub "[1/4] binary+helper: $_act"
+        if [ "$FORCE" = clean ]; then sub "[2/4] config: reset $CONF to stock"
+        elif [ -f "$CONF" ]; then sub "[2/4] config: keep existing $CONF"
+        else sub "[2/4] config: install stock $CONF"; fi
+    fi
+    sub "[3/4] watts: ensure powercap rule + $HELPER (group adm; needs root)"
+    _u=$(id -un)
+    if id -nG "$_u" 2>/dev/null | tr ' ' '\n' | grep -qx adm; then
+        sub "groups: $_u already in adm"
+    else
+        sub "groups: add $_u to adm (needs root; re-login or 'newgrp adm' after)"
+    fi
+    case "$VIDMODE" in
+        off) sub "[4/4] VID: SKIP (--no-vid)" ;;
+        on) sub "[4/4] VID: msr rule + setcap helper (needs root)" ;;
+        *) sub "[4/4] VID: OPTIONAL - asked after the root prompt; msr rule 0440 adm + cap_sys_rawio+ep on aldermon-msr only. Decline keeps 'VID n/a'; vCore/temps/freqs need no privileges either way." ;;
+    esac
+}
+
+recon_uninstall() {
+    section recon
+    item "binary: $([ -x "$BIN" ] && echo "$BIN ($("$BIN" --version 2>/dev/null || echo 'pre-version'))" || echo "not installed")"
+    item "helper: $(helper_state)"
+    item "rules: powercap $([ -f "$RULE" ] && echo present || echo missing), msr $([ -f "$MSRRULE" ] && echo present || echo missing)"
+    item "config: $CONF $([ -f "$CONF" ] && echo present || echo missing)"
+    item "planned actions:"
+    if [ "$PURGE" = yes ]; then
+        sub "remove binaries, capability, both udev rules, RUN helper, installer copy"
+        sub "restore /dev/cpu/*/msr to 0600 root:root and energy_uj to 0400 root:root"
+        sub "remove config $CONF (--purge)"
+    else
+        sub "remove binaries, capability, both udev rules, RUN helper, installer copy"
+        sub "restore /dev/cpu/*/msr to 0600 root:root and energy_uj to 0400 root:root"
+        sub "keep config $CONF (add --purge to remove)"
+    fi
+    case "$ADMREMOVE" in
+        yes) sub "remove $(id -un) from group adm" ;;
+        no) sub "leave group adm alone" ;;
+        *) sub "ask at the root stage whether to remove $(id -un) from group adm" ;;
+    esac
+}
+
+confirm_or_go() { # plan acceptance before escalating
+    [ "$ASSUME" = yes ] && return 0
+    printf 'proceed? [Y/n] '
+    read -r _r || die "no tty for confirmation - use --yes"
+    case "$_r" in
+        [nN]*) say "aborted, nothing changed"; exit 0 ;;
+    esac
+}
+
+root_install() { # FORCE VIDMODE PURGE ORIGUSER PREFIX SYSCONF DATADIR
+    FORCE=$1; VIDMODE=$2; _orig=$4; PREFIX=$5; SYSCONF=$6; DATADIR=$7
+    BIN=$PREFIX/bin/aldermon; MSRBIN=$PREFIX/bin/aldermon-msr
+    CONF=$SYSCONF/aldermon/aldermon.conf; STOCK_CONF=$SRCDIR/aldermon.conf
+    HELPER=$PREFIX/sbin/aldermon-powercap-chmod
+    RULE=$SYSCONF/udev/rules.d/60-aldermon-powercap.rules
+    MSRRULE=$SYSCONF/udev/rules.d/60-aldermon-msr.rules
+
+    [ -x "$REL/aldermon" ] || die "no release build in $SRCDIR - run the script unprivileged (it builds) or cargo build --release first"
+    SRCVER=$(src_ver)
+    INSTVER=$(installed_ver)
+
+    section install
+    item "[1/4] binary (installed: ${INSTVER:-none}, source: $SRCVER)"
+    _act=$(bin_action "$FORCE" "$INSTVER" "$SRCVER")
+    case $_act in
+        install* | upgrade*)
+            install -Dm755 "$REL/aldermon" "$BIN"
+            install -Dm755 "$REL/aldermon-msr" "$MSRBIN"
+            sub "installed: $BIN + $MSRBIN" ;;
+        skip*)
+            if [ ! -x "$MSRBIN" ]; then
+                install -Dm755 "$REL/aldermon-msr" "$MSRBIN"
+                sub "skipped binary (up to date); installed missing helper"
+            else
+                sub "skipped: up to date (use --clean to force)"
+            fi ;;
+        keep*) sub "$_act" ;;
+    esac
+
+    item "[2/4] config"
+    if [ "$FORCE" = clean ] || [ ! -e "$CONF" ]; then
+        [ -f "$STOCK_CONF" ] || die "stock config $STOCK_CONF not found (installed copy? use --setup)"
+        install -Dm644 "$STOCK_CONF" "$CONF"
+        sub "installed stock: $CONF"
+    else
+        sub "kept existing: $CONF (--clean resets to stock)"
+    fi
+
+    item "[3/4] watts (powercap)"
+    write_powercap
+
+    item "[4/4] VID (optional)"
+    _vid=no
+    if vid_choice; then
+        _vid=yes
+        write_msr_rule
+        setcap_helper "$FORCE"
+    fi
+
+    item "group membership"
+    ensure_adm "$_orig"
+
+    verify "$_orig" "$_vid"
+}
+
+root_setup() { # FORCE VIDMODE PURGE ORIGUSER PREFIX SYSCONF DATADIR
+    FORCE=$1; VIDMODE=$2; _orig=$4; PREFIX=$5; SYSCONF=$6; DATADIR=$7
+    BIN=$PREFIX/bin/aldermon; MSRBIN=$PREFIX/bin/aldermon-msr
+    CONF=$SYSCONF/aldermon/aldermon.conf
+    HELPER=$PREFIX/sbin/aldermon-powercap-chmod
+    RULE=$SYSCONF/udev/rules.d/60-aldermon-powercap.rules
+    MSRRULE=$SYSCONF/udev/rules.d/60-aldermon-msr.rules
+    section install
+    item "setup: watts (powercap)"
+    write_powercap
+    item "setup: VID (optional)"
+    _vid=no
+    if vid_choice; then
+        _vid=yes
+        write_msr_rule
+        setcap_helper "$FORCE"
+    fi
+    item "group membership"
+    ensure_adm "$_orig"
+    verify "$_orig" "$_vid"
+}
+
+root_uninstall() { # FORCE VIDMODE PURGE ORIGUSER PREFIX SYSCONF DATADIR ADMREMOVE
+    PURGE=$3; _orig=$4; PREFIX=$5; SYSCONF=$6; DATADIR=$7; ADMREMOVE=$8
+    BIN=$PREFIX/bin/aldermon; MSRBIN=$PREFIX/bin/aldermon-msr
+    CONF=$SYSCONF/aldermon/aldermon.conf
+    HELPER=$PREFIX/sbin/aldermon-powercap-chmod
+    RULE=$SYSCONF/udev/rules.d/60-aldermon-powercap.rules
+    MSRRULE=$SYSCONF/udev/rules.d/60-aldermon-msr.rules
+
+    section uninstall
+    if [ -x "$MSRBIN" ]; then
+        setcap -r "$MSRBIN" 2>/dev/null && item "dropped capability: $MSRBIN" || item "capability already absent"
+    fi
+    for _f in "$BIN" "$MSRBIN" "$HELPER" "$RULE" "$MSRRULE" "$DATADIR/aldermon/aldermon-install.sh"; do
+        if [ -e "$_f" ]; then rm -f "$_f"; item "removed: $_f"; fi
+    done
+    clean_legacy
+    # Restore stock device perms so recon reports them unset again.
+    if [ -e /dev/cpu/0/msr ]; then
+        chgrp root /dev/cpu/*/msr 2>/dev/null || true
+        chmod 0600 /dev/cpu/*/msr 2>/dev/null || true
+        item "restored /dev/cpu/*/msr -> 0600 root:root"
+    fi
+    _e=$(ls /sys/class/powercap/intel-rapl:0/energy_uj 2>/dev/null || true)
+    if [ -n "$_e" ]; then
+        chgrp root "$_e" 2>/dev/null || true
+        chmod 0400 "$_e" 2>/dev/null || true
+        item "restored energy_uj -> 0400 root:root"
+    fi
+    udevadm control --reload-rules
+    item "udev rules reloaded"
+    if [ "$PURGE" = yes ]; then
+        if [ -e "$CONF" ]; then rm -f "$CONF"; item "removed config: $CONF"; fi
+    else
+        item "kept config: $CONF (--purge removes it)"
+    fi
+    rmdir -p --ignore-fail-on-non-empty "$DATADIR/aldermon" 2>/dev/null || true
+    verify_uninstall "$_orig" "$ADMREMOVE"
+}
+
+verify_uninstall() { # ORIGUSER ADMREMOVE
+    _u=${1:-$(id -un)} _adm=${2:-no}
+    section verify
+    item "binary: $([ -e "$BIN" ] && echo STILL PRESENT || echo gone)"
+    item "helper: $([ -e "$MSRBIN" ] && echo STILL PRESENT || echo gone)"
+    item "powercap rule: $([ -e "$RULE" ] && echo STILL PRESENT || echo gone)"
+    item "msr rule: $([ -e "$MSRRULE" ] && echo STILL PRESENT || echo gone)"
+    if [ -e /dev/cpu/0/msr ]; then
+        item "msr node: $(ls -l /dev/cpu/0/msr | awk '{print $1, $3, $4}') (stock 0600 root root)"
+    fi
+    _e=$(ls /sys/class/powercap/intel-rapl:0/energy_uj 2>/dev/null || true)
+    if [ -n "$_e" ]; then item "energy_uj: $(ls -l "$_e" | awk '{print $1, $3, $4}')"; fi
+    item "config: $CONF $([ -e "$CONF" ] && echo present || echo gone)"
+    if id -nG "$_u" 2>/dev/null | tr ' ' '\n' | grep -qx adm; then
+        _do_adm=no
+        case "$_adm" in
+            yes) _do_adm=yes ;;
+            no) item "user $_u: still in group adm (use --del-adm to remove; adm also grants log access)" ;;
+            *)
+                printf 'remove %s from group adm? [y/N] ' "$_u"
+                read -r _r || _r=n
+                case "$_r" in [yY]*) _do_adm=yes ;; esac
+                ;;
+        esac
+        if [ "$_do_adm" = yes ]; then
+            if gpasswd -d "$_u" adm >/dev/null 2>&1; then
+                item "removed $_u from group adm (re-login for it to take effect)"
+            else
+                item "could not remove $_u from adm; do it manually: doas gpasswd -d $_u adm"
+            fi
+        elif [ "$_adm" = ask ]; then
+            item "kept $_u in group adm (adm also grants log access)"
+        fi
+    fi
+}
+
+escalate() { # INTERNALMODE
+    _mode=$1 _u=$(id -un)
+    if [ "$(id -u)" -eq 0 ]; then
+        "$0" "$_mode" "$FORCE" "$VIDMODE" "$PURGE" "$_u" "$PREFIX" "$SYSCONF" "$DATADIR" "$ADMREMOVE"
+    else
+        PRIV=$(command -v doas 2>/dev/null || command -v sudo 2>/dev/null || true)
+        [ -n "$PRIV" ] || die "no doas or sudo found - run the privileged steps as root"
+        say "requesting root via $PRIV"
+        exec "$PRIV" "$0" "$_mode" "$FORCE" "$VIDMODE" "$PURGE" "$_u" "$PREFIX" "$SYSCONF" "$DATADIR" "$ADMREMOVE"
+    fi
+}
+
+check_status() {
+    section status
+    if [ -x "$BIN" ]; then
+        item "binary: $("$BIN" --version 2>/dev/null || echo 'installed, pre-version (<= 2026-09)')"
+        if [ -x "$REL/aldermon" ]; then
+            _s=$("$REL/aldermon" --version | sed -n 's/^aldermon //p')
+            _i=$("$BIN" --version 2>/dev/null | sed -n 's/^aldermon //p')
+            [ -n "$_i" ] || _i=0.0.0
+            if ver_older "$_i" "$_s"; then item "update available: source builds $_s"
+            else item "source build: $_s $([ "$_i" = "$_s" ] && echo '(match)' || echo '(source is older)')"; fi
+        fi
+    else
+        item "binary: NOT INSTALLED ($BIN)"
+    fi
+    item "helper: $(helper_state)"
+    item "powercap rule: $([ -f "$RULE" ] && echo present || echo MISSING)"
+    item "msr rule: $([ -f "$MSRRULE" ] && echo present || echo MISSING)"
+    if [ -e /dev/cpu/0/msr ]; then
+        item "msr node: $(ls -l /dev/cpu/0/msr | awk '{print $1, $3, $4}') (needs 0440 adm + helper cap)"
+    fi
+    item "config: $CONF $([ -f "$CONF" ] && echo present || echo MISSING)"
+    if id -nG "$(id -un)" | tr ' ' '\n' | grep -qx adm; then
+        item "user $(id -un): in adm"
+    else
+        item "user $(id -un): NOT in adm"
+    fi
+}
+
+MODE=install
+FORCE=plain
+VIDMODE=ask
+PURGE=no
+ADMREMOVE=ask
+ASSUME=no
+while [ $# -gt 0 ]; do
+    case $1 in
+        -h | --help) usage; exit 0 ;;
+        --check) MODE=check ;;
+        --setup) MODE=setup ;;
+        --uninstall) MODE=uninstall ;;
+        --purge) MODE=uninstall; PURGE=yes ;;
+        --clean | -c) FORCE=clean ;;
+        --yes | -y) ASSUME=yes ;;
+        --no-vid) VIDMODE=off ;;
+        --vid) VIDMODE=on ;;
+        --del-adm) ADMREMOVE=yes ;;
+        --keep-adm) ADMREMOVE=no ;;
+        _root) shift; root_install "$@"; exit 0 ;;
+        _setup) shift; root_setup "$@"; exit 0 ;;
+        _unroot) shift; root_uninstall "$@"; exit 0 ;;
+        *) die "unknown argument: $1 (see --help)" ;;
+    esac
+    shift
+done
+
+case $MODE in
+    check) check_status ;;
+    uninstall)
+        recon_uninstall
+        confirm_or_go
+        escalate _unroot
+        ;;
+    setup)
+        recon
+        confirm_or_go
+        escalate _setup
+        ;;
+    install)
+        [ -f "$SRCDIR/Cargo.toml" ] || die "$SRCDIR is not a source tree - run from the checkout, or use --setup/--check"
+        recon
+        confirm_or_go
+        if [ ! -x "$REL/aldermon" ]; then
+            command -v cargo >/dev/null || die "cargo not found and no release build present"
+            say "building release binaries (as $(id -un))"
+            (cd "$SRCDIR" && cargo build --release)
+        fi
+        escalate _root
+        ;;
+esac
diff --git a/aldermon/aldermon-setup.sh b/aldermon/aldermon-setup.sh
deleted file mode 100755
index 39bb1c5..0000000
--- a/aldermon/aldermon-setup.sh
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/bin/sh
-# aldermon-setup.sh - one-shot privileged setup for aldermon (run once as
-# root; idempotent, re-running overwrites installed files):
-#
-#   1. RAPL watts: energy_uj is 0400 root-only. Installs a udev rule + RUN
-#      helper chgrp'ing it 0440 to group `adm` so the TUI reads watts
-#      unprivileged.
-#   2. VID: opening /dev/cpu/N/msr must pass TWO gates: the DAC file-mode
-#      check (node is 0600 root:root — CAP_SYS_RAWIO does NOT override DAC),
-#      then msr_open()'s capable(CAP_SYS_RAWIO). So: udev puts the nodes at
-#      0440 group `adm` (gate 1), and cap_sys_rawio+ep goes on the minimal
-#      read-only helper aldermon-msr (gate 2, reads only 0x198). Neither
-#      alone works; the app itself never holds the capability.
-#
-#   doas ./aldermon-setup.sh            (from the source tree)
-#   doas /usr/share/aldermon/aldermon-setup.sh   (after make install)
-
-set -eu
-
-PREFIX=${PREFIX:-/usr/local}
-MSRBIN=$PREFIX/bin/aldermon-msr
-HELPER=$PREFIX/sbin/aldermon-powercap-chmod
-RULE=/etc/udev/rules.d/60-aldermon-powercap.rules
-
-# Helper: relax energy_uj to 0440, group adm, on every powercap dir.
-cat > "$HELPER" <<'EOF'
-#!/bin/sh
-for d in /sys/class/powercap/intel-rapl*; do
-    [ -e "$d/energy_uj" ] && chgrp adm "$d/energy_uj" && chmod 0440 "$d/energy_uj"
-done
-exit 0
-EOF
-chmod 0755 "$HELPER"
-
-# RUN+= (not MODE=/GROUP=) - powercap has no devnode, so udev's standard
-# MODE/GROUP assignment doesn't apply; we chmod in-process instead.
-cat > "$RULE" <<EOF
-SUBSYSTEM=="powercap", ACTION=="add", RUN+="$HELPER"
-EOF
-
-# Apply immediately so a reboot isn't required.
-"$HELPER"
-
-# VID gate 1: unlike powercap, msr IS a real devnode (SUBSYSTEM=="msr",
-# KERNEL=="msr0..N", DEVNAME=/dev/cpu/N/msr), so udev MODE/GROUP works
-# directly — put the nodes 0440 group adm.
-MSRRULE=/etc/udev/rules.d/60-aldermon-msr.rules
-cat > "$MSRRULE" <<'EOF'
-KERNEL=="msr[0-9]*", SUBSYSTEM=="msr", MODE="0440", GROUP="adm"
-EOF
-udevadm control --reload-rules
-udevadm trigger --subsystem-match=msr
-
-# VID gate 2: hand the capability to the helper only. Fail loudly if it's
-# missing — without the cap the helper is inert and the TUI shows "VID n/a".
-if [ -x "$MSRBIN" ]; then
-    setcap cap_sys_rawio+ep "$MSRBIN"
-    echo "  setcap cap_sys_rawio+ep $MSRBIN"
-else
-    echo "aldermon-setup: $MSRBIN not found - run 'doas make install' first," >&2
-    echo "  or use aldermon --vid under root." >&2
-    exit 1
-fi
-
-echo "Installed:"
-echo "  $HELPER"
-echo "  $RULE"
-echo "  $MSRRULE"
-echo "Add yourself to adm if not already: doas usermod -aG adm \$USER"
\ No newline at end of file
diff --git a/aldermon/src/main.rs b/aldermon/src/main.rs
index a2088b8..ed5df05 100644
--- a/aldermon/src/main.rs
+++ b/aldermon/src/main.rs
@@ -26,15 +26,16 @@ usage: aldermon [OPTION]
   --vid        VID vs SIO in0 cross-check spike (needs MSR access, see below)
   --log        append CSV samples to ./aldermon-vid.log
   --help       show this help
+  --version    print version and exit
 
 The TUI always shows the delivered vCore, the requested VID and the delta.
 VID needs MSR access: run as root, or install the setcap'd aldermon-msr
-helper (doas make install + aldermon-setup.sh); otherwise it shows
+helper (doas make install + aldermon-install.sh); otherwise it shows
 'VID n/a (no msr access)'.
 --log appends one CSV row per poll; with --tui it logs live, with --vid it
 logs continuously until interrupted (otherwise --vid runs a 5-sample spike).
 
-Short flags group: -lvt == --log --vid --tui.";
+Short flags group: -lvt == --log --vid --tui. -V == --version.";
 
 #[derive(Default, Clone, Copy)]
 struct Opts {
@@ -54,6 +55,10 @@ fn main() {
                 "tui" => opts.tui = true,
                 "vid" => opts.vid = true,
                 "log" => opts.log = true,
+                "version" => {
+                    println!("aldermon {}", env!("CARGO_PKG_VERSION"));
+                    return;
+                }
                 "help" => {
                     println!("{USAGE}");
                     return;
@@ -66,6 +71,10 @@ fn main() {
                     't' => opts.tui = true,
                     'v' => opts.vid = true,
                     'l' => opts.log = true,
+                    'V' => {
+                        println!("aldermon {}", env!("CARGO_PKG_VERSION"));
+                        return;
+                    }
                     'h' => {
                         println!("{USAGE}");
                         return;