Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions LaunchAgents/com.asiago.disk-cleanup.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.asiago.disk-cleanup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/andrewrich/.local/bin/disk-cleanup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>0</integer>
<key>Hour</key>
<integer>4</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/Users/andrewrich/.local/state/disk-cleanup.out</string>
<key>StandardErrorPath</key>
<string>/Users/andrewrich/.local/state/disk-cleanup.out</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
32 changes: 32 additions & 0 deletions LaunchAgents/com.asiago.disk-space-alert.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.asiago.disk-space-alert</string>
<key>ProgramArguments</key>
<array>
<string>/Users/andrewrich/.local/bin/disk-space-alert.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/Users/andrewrich/.local/state/disk-space-alert.out</string>
<key>StandardErrorPath</key>
<string>/Users/andrewrich/.local/state/disk-space-alert.out</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Internal repo; not intended for public consumption.
| `reset-tccd.sh` | Resets the macOS TCC daemon (Transparency, Consent, and Control) and waits for it to respawn. Used when privacy-permission state gets wedged. |
| `local-macs.sh` | Lists ssh-able Macs on the LAN (Bonjour `_ssh._tcp`, 3-second browse), excluding this host and any name with a space. |
| `allup-fleet.sh` | Runs `allup` on every host from `local-macs` at once, one tmux pane per host via `ssh -t` — so each remote run stays interactive (casks, macOS updates, sudo prompts) and its output stays separate. `-d` starts without attaching; any other arguments replace `allup` as the remote command. Tests in `tests/allup-fleet.bats`. |
| `disk-cleanup.sh` | Weekly cache cleanup for the data volume. It runs `npm cache clean --force`, `uv cache prune` and `xcrun simctl delete unavailable`. The simctl step runs under `timeout` (120 s) and skips if there is no `timeout` or `gtimeout`. The script deletes no path itself; each tool cleans its own cache. A missing tool is a logged skip. A failed step does not stop the others, but the run then exits 1. It logs free space before and after. Runs under `LaunchAgents/com.asiago.disk-cleanup.plist` (Sunday 04:00) and logs to `~/.local/state/disk-cleanup.out`. Design: [`docs/plans/2026-10-22-disk-space-automation.md`](docs/plans/2026-10-22-disk-space-automation.md). Tests in `tests/disk-cleanup.bats`. |
| `disk-space-alert.sh` | Daily low-disk alert. If free space on `/System/Volumes/Data` is below 40 GiB (the first argument overrides it), it posts a macOS notification with `osascript`. It also posts one if it cannot read free space. It deletes nothing. `osascript` can exit 0 when the notification is not shown, so the log is the record. Runs under `LaunchAgents/com.asiago.disk-space-alert.plist` (daily 09:00) and logs to `~/.local/state/disk-space-alert.out`. Tests in `tests/disk-space-alert.bats`. |
| `tm-caffeinate.sh` | Keeps the machine awake while Time Machine is backing up. LaunchAgent-driven: re-execs under `caffeinate` and polls until the backup completes, then exits. |

### Development workflow
Expand Down Expand Up @@ -92,7 +94,7 @@ Internal repo; not intended for public consumption.
- `docs/plans/` — design docs and specs for non-trivial scripts.
- `tests/` — `bats` test suites and fixtures (`ssh-wrapper.bats`).
- `LaunchAgents/` — plists for scripts in this repo, symlinked into `~/Library/LaunchAgents/` (see `CLAUDE.md`).
- `.github/workflows/` — `claude-blocking-review.yml`, `dependabot-auto-merge.yml` (PR-review automation and dependabot auto-merge). Scheduled ralph-burndown automation moved to a LaunchAgent on mimolette.local.
- `.github/workflows/` — `bats.yml` (bats suites and shellcheck), `standards-check.yml`, `auto-tag.yml` (tags each push to main) and `dependabot-auto-merge.yml`. The scheduled ralph-burndown automation is no longer in operation.

## Standards

Expand Down
152 changes: 152 additions & 0 deletions disk-cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
set -euo pipefail

# Weekly cache cleanup. Each tool deletes its own cache; never add rm here.
# Why: docs/plans/2026-10-22-disk-space-automation.md (Incident).

# Each tool is a variable so tests can stub it. Empty or not executable = missing.
NPM_BIN="${NPM_BIN:-$(command -v npm || true)}"
UV_BIN="${UV_BIN:-$(command -v uv || true)}"
XCRUN_BIN="${XCRUN_BIN:-$(command -v xcrun || true)}"
DF_BIN="${DF_BIN:-$(command -v df || true)}"

# simctl hung past 300 s unwrapped on 2026-10-22. timeout is from Homebrew
# coreutils; without it the step skips.
TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v timeout || command -v gtimeout || true)}"
SIMCTL_TIMEOUT_SECONDS="${SIMCTL_TIMEOUT_SECONDS:-120}"
SIMCTL_KILL_GRACE_SECONDS=10

# Free space is logged for the Data volume; / is the read-only system volume.
DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}"

# Step results.
STEP_OK=0
STEP_FAILED=1
STEP_SKIPPED=2

# =========================================================
# FUNCTIONS
# =========================================================
function log_info() {
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[${timestamp}] [INFO] $*"
}

function log_error() {
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[${timestamp}] [ERROR] $*" >&2
}

# True when $1 names an executable file. An empty value is "missing".
function have_tool() {
[[ -n "${1:-}" && -x "${1}" ]]
}

# Report only: a df failure is logged but never changes the exit status.
function log_free_space() {
local label="$1"
local avail_kib

if ! have_tool "${DF_BIN}"; then
log_info "Free space ${label}: unknown (df not found)"
return 0
fi

# -P forces one line per filesystem on both macOS and Linux; Available is
# field 4 in both.
if ! avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" \
|| [[ ! "${avail_kib}" =~ ^[0-9]+$ ]]; then
log_error "Free space ${label}: could not read df for ${DATA_VOLUME}"
return 0
fi

log_info "Free space ${label}: $((avail_kib / 1024 / 1024)) GiB (${avail_kib} KiB) on ${DATA_VOLUME}"
}

function step_npm() {
if ! have_tool "${NPM_BIN}"; then
log_info "npm: not found, skipping"
return "${STEP_SKIPPED}"
fi
log_info "npm: cache clean --force"
if ! "${NPM_BIN}" cache clean --force; then
log_error "npm: cache clean failed"
return "${STEP_FAILED}"
fi
return "${STEP_OK}"
}

# `prune`, not `clean`: prune keeps cache entries still in use.
function step_uv() {
if ! have_tool "${UV_BIN}"; then
log_info "uv: not found, skipping"
return "${STEP_SKIPPED}"
fi
log_info "uv: cache prune"
if ! "${UV_BIN}" cache prune; then
log_error "uv: cache prune failed"
return "${STEP_FAILED}"
fi
return "${STEP_OK}"
}

function step_simctl() {
if ! have_tool "${XCRUN_BIN}"; then
log_info "simctl: xcrun not found, skipping"
return "${STEP_SKIPPED}"
fi
if ! have_tool "${TIMEOUT_BIN}"; then
log_info "simctl: timeout/gtimeout not found, skipping rather than running simctl unbounded"
return "${STEP_SKIPPED}"
fi
log_info "simctl: delete unavailable (timeout ${SIMCTL_TIMEOUT_SECONDS}s, kill after ${SIMCTL_KILL_GRACE_SECONDS}s grace)"
local rc=0
"${TIMEOUT_BIN}" -k "${SIMCTL_KILL_GRACE_SECONDS}s" "${SIMCTL_TIMEOUT_SECONDS}" "${XCRUN_BIN}" simctl delete unavailable || rc=$?
if ((rc == 124)); then
log_error "simctl: timed out after ${SIMCTL_TIMEOUT_SECONDS}s"
return "${STEP_FAILED}"
elif ((rc == 137)); then
# 137 is SIGKILL: from timeout -k, or from outside (OOM). GNU timeout gives 137 for both.
log_error "simctl: killed (exit 137): timed out after ${SIMCTL_TIMEOUT_SECONDS}s and ignored SIGTERM for ${SIMCTL_KILL_GRACE_SECONDS}s, or killed from outside"
return "${STEP_FAILED}"
elif ((rc != 0)); then
log_error "simctl: delete unavailable failed (exit ${rc})"
return "${STEP_FAILED}"
fi
return "${STEP_OK}"
}

# =========================================================
# MAIN EXECUTION
# =========================================================
function main() {
local failures=0 rc step

log_info "disk-cleanup started"
log_free_space "before"

for step in step_npm step_uv step_simctl; do
rc=0
"${step}" || rc=$?
if ((rc == STEP_FAILED)); then
((failures += 1))
fi
done

log_free_space "after"

if ((failures > 0)); then
log_error "disk-cleanup finished with ${failures} failed step(s)"
return 1
fi
log_info "disk-cleanup finished"
return 0
}

# Skip main when sourced (e.g., by bats tests loading function defs).
# Mirrors the guard in break-watcher.sh.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
100 changes: 100 additions & 0 deletions disk-space-alert.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail

# Daily low-disk alert. Posts a notification; deletes nothing.
# Why: docs/plans/2026-10-22-disk-space-automation.md

# Each tool is a variable so tests can stub it. Empty or not executable = missing.
DF_BIN="${DF_BIN:-$(command -v df || true)}"
OSASCRIPT_BIN="${OSASCRIPT_BIN:-/usr/bin/osascript}"

# Free space is read for the Data volume; / is the read-only system volume.
DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}"

# 40, not 50: the snapshot thinner targets 50 GiB and clears that on its own.
DEFAULT_THRESHOLD_GIB=40

# =========================================================
# FUNCTIONS
# =========================================================
function log_info() {
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[${timestamp}] [INFO] $*"
}

function log_error() {
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[${timestamp}] [ERROR] $*" >&2
}

# True when $1 names an executable file. An empty value is "missing".
function have_tool() {
[[ -n "${1:-}" && -x "${1}" ]]
}

# Prints whole GiB free on DATA_VOLUME, rounded down. Returns 1 if unknown.
function free_gib() {
local avail_kib

have_tool "${DF_BIN}" || return 1
# -P forces one line per filesystem on both macOS and Linux; Available is
# field 4 in both.
avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" || return 1
[[ "${avail_kib}" =~ ^[0-9]+$ ]] || return 1
echo "$((avail_kib / 1024 / 1024))"
}

# The title and message go in as argv, never into the AppleScript source, so
# no value can change the script.
function notify() {
local title="$1" message="$2"

if ! have_tool "${OSASCRIPT_BIN}"; then
log_error "osascript not found; could not post: ${title}: ${message}"
return 1
fi
if ! "${OSASCRIPT_BIN}" \
-e 'on run argv' \
-e 'display notification (item 1 of argv) with title (item 2 of argv)' \
-e 'end run' \
"${message}" "${title}"; then
log_error "osascript failed; could not post: ${title}: ${message}"
return 1
fi
}

# =========================================================
# MAIN EXECUTION
# =========================================================
function main() {
local threshold="${1:-${DEFAULT_THRESHOLD_GIB}}" free

if [[ ! "${threshold}" =~ ^[1-9][0-9]*$ ]]; then
log_error "threshold must be a whole number of GiB above 0, got '${threshold}'"
return 2
fi

# An alert that fails silently is worse than none, so an unreadable
# volume is reported too.
if ! free="$(free_gib)"; then
log_error "could not read free space on ${DATA_VOLUME}"
notify "Disk space check failed" "Could not read free space on ${DATA_VOLUME}." || true
return 1
fi

if ((free < threshold)); then
log_info "LOW: ${free} GiB free on ${DATA_VOLUME}, below ${threshold} GiB"
notify "Low disk space" "${free} GiB free on ${DATA_VOLUME} (alert below ${threshold} GiB)." || return 1
return 0
fi

log_info "OK: ${free} GiB free on ${DATA_VOLUME} (threshold ${threshold} GiB)"
}

# Skip main when sourced (e.g., by bats tests loading function defs).
# Mirrors the guard in break-watcher.sh.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Loading
Loading