feat(doctor): nomarchy-doctor — read-only health sheet + VM check (item 10)
Some checks failed
Check / eval (push) Has been cancelled
Some checks failed
Check / eval (push) Has been cancelled
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>
This commit is contained in:
138
pkgs/nomarchy-doctor/nomarchy-doctor.sh
Normal file
138
pkgs/nomarchy-doctor/nomarchy-doctor.sh
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user