#!/bin/zsh # # macOS Scheduled Cleanup — modular system maintenance # Runs weekly via launchd. Measures and reports space freed. # Public repo: psingletary.com/macOS-sched-cleanup # # Each module function echoes bytes freed to stdout. Returns 0 on # success, 1 if skipped (prerequisite missing or nothing to clean). # The main() function collects results and sends a notification. set -euo pipefail readonly LOG_DIR="$HOME/.logs" readonly LOG_FILE="$LOG_DIR/cleanup.log" readonly ERROR_LOG="$LOG_DIR/cleanup-error.log" readonly ROTATION_DAYS=84 # 12 weeks # --------------------------------------------------------------------------- # Log rotation — archive current log if >12 weeks old, purge old archives # --------------------------------------------------------------------------- rotate_log() { mkdir -p "$LOG_DIR" # Rotate the main log if it's older than 12 weeks if [[ -f "$LOG_FILE" ]]; then if [[ $(find "$LOG_FILE" -mtime +"$ROTATION_DAYS" 2>/dev/null) ]]; then local archive="$LOG_DIR/cleanup-$(date -r "$LOG_FILE" +%Y-%m-%d).log" mv "$LOG_FILE" "$archive" fi fi # Purge any rotated logs older than 12 weeks find "$LOG_DIR" -name "cleanup-*.log" -mtime +"$ROTATION_DAYS" -delete 2>/dev/null || true find "$LOG_DIR" -name "cleanup-error-*.log" -mtime +"$ROTATION_DAYS" -delete 2>/dev/null || true } # --------------------------------------------------------------------------- # Disk space measurement (bytes free on /) # --------------------------------------------------------------------------- measure_free_bytes() { df -b / 2>/dev/null | awk 'NR==2 {print $4}' } # --------------------------------------------------------------------------- # Human-readable byte formatting # --------------------------------------------------------------------------- format_bytes() { local bytes=$1 if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "scale=1; $bytes / 1073741824" | bc)" elif (( bytes >= 1048576 )); then printf "%.0f MB" "$(echo "scale=0; $bytes / 1048576" | bc)" elif (( bytes >= 1024 )); then printf "%.0f KB" "$(echo "scale=0; $bytes / 1024" | bc)" else printf "%d B" "$bytes" fi } # --------------------------------------------------------------------------- # Module: Edge browser caches # --------------------------------------------------------------------------- clean_edge() { local edge_dir="$HOME/Library/Application Support/Microsoft Edge" [[ -d "$edge_dir" ]] || { echo 0; return 1; } local before=$(du -sk "$edge_dir" 2>/dev/null | awk '{print $1}') # IndexedDB find "$edge_dir/Default/IndexedDB" -mindepth 1 -maxdepth 4 -type d \ -exec rm -rf {} + 2>/dev/null || true # Service Worker CacheStorage find "$edge_dir/Default/Service Worker/CacheStorage" -mindepth 1 -maxdepth 4 -type d \ -exec rm -rf {} + 2>/dev/null || true # Service Worker ScriptCache find "$edge_dir/Default/Service Worker/ScriptCache" -mindepth 1 -maxdepth 4 -type d \ -exec rm -rf {} + 2>/dev/null || true # Disk cache find "$HOME/Library/Caches/Microsoft Edge" -mindepth 1 -maxdepth 4 -type d \ -exec rm -rf {} + 2>/dev/null || true local after=$(du -sk "$edge_dir" 2>/dev/null | awk '{print $1}') local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: Homebrew cache # --------------------------------------------------------------------------- clean_brew() { command -v brew >/dev/null 2>&1 || { echo 0; return 1; } local before=$(du -sk "$(brew --cache)" 2>/dev/null | awk '{print $1}') brew cleanup --prune=all >/dev/null 2>>"$ERROR_LOG" || true local after=$(du -sk "$(brew --cache)" 2>/dev/null | awk '{print $1}') local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: Xcode derived data (7-day staleness check) # --------------------------------------------------------------------------- clean_xcode() { local derived="$HOME/Library/Developer/Xcode/DerivedData" [[ -d "$derived" ]] || { echo 0; return 1; } local before=$(du -sk "$derived" 2>/dev/null | awk '{print $1}') # Only delete project directories that haven't been touched in 7+ days. # This prevents breaking active builds. find "$derived" -mindepth 1 -maxdepth 1 -type d -mtime +7 \ -exec rm -rf {} + 2>/dev/null || true # Also clean iOS simulator caches (stale, not actively running simulators) local sim_cache="$HOME/Library/Developer/CoreSimulator/Caches" if [[ -d "$sim_cache" ]]; then find "$sim_cache" -mindepth 1 -maxdepth 4 -type d -mtime +7 \ -exec rm -rf {} + 2>/dev/null || true fi # Old iOS device support files (>30 days) local device_support="$HOME/Library/Developer/Xcode/iOS DeviceSupport" if [[ -d "$device_support" ]]; then find "$device_support" -mindepth 1 -maxdepth 1 -type d -mtime +30 \ -exec rm -rf {} + 2>/dev/null || true fi local after=$(du -sk "$derived" 2>/dev/null | awk '{print $1}') local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: npm cache # --------------------------------------------------------------------------- clean_npm() { command -v npm >/dev/null 2>&1 || { echo 0; return 1; } local cache_dir cache_dir=$(npm config get cache 2>/dev/null) || { echo 0; return 1; } [[ -d "$cache_dir" ]] || { echo 0; return 1; } local before=$(du -sk "$cache_dir" 2>/dev/null | awk '{print $1}') npm cache clean --force >/dev/null 2>>"$ERROR_LOG" || true local after=$(du -sk "$cache_dir" 2>/dev/null | awk '{print $1}') local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: pip cache # --------------------------------------------------------------------------- clean_pip() { command -v pip3 >/dev/null 2>&1 || { echo 0; return 1; } local cache_dir="$HOME/Library/Caches/pip" local before=0 [[ -d "$cache_dir" ]] && before=$(du -sk "$cache_dir" 2>/dev/null | awk '{print $1}') pip3 cache purge >/dev/null 2>>"$ERROR_LOG" || true local after=0 [[ -d "$cache_dir" ]] && after=$(du -sk "$cache_dir" 2>/dev/null | awk '{print $1}') after=${after:-0} local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: Cargo cache (Rust) # --------------------------------------------------------------------------- clean_cargo() { local cargo_home="${CARGO_HOME:-$HOME/.cargo}" [[ -d "$cargo_home" ]] || { echo 0; return 1; } local before=0 local registry="$cargo_home/registry/cache" local git_checkouts="$cargo_home/git/checkouts" [[ -d "$registry" ]] && before=$((before + $(du -sk "$registry" 2>/dev/null | awk '{print $1}'))) [[ -d "$git_checkouts" ]] && before=$((before + $(du -sk "$git_checkouts" 2>/dev/null | awk '{print $1}'))) [[ -n "$registry" ]] && rm -rf "${registry:?}"/* 2>/dev/null || true [[ -n "$git_checkouts" ]] && rm -rf "${git_checkouts:?}"/* 2>/dev/null || true local after=0 [[ -d "$registry" ]] && after=$((after + $(du -sk "$registry" 2>/dev/null | awk '{print $1}'))) [[ -d "$git_checkouts" ]] && after=$((after + $(du -sk "$git_checkouts" 2>/dev/null | awk '{print $1}'))) local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Module: Docker system prune # --------------------------------------------------------------------------- clean_docker() { command -v docker >/dev/null 2>&1 || { echo 0; return 1; } # Check if Docker daemon is running docker info >/dev/null 2>&1 || { echo 0; return 1; } # docker system prune -f output includes "Total reclaimed space: X.XXGB" local output output=$(docker system prune -f 2>>"$ERROR_LOG") || { echo 0; return 1; } # Parse "Total reclaimed space" from docker output local reclaimed reclaimed=$(echo "$output" | grep -i "Total reclaimed space" | grep -oE '[0-9.]+[GMK]?B' | head -1) if [[ -n "$reclaimed" ]]; then # Convert human-readable to bytes local num unit num=$(echo "$reclaimed" | grep -oE '[0-9.]+') unit=$(echo "$reclaimed" | grep -oE '[GMK]B' | head -1) case "$unit" in GB) echo "scale=0; $num * 1073741824" | bc ;; MB) echo "scale=0; $num * 1048576" | bc ;; KB) echo "scale=0; $num * 1024" | bc ;; *) echo 0 ;; esac else echo 0 fi return 0 } # --------------------------------------------------------------------------- # Module: macOS per-user temp files # --------------------------------------------------------------------------- clean_tmp() { local tmpdir="${TMPDIR:-/tmp/}" [[ -d "$tmpdir" ]] || { echo 0; return 1; } local before=$(du -sk "$tmpdir" 2>/dev/null | awk '{print $1}') # Only delete files/dirs in TMPDIR older than 1 day, and only those # owned by the current user (no sudo, safe by construction). find "$tmpdir" -mindepth 1 -maxdepth 2 -user "$(whoami)" -mtime +1 \ -exec rm -rf {} + 2>/dev/null || true local after=$(du -sk "$tmpdir" 2>/dev/null | awk '{print $1}') local freed=$(( (before - after) * 1024 )) (( freed > 0 )) && echo "$freed" || echo 0 return 0 } # --------------------------------------------------------------------------- # Send macOS notification via terminal-notifier # --------------------------------------------------------------------------- notify() { local title="$1" local message="$2" if command -v terminal-notifier >/dev/null 2>&1; then # NEVER use -timeout — it removes the notification from Notification # Center entirely on expiry. terminal-notifier -title "$title" -message "$message" 2>/dev/null || true fi } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { rotate_log { echo "=== Cleanup started: $(date) ===" echo "Host: $(hostname)" local before_global before_global=$(measure_free_bytes) echo "Free before: $(format_bytes "$before_global")" # Run each module, collect results typeset -A MODULE_FREED typeset -a MODULE_ORDER local total_freed=0 run_module() { local name="$1" local description="$2" shift 2 echo "" echo "--- $description ---" MODULE_ORDER+=("$name") local bytes bytes=$("$@" 2>>"$ERROR_LOG") || bytes=0 MODULE_FREED[$name]=$bytes if (( bytes > 0 )); then echo "Freed: $(format_bytes "$bytes")" total_freed=$((total_freed + bytes)) elif (( bytes == 0 )); then echo "Nothing to clean (or module skipped)" fi } run_module edge "Edge browser caches" clean_edge run_module brew "Homebrew" clean_brew run_module xcode "Xcode derived data" clean_xcode run_module npm "npm cache" clean_npm run_module pip "pip cache" clean_pip run_module cargo "Cargo cache" clean_cargo run_module docker "Docker" clean_docker run_module tmp "Temp files" clean_tmp local after_global after_global=$(measure_free_bytes) local df_freed=$((after_global - before_global)) echo "" echo "=== Cleanup finished: $(date) ===" echo "Total freed (sum of modules): $(format_bytes "$total_freed")" echo "Disk free delta (df before/after): $(format_bytes "$df_freed")" echo "Free after: $(format_bytes "$after_global")" # Build notification message if (( total_freed > 0 )); then local msg_parts=() local total_label total_label=$(format_bytes "$total_freed") for mod in "${MODULE_ORDER[@]}"; do local b=${MODULE_FREED[$mod]} if (( b > 1048576 )); then # Only mention if >1 MB local label case "$mod" in edge) label="Edge" ;; brew) label="Brew" ;; xcode) label="Xcode" ;; npm) label="npm" ;; pip) label="pip" ;; cargo) label="Cargo" ;; docker) label="Docker" ;; tmp) label="Temp" ;; *) label="$mod" ;; esac msg_parts+=("$label $(format_bytes "$b")") fi done local msg if (( ${#msg_parts[@]} > 0 )); then msg="Freed ${total_label} — ${(j:, :)msg_parts}" else msg="Freed ${total_label}" fi notify "Weekly Cleanup" "$msg" else notify "Weekly Cleanup" "No cleanup needed this week" fi } >>"$LOG_FILE" 2>>"$ERROR_LOG" } main "$@"