#!/usr/bin/env bash # # health-check.sh — after a reboot (or any time), report what is and is not running # on your always-on Mac mini AI server. # # Read-only: this script never changes a setting, starts, or stops anything. # Each line is PASS, WARN, FAIL, or INFO. Exit status is 1 if anything FAILed. # # Checks: # - macOS version and uptime (was there a surprise reboot?) # - power settings: sleep, autorestart, womp # - FileVault and automatic login (what happens after a power cut) # - Remote Login (SSH) and Screen Sharing listening locally # - Tailscale connected # - apps you expect to be running (Cursor, Claude, sync clients, ...) # - every LaunchAgent in ~/Library/LaunchAgents, plus any you name # - free disk space, internet reachability, last Time Machine backup # # Usage: ./health-check.sh [--app NAME]... [--agent LABEL]... [--config FILE] set -euo pipefail readonly SCRIPT_NAME="${0##*/}" readonly VERSION="1.0.0" readonly DEFAULT_CONFIG="$HOME/.config/mac-ai-server/health-check.conf" CONFIG_FILE="" CHECK_NETWORK=1 USE_COLOR=0 [[ -t 1 ]] && USE_COLOR=1 # Apps reported as INFO if missing; anything passed with --app is required (FAIL if missing). OPTIONAL_APPS=("Cursor" "Claude" "Tailscale" "Google Drive" "OneDrive") REQUIRED_APPS=() REQUIRED_AGENTS=() PASS_COUNT=0 WARN_COUNT=0 FAIL_COUNT=0 usage() { cat <&2 exit 2 } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --app) [[ $# -ge 2 && -n $2 ]] || die "--app needs a process name" REQUIRED_APPS+=("$2") shift ;; --agent) [[ $# -ge 2 && -n $2 ]] || die "--agent needs a LaunchAgent label" REQUIRED_AGENTS+=("$2") shift ;; --config) [[ $# -ge 2 && -n $2 ]] || die "--config needs a file path" CONFIG_FILE=$2 shift ;; --no-network) CHECK_NETWORK=0 ;; --no-color) USE_COLOR=0 ;; --dry-run) ;; -h | --help) usage exit 0 ;; -V | --version) printf '%s %s\n' "$SCRIPT_NAME" "$VERSION" exit 0 ;; *) die "unknown option '$1' (try --help)" ;; esac shift done } # Config lines: "app=Name" or "agent=label". Blank lines and # comments are ignored. # The file is parsed, never sourced, so it cannot run code. load_config() { local file=$1 line key value [[ -r $file ]] || die "cannot read config file '$file'" while IFS= read -r line || [[ -n $line ]]; do line=${line%%#*} line=$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') [[ -z $line ]] && continue key=${line%%=*} value=${line#*=} case "$key" in app) REQUIRED_APPS+=("$value") ;; agent) REQUIRED_AGENTS+=("$value") ;; *) printf 'warning: ignoring unknown config line: %s\n' "$line" >&2 ;; esac done <"$file" } paint() { local code=$1 text=$2 if ((USE_COLOR)); then printf '\033[%sm%s\033[0m' "$code" "$text" else printf '%s' "$text" fi } pass() { PASS_COUNT=$((PASS_COUNT + 1)) printf ' %s %s\n' "$(paint 32 PASS)" "$*" } warn() { WARN_COUNT=$((WARN_COUNT + 1)) printf ' %s %s\n' "$(paint 33 WARN)" "$*" } fail() { FAIL_COUNT=$((FAIL_COUNT + 1)) printf ' %s %s\n' "$(paint 31 FAIL)" "$*" } info() { printf ' %s %s\n' "$(paint 36 INFO)" "$*"; } section() { printf '\n%s\n' "$(paint 1 "$*")"; } pmset_current() { pmset -g 2>/dev/null | awk -v key="$1" '$1 == key { print $2; exit }' } # Is something listening on localhost:PORT? (nc on macOS supports -G for connect timeout.) port_open() { nc -z -G 2 127.0.0.1 "$1" >/dev/null 2>&1 } check_system() { section "System" local version boot_sec now up_min version=$(sw_vers -productVersion 2>/dev/null || echo "unknown") info "macOS $version" boot_sec=$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ sec = \([0-9]*\),.*/\1/p') if [[ -n $boot_sec ]]; then now=$(date +%s) up_min=$(((now - boot_sec) / 60)) if ((up_min < 60)); then warn "Up for ${up_min} min — it rebooted recently. Check everything below came back." else pass "Up for $((up_min / 1440))d $(((up_min % 1440) / 60))h" fi else info "Uptime unavailable" fi } check_power() { section "Power (pmset)" local v v=$(pmset_current sleep) if [[ $v == "0" ]]; then pass "System sleep disabled (sleep 0)"; else fail "System sleep is ${v:-unknown} min — run setup-power.sh"; fi v=$(pmset_current autorestart) if [[ $v == "1" ]]; then pass "Restart after power failure on (autorestart 1)" elif [[ -z $v ]]; then info "autorestart not reported; check System Settings > Energy" else fail "Restart after power failure is off — run setup-power.sh" fi v=$(pmset_current womp) if [[ $v == "1" ]]; then pass "Wake for network access on (womp 1)"; else warn "Wake for network access is off"; fi v=$(pmset_current displaysleep) if [[ $v == "0" ]]; then info "Display never sleeps (displaysleep 0)" elif [[ -n $v ]]; then info "Display sleeps after ${v} min (fine for a headless server)" fi return 0 } check_unlock() { section "Unattended restart" local fv auto fv=$(fdesetup status 2>/dev/null | head -n 1 || true) auto=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null || true) if [[ $fv == *"is On"* ]]; then info "FileVault is On — after a power cut the Mac waits for a password before your apps start." info " macOS 26+: unlock over SSH (password) if Remote Login is on. Planned reboots: sudo fdesetup authrestart" elif [[ $fv == *"is Off"* ]]; then warn "FileVault is Off — disk is not encrypted at rest (a deliberate trade-off; see the guide)." else info "FileVault status unavailable" fi if [[ -n $auto ]]; then info "Automatic login is enabled for one account (apps and LaunchAgents start after reboot)." elif [[ $fv == *"is On"* ]]; then info "Automatic login is off (expected with FileVault)." else warn "Automatic login is off — LaunchAgents and login items wait until someone logs in." fi } check_remote_access() { section "Remote access" if port_open 22; then pass "Remote Login (SSH) is listening"; else warn "Remote Login (SSH) is not listening on port 22"; fi if port_open 5900; then pass "Screen Sharing is listening"; else warn "Screen Sharing is not listening on port 5900"; fi local ts="" if command -v tailscale >/dev/null 2>&1; then ts=tailscale elif [[ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then ts=/Applications/Tailscale.app/Contents/MacOS/Tailscale fi if [[ -z $ts ]]; then warn "Tailscale CLI not found (install Tailscale or enable its CLI integration)" elif TAILSCALE_BE_CLI=1 "$ts" status >/dev/null 2>&1; then pass "Tailscale is connected" else fail "Tailscale is installed but not connected (open the app or run 'tailscale up')" fi } app_running() { pgrep -x "$1" >/dev/null 2>&1 } check_apps() { section "Apps" local app for app in "${REQUIRED_APPS[@]+"${REQUIRED_APPS[@]}"}"; do if app_running "$app"; then pass "$app is running"; else fail "$app is NOT running (required)"; fi done for app in "${OPTIONAL_APPS[@]}"; do if [[ " ${REQUIRED_APPS[*]+"${REQUIRED_APPS[*]}"} " == *" $app "* ]]; then continue; fi if app_running "$app"; then info "$app is running"; else info "$app is not running"; fi done } # Prints "state|last exit code|last terminating signal" (fails if not loaded). launchctl # prints either an exit code or, for a crash or kill, only a signal such as "Killed: 9". agent_state() { local out out=$(launchctl print "gui/$(id -u)/$1" 2>/dev/null) || return 1 local state code signal state=$(printf '%s\n' "$out" | awk -F' = ' '/^[[:space:]]*state = / { print $2; exit }') code=$(printf '%s\n' "$out" | awk -F' = ' '/^[[:space:]]*last exit code = / { print $2; exit }') signal=$(printf '%s\n' "$out" | awk -F' = ' '/^[[:space:]]*last terminating signal = / { print $2; exit }') printf '%s|%s|%s\n' "${state:-loaded}" "${code:-}" "${signal:-}" } check_agent() { local label=$1 required=$2 result state code signal if result=$(agent_state "$label"); then state=${result%%|*} result=${result#*|} code=${result%%|*} signal=${result#*|} if [[ $state == "running" ]]; then pass "$label is running" elif [[ -n $signal ]]; then warn "$label is loaded but not running (last terminating signal: $signal) — see its log files" elif [[ -n $code && $code != "0" && $code != "(never exited)" ]]; then warn "$label is loaded but not running (last exit code: $code) — see its log files" else pass "$label is loaded (${state})" fi elif ((required)); then fail "$label is NOT loaded (required) — see install-launchagent.sh" else warn "$label has a plist but is not loaded" fi } check_agents() { section "LaunchAgents" local dir="$HOME/Library/LaunchAgents" plist label seen=" " checked=0 local label_req for label_req in "${REQUIRED_AGENTS[@]+"${REQUIRED_AGENTS[@]}"}"; do check_agent "$label_req" 1 seen+="$label_req " checked=$((checked + 1)) done if [[ -d $dir ]]; then for plist in "$dir"/*.plist; do [[ -e $plist ]] || continue label=$(basename "$plist" .plist) [[ $seen == *" $label "* ]] && continue check_agent "$label" 0 checked=$((checked + 1)) done fi if ((checked == 0)); then info "No LaunchAgents found in ~/Library/LaunchAgents" fi } # On APFS, / is the sealed system volume: df's Used and Capacity for it count only macOS # itself (~13 GB) and badly understate a full disk. Size and Available are the whole # container's, so the real fullness is (size - available) / size. check_disk() { section "Storage" local usage used free_gb usage=$(df -Pk / 2>/dev/null | awk 'NR == 2 && $2 > 0 { printf "%d %d\n", ($2 - $4) * 100 / $2 + 0.5, $4 * 1024 / 1e9 }') used=${usage% *} free_gb=${usage#* } if [[ -z $usage ]]; then info "Disk usage unavailable" elif ((used >= 90)); then fail "Startup disk is ${used}% full (${free_gb} GB free)" elif ((used >= 80)); then warn "Startup disk is ${used}% full (${free_gb} GB free)" else pass "Startup disk is ${used}% full (${free_gb} GB free)" fi # tmutil exits 0 even when it fails, so look at what it printed. local latest destinations latest=$(tmutil latestbackup 2>/dev/null || true) destinations=$(tmutil destinationinfo 2>/dev/null || true) if [[ -n $latest && $latest == /* ]]; then info "Last Time Machine backup: ${latest##*/}" elif [[ $destinations == *"No destinations configured"* ]]; then info "Time Machine has no backup disk set up" else info "No Time Machine backup found (or Terminal lacks Full Disk Access to read it)" fi } check_network() { section "Network" if ((!CHECK_NETWORK)); then info "Skipped (--no-network)" return 0 fi if curl -fsS -m 5 -o /dev/null https://www.apple.com/library/test/success.html 2>/dev/null; then pass "Internet is reachable" else fail "Internet is not reachable" fi } main() { parse_args "$@" if [[ -n $CONFIG_FILE ]]; then load_config "$CONFIG_FILE" elif [[ -r $DEFAULT_CONFIG ]]; then load_config "$DEFAULT_CONFIG" fi if [[ $(uname -s) != "Darwin" ]]; then die "this script only runs on macOS (uname reports '$(uname -s)')" fi printf '%s\n' "$(paint 1 "Mac mini AI server — health check") ($(date '+%Y-%m-%d %H:%M'))" check_system check_power check_unlock check_remote_access check_apps check_agents check_disk check_network printf '\nSummary: %d pass, %d warn, %d fail\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" ((FAIL_COUNT == 0)) } main "$@"