Files
Nomarchy/pkgs/nomarchy-doctor/nomarchy-doctor.sh
Bernardo Magri 2a23e82169
Some checks failed
Check / eval (push) Has been cancelled
feat(doctor): nomarchy-doctor — read-only health sheet + VM check (item 10)
The things that actually break user machines, one command (and menu ›
System › Doctor): failed system/user units, disk space (real
filesystems only — fstype allowlist makes it VM/live safe, device
dedupe collapses / and /nix), theme-state.json parses + is
git-tracked, flake dirty/behind as warnings, generation age (profile
symlink mtime — store paths are epoch-1), snapper timeline when
enabled. Read-only by contract; every ✖ prints the one fixing command;
exit 1 on any problem. Ships as pkgs/nomarchy-doctor so the VM check
runs on a minimal node.

Verified: V1 package build (shellcheck-gated) + a real-hardware smoke
that correctly flagged a genuinely failed user unit on the dev box;
V2 green — checks.doctor VM test: induced failed unit flips the sheet
to exit-1 naming the unit and its fix, reset-failed returns healthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:34:11 +01:00

139 lines
5.9 KiB
Bash
Raw 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 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)" \
"jq . $flake/theme-state.json (shows the bad 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
# ── 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