Some checks failed
Check / eval (push) Has been cancelled
Report-only cycle_count + retained capacity (charge_* or energy_*) as % of design on system batteries; soft warn below 70% of design; skip when attrs missing. NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor fixture (test_power has no cycle/design attrs). Verified: V2 — nix flake check --no-build; checks.doctor green (fake BAT0 cycles/% + Device-scope ignore); local BAT0 smoke (energy_*).
386 lines
17 KiB
Bash
386 lines
17 KiB
Bash
# 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 nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)"
|
||
else
|
||
ok "$mp has space (${use}% used, ${avail} free)"
|
||
fi
|
||
done
|
||
|
||
# ── the flake + state file ───────────────────────────────────────────
|
||
if [ -f "$flake/theme-state.json" ]; then
|
||
if jq empty "$flake/theme-state.json" 2>/dev/null; then
|
||
ok "theme-state.json parses"
|
||
else
|
||
bad "theme-state.json is not valid JSON (rebuilds will fail)" \
|
||
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
|
||
fi
|
||
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then
|
||
ok "theme-state.json is git-tracked"
|
||
else
|
||
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \
|
||
"git -C $flake add theme-state.json"
|
||
fi
|
||
else
|
||
skip "theme-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
|
||
|
||
# ── 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
|
||
|
||
# ── 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
|
||
|
||
# ══ 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
|