Files
Nomarchy/pkgs/nomarchy-doctor/nomarchy-doctor.sh
Bernardo Magri e324876eb7
Some checks failed
Check / eval (push) Has been cancelled
feat(doctor): #147 — flag machine state the flake never set; linger is the worked example
/var/lib survives a migration untouched, and a stray pre-Nomarchy
Linger=yes marker shaped the QA box's session behavior for six months
with nothing in the flake to show for it. Ship the class as guards:

- nomarchy-doctor: new machine-state-drift check after the flake-checkout
  check — loginctl show-user -p Linger; "yes" warns (nothing is broken —
  it is drift) and prints the disable-linger fix; unknown-to-logind skips.
- docs/MIGRATION.md § Post-migration cleanup: "/var/lib machine state
  survives migration" — the worked example and the general suspicion rule.
- checks.doctor: linger sub-case — warn fires under enable-linger, exit
  stays 0, warning clears after disable-linger.

Deliberately NOT hardening the session wiring against the portal-gtk
restart race linger enabled: a clean install cannot reach that state.
The Waybar exec-once loose thread survives as BACKLOG #149.

Verification: V2 — shellcheck clean, nix flake check --no-build green,
nix build .#checks.x86_64-linux.doctor green (both branches exercised).
Implementation by a Sonnet subagent; design and review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:44:43 +01:00

453 lines
20 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# nomarchy-doctor — one-shot, READ-ONLY health check: the things that
# actually break user machines, as a pass/fail sheet where every
# failure prints the one command that fixes it. It never changes
# anything itself. Exit 0 = no failures (warnings allowed), 1 = at
# least one ✖.
flake="${NOMARCHY_PATH:-$HOME/.nomarchy}"
fails=0
warns=0
grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; }
bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1"
printf ' %bfix: %s%b\n' "$dim" "$2" "$rst"
fails=$((fails + 1)); }
warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1"
if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi
warns=$((warns + 1)); }
skip() { printf ' %b %s%b\n' "$dim" "$1" "$rst"; }
printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')"
# ── systemd units ────────────────────────────────────────────────────
mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
if [ "${#sysfailed[@]}" -eq 0 ]; then
ok "no failed system units"
else
bad "failed system unit(s): ${sysfailed[*]}" \
"journalctl -b -u <unit> (read why; sys-rebuild after fixing)"
fi
# A user bus only exists inside a session (not over bare SSH/root).
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
if [ "${#usrfailed[@]}" -eq 0 ]; then
ok "no failed user units"
else
bad "failed user unit(s): ${usrfailed[*]}" \
"journalctl --user -b -u <unit>"
fi
else
skip "user units (no user session bus here)"
fi
# ── disk space ───────────────────────────────────────────────────────
# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of
# live systems and test VMs (where usage numbers mean nothing).
seen=""
for mp in / /boot /nix; do
[ -d "$mp" ] || continue
line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}')
[ -n "$line" ] || continue
read -r dev fstype use availkb <<<"$line"
case "$fstype" in
ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;;
*) continue ;;
esac
case " $seen " in *" $dev "*) continue ;; esac
seen="$seen $dev"
avail=$(numfmt --to=iec $((availkb * 1024)))
if [ "$use" -ge 90 ]; then
bad "$mp is ${use}% full (${avail} free)" \
"sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
else
ok "$mp has space (${use}% used, ${avail} free)"
fi
done
# ── the flake + state file ───────────────────────────────────────────
# #107: prefer state.json; theme-state.json still counts until migrated.
state_file=
if [ -f "$flake/state.json" ]; then
state_file="$flake/state.json"
elif [ -f "$flake/theme-state.json" ]; then
state_file="$flake/theme-state.json"
fi
if [ -n "$state_file" ]; then
state_base=$(basename "$state_file")
if jq empty "$state_file" 2>/dev/null; then
ok "$state_base parses"
else
bad "$state_base is not valid JSON (rebuilds will fail)" \
"nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
fi
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
ok "$state_base is git-tracked"
else
bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
"git -C $flake add $state_base"
fi
if [ "$state_base" = "theme-state.json" ]; then
warn "state file still named theme-state.json — run any menu write or" \
"nomarchy-state-sync set … to migrate to state.json"
fi
else
skip "state.json (no flake checkout at $flake)"
fi
if [ -d "$flake/.git" ]; then
dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l)
if [ "$dirty" -gt 0 ]; then
warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \
"commit when happy: git -C $flake add -A && git -C $flake commit -m settings"
else
ok "flake checkout is clean"
fi
behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
if [ "$behind" -gt 0 ]; then
warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update"
fi
fi
# ── machine-state drift (survives migration, unseen by the flake) ────
# Worked example: systemd linger. The flake never sets it anywhere, so
# a "yes" here is leftover state from a previous OS/install, not
# something Nomarchy configured — it can cause months of divergent
# session behavior that no amount of flake auditing will explain.
linger=$(loginctl show-user "${USER:-$(id -un)}" --property=Linger --value 2>/dev/null || true)
case "$linger" in
'') skip "user linger (user unknown to logind — no session)" ;;
yes) warn "user linger is enabled but Nomarchy never sets it (machine state the flake can't see)" \
"loginctl disable-linger ${USER:-$(id -un)}" ;;
*) ok "no user linger (matches the flake)" ;;
esac
# ── generation age ───────────────────────────────────────────────────
# The profile SYMLINK's own mtime is the generation's creation time
# (store paths themselves are all epoch-1).
link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)
if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then
ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0)
days=$(( ($(date +%s) - ts) / 86400 ))
if [ "$days" -gt 30 ]; then
warn "last system rebuild was $days days ago" \
"sys-update when convenient (security fixes arrive with input bumps)"
else
ok "system generation is $days day(s) old"
fi
else
skip "system generation age (no system profile)"
fi
# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ─────────
if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then
ok "generation prune timer is enabled (14d, keep ≥3 past)"
else
bad "nomarchy-gen-prune.timer is installed but not enabled" \
"systemctl enable --now nomarchy-gen-prune.timer"
fi
elif command -v nomarchy-gen-prune >/dev/null 2>&1; then
warn "nomarchy-gen-prune is on PATH but no timer unit" \
"rebuild the system so gen-prune.nix ships the weekly timer"
else
skip "generation prune (package not on this machine)"
fi
# ── snapper timeline (when enabled) ──────────────────────────────────
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-active --quiet snapper-timeline.timer; then
ok "snapper timeline snapshots are running"
else
bad "snapper is enabled but the timeline timer is not active" \
"systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)"
fi
else
skip "snapper (not enabled on this machine)"
fi
# ── first-boot HM pre-activate (installer fail flag, BACKLOG #83) ────
# Installer writes /var/log/nomarchy-hm-preactivate.log on the target.
# If that log exists and there is still no Home Manager generation, the
# desktop never baked — print the recovery one-liner. Override log path
# with NOMARCHY_HM_PREACTIVATE_LOG for checks.doctor.
pre_log="${NOMARCHY_HM_PREACTIVATE_LOG:-/var/log/nomarchy-hm-preactivate.log}"
hm_gen_present=0
uid_name="${USER:-$(id -un 2>/dev/null || echo)}"
for d in \
"${XDG_STATE_HOME:-${HOME:-}/.local/state}/nix/profiles" \
"/nix/var/nix/profiles/per-user/${uid_name}"; do
[ -n "$d" ] || continue
if [ -e "$d/home-manager" ] \
|| ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
hm_gen_present=1
break
fi
done
if [ ! -r "$pre_log" ]; then
skip "first-boot pre-activate (no installer log — not a failed bake)"
elif [ "$hm_gen_present" -eq 1 ]; then
ok "first-boot pre-activate: desktop generation is present"
else
bad "desktop was not pre-activated at install (no Home Manager generation)" \
"home-manager switch --flake ~/.nomarchy -b bak (details: $pre_log)"
fi
# ══ hardware ═════════════════════════════════════════════════════════
# Every check below self-gates: it skips cleanly when the tool, service,
# or device isn't present, so the section shrinks to fit the machine and
# never fails just because a feature is absent. Still read-only.
# ── network (NetworkManager) ─────────────────────────────────────────
if command -v nmcli >/dev/null 2>&1; then
nmstate=$(nmcli -t -f STATE general status 2>/dev/null || true)
case "$nmstate" in
connected*) ok "NetworkManager: $nmstate" ;;
"") skip "NetworkManager (no state reported)" ;;
*) warn "NetworkManager state: $nmstate" \
"connect in System Network (or: nmcli device)" ;;
esac
else
skip "NetworkManager (nmcli not present)"
fi
# ── default audio sink (PipeWire) ────────────────────────────────────
# wpctl/pactl talk to the user's PipeWire session — only meaningful with
# a user bus (not over bare SSH/root).
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
if command -v wpctl >/dev/null 2>&1; then
if wpctl inspect @DEFAULT_AUDIO_SINK@ >/dev/null 2>&1; then
ok "default audio sink present"
else
warn "no default PipeWire sink" "pick one in System Audio (or: wpctl status)"
fi
elif command -v pactl >/dev/null 2>&1; then
defsink=$(pactl get-default-sink 2>/dev/null || true)
if [ -n "$defsink" ] && [ "$defsink" != "@DEFAULT_SINK@" ]; then
ok "default audio sink: $defsink"
else
warn "no default audio sink" "pick one in System Audio"
fi
else
skip "audio sink (no wpctl/pactl)"
fi
else
skip "audio sink (no user session bus here)"
fi
# ── GPU acceleration smoke (only if the probes are installed) ────────
if command -v vainfo >/dev/null 2>&1; then
if vainfo >/dev/null 2>&1; then
ok "VA-API acceleration works (vainfo)"
else
warn "vainfo present but VA-API probe failed" \
"check mkFlake.hardwareProfile / GPU drivers (or run: vainfo)"
fi
else
skip "VA-API smoke (vainfo not installed)"
fi
if command -v glxinfo >/dev/null 2>&1; then
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
if glxinfo -B >/dev/null 2>&1; then
ok "OpenGL renderer responds (glxinfo)"
else
warn "glxinfo present but GL probe failed" \
"check mkFlake.hardwareProfile / GPU drivers (or run: glxinfo -B)"
fi
else
skip "OpenGL smoke (no display attached)"
fi
else
skip "OpenGL smoke (glxinfo not installed)"
fi
# ── fingerprint (only when fprintd is enabled) ───────────────────────
if [ -n "$(systemctl list-unit-files fprintd.service --no-legend --plain 2>/dev/null || true)" ]; then
enrolled=""
if command -v fprintd-list >/dev/null 2>&1 && [ -n "${USER:-}" ]; then
enrolled=$(fprintd-list "$USER" 2>/dev/null || true)
fi
if [ -n "$enrolled" ] && ! printf '%s' "$enrolled" | grep -qi 'no fingers'; then
ok "fprintd enabled, fingerprint(s) enrolled for ${USER:-user}"
elif [ -n "$enrolled" ]; then
warn "fprintd enabled but no fingerprints enrolled" "enroll one: fprintd-enroll"
else
ok "fprintd unit installed (fingerprint enabled)"
fi
else
skip "fingerprint (fprintd not enabled)"
fi
# ── firmware updates (fwupd) ─────────────────────────────────────────
# fwupd is D-Bus-activated, so an idle daemon is normal, not a fault.
# get-updates reads local metadata (no flashing); a hit is a soft warn.
if command -v fwupdmgr >/dev/null 2>&1 \
&& [ -n "$(systemctl list-unit-files fwupd.service --no-legend --plain 2>/dev/null || true)" ]; then
if systemctl is-active --quiet fwupd.service; then
ok "fwupd daemon active"
else
skip "fwupd daemon (idle — D-Bus-activated on demand)"
fi
if timeout 20 fwupdmgr get-updates >/dev/null 2>&1; then
warn "firmware updates are available" "review, then apply: fwupdmgr update"
else
ok "firmware up to date (no pending updates)"
fi
else
skip "fwupd (not enabled / fwupdmgr absent)"
fi
# ── laptop battery charge threshold (only when a limit is set) ───────
# Name-agnostic (BAT0, CMB0, …) — same type/scope filter as notify (#60).
# Dell: Adaptive mode ignores end_threshold while still reporting the
# written value — warn if type is not Custom when a limit is active.
bat_seen=0; bat_limited=0
for bat in /sys/class/power_supply/*/; do
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
f="$bat/charge_control_end_threshold"
[ -r "$f" ] || continue
bat_seen=1
lim=$(cat "$f" 2>/dev/null || true)
case "$lim" in
''|*[!0-9]*) continue ;;
esac
if [ "$lim" -lt 100 ]; then
bat_limited=1
ctype=$(cat "$bat/charge_types" 2>/dev/null || cat "$bat/charge_type" 2>/dev/null || true)
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
name=$(basename "$bat")
if [ -n "$active" ] && [ "$active" != Custom ]; then
warn "$name charge limit ${lim}% but type is $active (not Custom)" \
"thresholds only apply in Custom — run: systemctl restart nomarchy-battery-charge-limit"
else
ok "$name charge limit active at ${lim}%${active:+ (type $active)}"
fi
fi
done
if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
skip "battery charge limit (none set — charges to 100%)"
fi
# ── battery health (report-only, BACKLOG #80) ────────────────────────
# cycle_count + retained capacity (charge_* µAh or energy_* µWh). Same
# system-battery filter as charge-limit/notify. Self-gates when no
# battery or the firmware omits the attrs (desktops, bare VMs). Override
# the sysfs root with NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
# fixture (test_power has no cycle/design attrs).
ps_root="${NOMARCHY_POWER_SUPPLY_ROOT:-/sys/class/power_supply}"
bat_health_seen=0
for bat in "$ps_root"/*/; do
[ -d "$bat" ] || continue
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
name=$(basename "$bat")
cycles=$(cat "$bat/cycle_count" 2>/dev/null || true)
case "$cycles" in ''|*[!0-9]*) cycles= ;; esac
# Some firmwares export 0 forever — treat as unknown, not "brand new".
if [ -n "$cycles" ] && [ "$cycles" -eq 0 ]; then cycles=; fi
full=""; design=""
if [ -r "$bat/charge_full" ] && [ -r "$bat/charge_full_design" ]; then
full=$(cat "$bat/charge_full" 2>/dev/null || true)
design=$(cat "$bat/charge_full_design" 2>/dev/null || true)
elif [ -r "$bat/energy_full" ] && [ -r "$bat/energy_full_design" ]; then
full=$(cat "$bat/energy_full" 2>/dev/null || true)
design=$(cat "$bat/energy_full_design" 2>/dev/null || true)
fi
case "$full" in ''|*[!0-9]*) full= ;; esac
case "$design" in ''|*[!0-9]*) design= ;; esac
pct=""
if [ -n "$full" ] && [ -n "$design" ] && [ "$design" -gt 0 ]; then
pct=$(( full * 100 / design ))
fi
[ -n "$cycles" ] || [ -n "$pct" ] || continue
bat_health_seen=1
detail=""
[ -n "$cycles" ] && detail="${cycles} cycles"
if [ -n "$pct" ]; then
[ -n "$detail" ] && detail="$detail, "
detail="${detail}${pct}% of design capacity"
fi
# Soft warn only — wear isn't a doctor "fix", just a heads-up.
if [ -n "$pct" ] && [ "$pct" -lt 70 ]; then
warn "$name health: $detail" \
"battery wear is normal over years — replace when runtime suffers"
else
ok "$name health: $detail"
fi
done
if [ "$bat_health_seen" -eq 0 ]; then
skip "battery health (no system battery / no cycle or design capacity attrs)"
fi
# ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
# Read-only: is there a working hibernate path? zram is RAM-only and can't
# hold a resume image, so hibernation needs a disk swap (partition or file)
# PLUS resume= wiring; a swapfile additionally needs resume_offset. A
# swap=0 machine opts out cleanly and this section skips. Never fails the
# sheet — an absent or under-sized swap is advisory, not a running fault.
# (|| echo 0 keeps set -euo pipefail happy if a proc file is unreadable.)
disk_swap_kb=$(awk 'NR>1 && $1 !~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
zram_kb=$(awk 'NR>1 && $1 ~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
if [ "${disk_swap_kb:-0}" -gt 0 ]; then
if grep -q 'resume=' /proc/cmdline 2>/dev/null; then
ok "hibernate: resume device set on the kernel cmdline"
# A swapfile also needs resume_offset to locate the image within it.
if awk 'NR>1 && $1 !~ /zram/ && $2=="file"{f=1} END{exit !f}' /proc/swaps 2>/dev/null \
&& ! grep -q 'resume_offset=' /proc/cmdline 2>/dev/null; then
warn "swapfile in use but no resume_offset= on the cmdline — resume will fail" \
"add boot.kernelParams resume_offset (docs/MIGRATION.md → Enabling hibernation)"
fi
else
warn "disk swap present but no resume= on the kernel cmdline — hibernate won't resume" \
"set boot.resumeDevice (docs/MIGRATION.md → Enabling hibernation)"
fi
if [ "${ram_kb:-0}" -gt 0 ] && [ "$disk_swap_kb" -lt "$ram_kb" ]; then
warn "disk swap ($(numfmt --to=iec $((disk_swap_kb * 1024)))) is smaller than RAM ($(numfmt --to=iec $((ram_kb * 1024)))) — a full hibernate image may not fit" \
"size swap ≥ RAM (docs/MIGRATION.md → Enabling hibernation)"
else
ok "hibernate: disk swap ≥ RAM ($(numfmt --to=iec $((disk_swap_kb * 1024))))"
fi
else
skip "hibernate (no disk swap — swap=0 opt-out; zram alone can't resume)"
fi
# zram compressed-RAM swap — the memory-pressure layer oom.nix ships on
# by default (#76). Its absence on a Nomarchy box means something is off.
if [ "${zram_kb:-0}" -gt 0 ]; then
ok "zram compressed-RAM swap active ($(numfmt --to=iec $((zram_kb * 1024))))"
else
warn "zram swap not active (the default memory-pressure layer)" \
"expected on by default — check modules/nixos/oom.nix, then sys-rebuild"
fi
# Best-effort: a suspend/hibernate failure recorded in the previous boot's
# kernel log. Needs journal read access; silently no-ops without it. The
# `|| true` keeps a no-match grep / missing -1 boot from tripping set -e.
if command -v journalctl >/dev/null 2>&1; then
pmerr=$(journalctl -b -1 -k --no-pager 2>/dev/null \
| grep -iE 'PM: .*(hibernat|suspend).*(fail|error)|Failed to (hibernate|suspend)' \
| tail -n1 || true)
if [ -n "$pmerr" ]; then
warn "a suspend/hibernate error is in the previous boot's log" \
"review: journalctl -b -1 -k -g 'hibernat|suspend'"
fi
fi
# ── verdict ──────────────────────────────────────────────────────────
echo
if [ "$fails" -eq 0 ]; then
printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns"
exit 0
else
printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns"
exit 1
fi