Created
September 17, 2026 22:00
-
-
Save oxygen/3531e47f0ac165ae7ab16e32b0fd7738 to your computer and use it in GitHub Desktop.
Mac GC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * macos26-cleanup.js v3.3 | |
| * Precise interactive cleanup for macOS Tahoe 26+ | |
| * Targets what System Settings → Storage often leaves under "System Data". | |
| * | |
| * Key insight: System Data is mostly a remainder + APFS purgeable space | |
| * (local Time Machine snapshots). Ordinary du/fs walks miss it. | |
| * | |
| * v3.3: | |
| * - Works correctly under sudo: resolves real user HOME via SUDO_USER / | |
| * console user / dscl (override with CLEAN_HOME=/Users/You) | |
| * - Snapshot thinning uses sudo only when not already root | |
| * v3.2: diagnosis, thinlocalsnapshots, loop after clean | |
| * v3.1: totals, MobileSync, CoreSimulator, iPhone Mirroring, broader caches… | |
| */ | |
| 'use strict'; | |
| const fs = require('fs').promises; | |
| const path = require('path'); | |
| const os = require('os'); | |
| const { exec } = require('child_process'); | |
| const { promisify } = require('util'); | |
| const readline = require('readline'); | |
| const execAsync = promisify(exec); | |
| const IS_MAC = process.platform === 'darwin'; | |
| const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0; | |
| /** | |
| * Resolve the real interactive user's home even when the process is root. | |
| * Under sudo, os.homedir() becomes /var/root — that would make every | |
| * ~/Library target wrong. Prefer SUDO_USER / logname / console user. | |
| */ | |
| function resolveHome() { | |
| // Explicit override always wins | |
| if (process.env.CLEAN_HOME && process.env.CLEAN_HOME.startsWith('/')) { | |
| return process.env.CLEAN_HOME; | |
| } | |
| // If not root, normal home is correct | |
| if (!IS_ROOT) { | |
| return os.homedir(); | |
| } | |
| // Running as root: recover the invoking user | |
| const candidates = []; | |
| if (process.env.SUDO_USER) { | |
| candidates.push(process.env.SUDO_USER); | |
| } | |
| if (process.env.USER && process.env.USER !== 'root') { | |
| candidates.push(process.env.USER); | |
| } | |
| if (process.env.LOGNAME && process.env.LOGNAME !== 'root') { | |
| candidates.push(process.env.LOGNAME); | |
| } | |
| // macOS console user (who is logged into the GUI) | |
| try { | |
| const { execSync } = require('child_process'); | |
| const consoleUser = execSync('stat -f %Su /dev/console 2>/dev/null', { encoding: 'utf8' }).trim(); | |
| if (consoleUser && consoleUser !== 'root') candidates.push(consoleUser); | |
| } catch {} | |
| try { | |
| const { execSync } = require('child_process'); | |
| const logname = execSync('logname 2>/dev/null', { encoding: 'utf8' }).trim(); | |
| if (logname && logname !== 'root') candidates.push(logname); | |
| } catch {} | |
| for (const user of candidates) { | |
| // dscl is the reliable way to get home on macOS | |
| try { | |
| const { execSync } = require('child_process'); | |
| const home = execSync(`dscl . -read /Users/${user} NFSHomeDirectory 2>/dev/null`, { encoding: 'utf8' }) | |
| .replace(/^NFSHomeDirectory:\s*/i, '') | |
| .trim(); | |
| if (home && home.startsWith('/')) return home; | |
| } catch {} | |
| // Fallback convention | |
| const guess = path.join('/Users', user); | |
| try { | |
| const { existsSync } = require('fs'); | |
| if (existsSync(guess)) return guess; | |
| } catch {} | |
| } | |
| // Last resort | |
| return os.homedir(); | |
| } | |
| const HOME = resolveHome(); | |
| const RUNNING_AS_ROOT = IS_ROOT; | |
| // ── ANSI colors ────────────────────────────────────────────────────────────── | |
| const c = { | |
| reset: '\x1b[0m', | |
| bold: '\x1b[1m', | |
| dim: '\x1b[2m', | |
| red: '\x1b[31m', | |
| green: '\x1b[32m', | |
| yellow: '\x1b[33m', | |
| blue: '\x1b[34m', | |
| magenta: '\x1b[35m', | |
| cyan: '\x1b[36m', | |
| white: '\x1b[37m', | |
| bgBlue: '\x1b[44m', | |
| bgGreen: '\x1b[42m', | |
| bgDim: '\x1b[100m', | |
| }; | |
| const log = { | |
| info: (m) => console.log(`${c.cyan}ℹ${c.reset} ${m}`), | |
| ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`), | |
| warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`), | |
| err: (m) => console.log(`${c.red}✗${c.reset} ${m}`), | |
| title: (m) => console.log(`\n${c.bold}${c.blue}═══ ${m} ═══${c.reset}`), | |
| dim: (m) => console.log(`${c.dim}${m}${c.reset}`), | |
| }; | |
| // ── Human size ─────────────────────────────────────────────────────────────── | |
| function hr(bytes = 0) { | |
| if (bytes >= 1e12) return (bytes / 1e12).toFixed(2) + ' TB'; | |
| if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + ' GB'; | |
| if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB'; | |
| if (bytes >= 1e3) return (bytes / 1e3).toFixed(0) + ' KB'; | |
| return bytes + ' B'; | |
| } | |
| // ── Simple ASCII table ─────────────────────────────────────────────────────── | |
| function table(headers, rows, opts = {}) { | |
| const colWidths = headers.map((h, i) => { | |
| const max = Math.max(h.length, ...rows.map(r => String(r[i] ?? '').replace(/\x1b\[[0-9;]*m/g, '').length)); | |
| return Math.min(max + 2, opts.maxCol || 50); | |
| }); | |
| const line = (chars) => | |
| chars.left + colWidths.map(w => chars.mid.repeat(w)).join(chars.sep) + chars.right; | |
| const row = (cells, colorFn = (s) => s) => | |
| '│' + cells.map((cell, i) => { | |
| const raw = String(cell ?? ''); | |
| const visible = raw.replace(/\x1b\[[0-9;]*m/g, ''); | |
| const pad = ' '.repeat(Math.max(0, colWidths[i] - visible.length)); | |
| return ' ' + colorFn(raw) + pad.slice(1); | |
| }).join('│') + '│'; | |
| const top = line({ left: '┌', mid: '─', sep: '┬', right: '┐' }); | |
| const mid = line({ left: '├', mid: '─', sep: '┼', right: '┤' }); | |
| const bottom = line({ left: '└', mid: '─', sep: '┴', right: '┘' }); | |
| console.log(top); | |
| console.log(row(headers, s => c.bold + s + c.reset)); | |
| console.log(mid); | |
| rows.forEach(r => console.log(row(r))); | |
| console.log(bottom); | |
| } | |
| // ── Pure Node FS helpers ───────────────────────────────────────────────────── | |
| async function exists(p) { | |
| try { await fs.access(p); return true; } catch { return false; } | |
| } | |
| /** | |
| * Recursive directory size using pure Node (no `du`). | |
| * Skips permission errors and does not follow symlinks. | |
| */ | |
| async function pathSize(p) { | |
| let total = 0; | |
| async function walk(dir) { | |
| let entries; | |
| try { | |
| entries = await fs.readdir(dir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const ent of entries) { | |
| const full = path.join(dir, ent.name); | |
| try { | |
| if (ent.isSymbolicLink()) continue; | |
| if (ent.isDirectory()) { | |
| await walk(full); | |
| } else if (ent.isFile()) { | |
| const st = await fs.stat(full); | |
| total += st.size; | |
| } | |
| } catch { | |
| // permission / vanished – ignore | |
| } | |
| } | |
| } | |
| try { | |
| const st = await fs.stat(p); | |
| if (st.isFile()) return st.size; | |
| if (st.isDirectory()) await walk(p); | |
| } catch { | |
| return 0; | |
| } | |
| return total; | |
| } | |
| /** | |
| * Remove a path completely (file or directory) with pure Node. | |
| */ | |
| async function rmRf(p) { | |
| if (!(await exists(p))) return; | |
| await fs.rm(p, { recursive: true, force: true, maxRetries: 3 }); | |
| } | |
| /** | |
| * Delete contents of a directory older than `olderThanDays`. | |
| */ | |
| async function cleanDirContents(dir, olderThanDays = 0) { | |
| if (!(await exists(dir))) return; | |
| const cutoff = olderThanDays > 0 | |
| ? Date.now() - olderThanDays * 24 * 60 * 60 * 1000 | |
| : 0; | |
| async function walk(current) { | |
| let entries; | |
| try { | |
| entries = await fs.readdir(current, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const ent of entries) { | |
| const full = path.join(current, ent.name); | |
| try { | |
| if (ent.isSymbolicLink()) continue; | |
| if (ent.isDirectory()) { | |
| await walk(full); | |
| try { | |
| const left = await fs.readdir(full); | |
| if (left.length === 0) await fs.rmdir(full); | |
| } catch {} | |
| } else if (ent.isFile()) { | |
| if (cutoff === 0) { | |
| await fs.unlink(full); | |
| } else { | |
| const st = await fs.stat(full); | |
| if (st.mtimeMs < cutoff) await fs.unlink(full); | |
| } | |
| } | |
| } catch {} | |
| } | |
| } | |
| if (olderThanDays === 0) { | |
| const entries = await fs.readdir(dir).catch(() => []); | |
| for (const name of entries) { | |
| await rmRf(path.join(dir, name)); | |
| } | |
| } else { | |
| await walk(dir); | |
| } | |
| } | |
| /** | |
| * Delete only files/folders matching a name predicate. | |
| */ | |
| async function findAndRemoveMatching(root, namePredicate, maxDepth = 6) { | |
| async function walk(dir, depth) { | |
| if (depth > maxDepth) return; | |
| let entries; | |
| try { | |
| entries = await fs.readdir(dir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const ent of entries) { | |
| const full = path.join(dir, ent.name); | |
| try { | |
| if (ent.isSymbolicLink()) continue; | |
| if (namePredicate(ent.name, full)) { | |
| await rmRf(full); | |
| } else if (ent.isDirectory()) { | |
| await walk(full, depth + 1); | |
| } | |
| } catch {} | |
| } | |
| } | |
| if (await exists(root)) await walk(root, 0); | |
| } | |
| // ── Interactive prompt ─────────────────────────────────────────────────────── | |
| const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | |
| function ask(q) { | |
| return new Promise(resolve => rl.question(q, resolve)); | |
| } | |
| async function confirm(msg, defaultYes = false) { | |
| const hint = defaultYes ? 'Y/n' : 'y/N'; | |
| const a = (await ask(`${c.yellow}?${c.reset} ${msg} [${hint}] `)).trim().toLowerCase(); | |
| if (!a) return defaultYes; | |
| return a === 'y' || a === 'yes'; | |
| } | |
| // ── Cleanup targets ────────────────────────────────────────────────────────── | |
| function buildTargets() { | |
| const targets = []; | |
| // ── 1. Selected high-value user caches (safe) ──────────────────────────── | |
| const cachePatterns = [ | |
| { id: 'cache-google', name: 'Google / Chrome caches', path: path.join(HOME, 'Library/Caches/Google') }, | |
| { id: 'cache-firefox', name: 'Firefox caches', path: path.join(HOME, 'Library/Caches/Firefox') }, | |
| { id: 'cache-spotify', name: 'Spotify caches', path: path.join(HOME, 'Library/Caches/com.spotify.client') }, | |
| { id: 'cache-spotify-as', name: 'Spotify App Support cache', path: path.join(HOME, 'Library/Application Support/Spotify/PersistentCache') }, | |
| { id: 'cache-slack', name: 'Slack caches', path: path.join(HOME, 'Library/Caches/com.tinyspeck.slackmacgap') }, | |
| { id: 'cache-discord', name: 'Discord caches', path: path.join(HOME, 'Library/Caches/com.hnc.Discord') }, | |
| { id: 'cache-vscode', name: 'VS Code / Cursor caches', path: path.join(HOME, 'Library/Application Support/Code/Cache') }, | |
| { id: 'cache-cursor', name: 'Cursor caches', path: path.join(HOME, 'Library/Application Support/Cursor/Cache') }, | |
| { id: 'cache-homebrew', name: 'Homebrew caches', path: path.join(HOME, 'Library/Caches/Homebrew') }, | |
| { id: 'cache-npm', name: 'npm caches', path: path.join(HOME, 'Library/Caches/npm') }, | |
| { id: 'cache-yarn', name: 'Yarn caches', path: path.join(HOME, 'Library/Caches/Yarn') }, | |
| { id: 'cache-pnpm', name: 'pnpm caches', path: path.join(HOME, 'Library/Caches/pnpm') }, | |
| { id: 'cache-pip', name: 'pip caches', path: path.join(HOME, 'Library/Caches/pip') }, | |
| { id: 'cache-bird', name: 'iCloud Drive (bird) cache', path: path.join(HOME, 'Library/Caches/com.apple.bird') }, | |
| { id: 'cache-sharingd', name: 'AirDrop / sharingd cache', path: path.join(HOME, 'Library/Caches/com.apple.sharingd') }, | |
| { id: 'cache-adobe', name: 'Adobe Media Cache', path: path.join(HOME, 'Library/Application Support/Adobe/Common/Media Cache Files') }, | |
| ]; | |
| for (const p of cachePatterns) { | |
| targets.push({ | |
| id: p.id, | |
| name: p.name, | |
| description: p.path.replace(HOME, '~'), | |
| risk: 'safe', | |
| sizeFn: () => pathSize(p.path), | |
| cleanFn: async () => { if (await exists(p.path)) await rmRf(p.path); }, | |
| }); | |
| } | |
| // Remaining large folders under ~/Library/Caches (not already covered) | |
| targets.push({ | |
| id: 'cache-other', | |
| name: 'Other large ~/Library/Caches', | |
| description: 'Remaining folders > 50 MB under Caches', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| const root = path.join(HOME, 'Library/Caches'); | |
| if (!(await exists(root))) return 0; | |
| const known = new Set(cachePatterns.map(p => path.basename(p.path))); | |
| let total = 0; | |
| const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); | |
| for (const ent of entries) { | |
| if (!ent.isDirectory() || known.has(ent.name)) continue; | |
| const sz = await pathSize(path.join(root, ent.name)); | |
| if (sz > 50 * 1024 * 1024) total += sz; | |
| } | |
| return total; | |
| }, | |
| cleanFn: async () => { | |
| const root = path.join(HOME, 'Library/Caches'); | |
| if (!(await exists(root))) return; | |
| const known = new Set(cachePatterns.map(p => path.basename(p.path))); | |
| const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); | |
| for (const ent of entries) { | |
| if (!ent.isDirectory() || known.has(ent.name)) continue; | |
| const full = path.join(root, ent.name); | |
| const sz = await pathSize(full); | |
| if (sz > 50 * 1024 * 1024) await rmRf(full); | |
| } | |
| }, | |
| }); | |
| // ── 2. Logs ────────────────────────────────────────────────────────────── | |
| targets.push({ | |
| id: 'logs-user', | |
| name: 'User Logs (>14 days)', | |
| description: '~/Library/Logs', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Logs')), | |
| cleanFn: () => cleanDirContents(path.join(HOME, 'Library/Logs'), 14), | |
| }); | |
| targets.push({ | |
| id: 'logs-diag', | |
| name: 'Diagnostic Reports', | |
| description: '~/Library/Logs/DiagnosticReports', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Logs/DiagnosticReports')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, 'Library/Logs/DiagnosticReports'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| // ── 3. Saved Application State ─────────────────────────────────────────── | |
| targets.push({ | |
| id: 'saved-state', | |
| name: 'Saved Application State', | |
| description: '~/Library/Saved Application State', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Saved Application State')), | |
| cleanFn: () => cleanDirContents(path.join(HOME, 'Library/Saved Application State')), | |
| }); | |
| // ── 4. Incomplete / leftover software updates ──────────────────────────── | |
| targets.push({ | |
| id: 'updates-library', | |
| name: 'Incomplete Software Updates', | |
| description: '/Library/Updates (leftover pkgs)', | |
| risk: 'safe', | |
| sizeFn: () => pathSize('/Library/Updates'), | |
| cleanFn: async () => { | |
| const root = '/Library/Updates'; | |
| if (!(await exists(root))) return; | |
| let entries; | |
| try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return; } | |
| for (const ent of entries) { | |
| if (ent.name.endsWith('.plist') || ent.name.endsWith('.txt')) continue; | |
| await rmRf(path.join(root, ent.name)); | |
| } | |
| }, | |
| }); | |
| // ── 5. AirDrop temporary residue ───────────────────────────────────────── | |
| targets.push({ | |
| id: 'airdrop-temp', | |
| name: 'AirDrop temp / canceled transfers', | |
| description: 'sharingd + “(A Document Being Saved By sharingd)”', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| let total = await pathSize(path.join(HOME, 'Library/Caches/com.apple.sharingd')); | |
| // Approximate contribution from var/folders is hard without full walk; | |
| // we still clean it thoroughly in cleanFn. | |
| return total; | |
| }, | |
| cleanFn: async () => { | |
| const sharingd = path.join(HOME, 'Library/Caches/com.apple.sharingd'); | |
| if (await exists(sharingd)) await rmRf(sharingd); | |
| await findAndRemoveMatching( | |
| '/private/var/folders', | |
| (name) => | |
| name.includes('(A Document Being Saved By sharingd)') || | |
| (name.includes('sharingd') && name.toLowerCase().includes('temporary')), | |
| 5 | |
| ); | |
| const downloads = path.join(HOME, 'Downloads'); | |
| if (await exists(downloads)) { | |
| const entries = await fs.readdir(downloads).catch(() => []); | |
| for (const name of entries) { | |
| if (name.endsWith('.download') || name.endsWith('.partial')) { | |
| await rmRf(path.join(downloads, name)); | |
| } | |
| } | |
| } | |
| }, | |
| }); | |
| // ── 6. iPhone Mirroring cache (Sequoia/Tahoe) ──────────────────────────── | |
| targets.push({ | |
| id: 'iphone-mirroring', | |
| name: 'iPhone Mirroring cache', | |
| description: '~/Library/Group Containers/group.com.apple.iphonemirroring', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Group Containers/group.com.apple.iphonemirroring')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, 'Library/Group Containers/group.com.apple.iphonemirroring'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| // ── 7. iOS / iPadOS local device backups (often huge) ──────────────────── | |
| targets.push({ | |
| id: 'ios-backups', | |
| name: 'iOS / iPadOS device backups', | |
| description: '~/Library/Application Support/MobileSync/Backup', | |
| risk: 'caution', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Application Support/MobileSync/Backup')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, 'Library/Application Support/MobileSync/Backup'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| // ── 8. System-level caches (caution) ───────────────────────────────────── | |
| targets.push({ | |
| id: 'cache-system', | |
| name: 'System /Library/Caches', | |
| description: '/Library/Caches (non-critical)', | |
| risk: 'caution', | |
| sizeFn: () => pathSize('/Library/Caches'), | |
| cleanFn: async () => { | |
| // Only clear contents of known-safe subfolders; never wipe the root | |
| const root = '/Library/Caches'; | |
| if (!(await exists(root))) return; | |
| const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); | |
| for (const ent of entries) { | |
| // Skip anything that looks Apple-critical | |
| if (ent.name.startsWith('com.apple.') && !ent.name.includes('Cache')) continue; | |
| await rmRf(path.join(root, ent.name)); | |
| } | |
| }, | |
| }); | |
| // ── 9. Package-manager clean commands ──────────────────────────────────── | |
| targets.push({ | |
| id: 'brew', | |
| name: 'Homebrew cleanup', | |
| description: 'brew cleanup -s --prune=all', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('brew --cache 2>/dev/null'); | |
| return pathSize(stdout.trim()); | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('brew cleanup -s --prune=all'); } catch {} | |
| }, | |
| }); | |
| // 1. CoreSimulator Runtimes (Xcode leftovers) | |
| targets.push({ | |
| id: 'sim-runtimes', | |
| name: 'CoreSimulator Runtimes', | |
| description: '/Library/Developer/CoreSimulator/Profiles/Runtimes', | |
| risk: 'caution', | |
| sizeFn: () => pathSize('/Library/Developer/CoreSimulator/Profiles/Runtimes'), | |
| cleanFn: async () => { | |
| const p = '/Library/Developer/CoreSimulator/Profiles/Runtimes'; | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| // 2. QuickLook Thumbnail Database | |
| targets.push({ | |
| id: 'ql-thumbnails', | |
| name: 'QuickLook Thumbnail Caches', | |
| description: '~/Library/Caches/com.apple.quicklook.thumbnailcache', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Caches/com.apple.quicklook.thumbnailcache')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, 'Library/Caches/com.apple.quicklook.thumbnailcache'); | |
| if (await exists(p)) { | |
| await execAsync(`qlmanage -r cache 2>/dev/null || true`); | |
| await rmRf(p); | |
| } | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'npm', | |
| name: 'npm cache clean', | |
| description: 'npm cache clean --force', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('npm config get cache 2>/dev/null'); | |
| return pathSize(stdout.trim()); | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('npm cache clean --force'); } catch {} | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'yarn', | |
| name: 'Yarn cache clean', | |
| description: 'yarn cache clean', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('yarn cache dir 2>/dev/null'); | |
| return pathSize(stdout.trim()); | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('yarn cache clean'); } catch {} | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'pnpm', | |
| name: 'pnpm store prune', | |
| description: 'pnpm store prune', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('pnpm store path 2>/dev/null'); | |
| return pathSize(stdout.trim()); | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('pnpm store prune'); } catch {} | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'pip', | |
| name: 'pip cache purge', | |
| description: 'pip cache purge', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('pip cache dir 2>/dev/null || pip3 cache dir 2>/dev/null'); | |
| return pathSize(stdout.trim()); | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('pip cache purge || pip3 cache purge'); } catch {} | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'cargo', | |
| name: 'Cargo registry cache', | |
| description: '~/.cargo/registry/cache', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, '.cargo/registry/cache')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, '.cargo/registry/cache'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'go', | |
| name: 'Go build & module cache', | |
| description: 'go clean -cache -modcache', | |
| risk: 'safe', | |
| sizeFn: async () => { | |
| try { | |
| const { stdout } = await execAsync('go env GOCACHE 2>/dev/null'); | |
| const a = await pathSize(stdout.trim()); | |
| const { stdout: mod } = await execAsync('go env GOMODCACHE 2>/dev/null'); | |
| const b = await pathSize(mod.trim()); | |
| return a + b; | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| try { await execAsync('go clean -cache -modcache'); } catch {} | |
| }, | |
| }); | |
| // ── 10. Docker ─────────────────────────────────────────────────────────── | |
| targets.push({ | |
| id: 'docker', | |
| name: 'Docker system prune', | |
| description: 'docker system prune -f (volumes kept)', | |
| risk: 'caution', | |
| sizeFn: async () => 0, | |
| cleanFn: async () => { | |
| try { await execAsync('docker system prune -f'); } catch {} | |
| }, | |
| }); | |
| // ── 11. Temp + Trash ───────────────────────────────────────────────────── | |
| targets.push({ | |
| id: 'tmp', | |
| name: 'System /tmp (old files)', | |
| description: '/tmp files older than 1 day', | |
| risk: 'safe', | |
| sizeFn: () => pathSize('/tmp'), | |
| cleanFn: () => cleanDirContents('/tmp', 1), | |
| }); | |
| targets.push({ | |
| id: 'trash', | |
| name: 'Empty Trash', | |
| description: '~/.Trash', | |
| risk: 'safe', | |
| sizeFn: () => pathSize(path.join(HOME, '.Trash')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, '.Trash'); | |
| if (await exists(p)) await cleanDirContents(p, 0); | |
| }, | |
| }); | |
| // ── 12. Local Time Machine snapshots (via tmutil) ──────────────────────── | |
| // This is the #1 reason System Data stays huge. Snapshots are NOT visible | |
| // to normal du/fs walks — they pin APFS blocks. Tools that "figure out" | |
| // System Data compare df vs Finder free space and use tmutil. | |
| targets.push({ | |
| id: 'tm-snapshots', | |
| name: 'Local Time Machine snapshots', | |
| description: 'tmutil thinlocalsnapshots (purgeable space)', | |
| risk: 'caution', | |
| sizeFn: async () => { | |
| // Best-effort: count how many exist; size is opaque | |
| try { | |
| const { stdout } = await execAsync('tmutil listlocalsnapshots / 2>/dev/null'); | |
| const count = stdout.split('\n').filter(l => l.includes('com.apple.TimeMachine')).length; | |
| // Return a non-zero sentinel so it always appears when snapshots exist | |
| return count > 0 ? count * 1024 * 1024 * 100 : 0; // ~100 MB placeholder per snap | |
| } catch { return 0; } | |
| }, | |
| cleanFn: async () => { | |
| // Prefer root; add sudo only when not already root | |
| const run = (cmd) => execAsync(RUNNING_AS_ROOT ? cmd : `sudo ${cmd}`); | |
| try { | |
| await run('tmutil thinlocalsnapshots / 100000000000 4'); | |
| return; | |
| } catch (e) { | |
| log.warn(`thinlocalsnapshots failed: ${e.message || e}`); | |
| } | |
| try { | |
| const { stdout } = await execAsync('tmutil listlocalsnapshots / 2>/dev/null'); | |
| const lines = stdout.split('\n').filter(l => l.includes('TimeMachine') || /\d{4}-\d{2}-\d{2}/.test(l)); | |
| for (const line of lines) { | |
| const m = line.match(/(\d{4}-\d{2}-\d{2}-\d{6})/); | |
| const date = m ? m[1] : null; | |
| if (date) { | |
| try { | |
| await run(`tmutil deletelocalsnapshots ${date}`); | |
| log.ok(`Deleted snapshot ${date}`); | |
| } catch (e2) { | |
| log.warn(`Could not delete ${date}: ${e2.message || e2}`); | |
| } | |
| } | |
| } | |
| } catch {} | |
| }, | |
| }); | |
| // ── 13. Developer targets (shown dimmed at bottom) ─────────────────────── | |
| targets.push({ | |
| id: 'xcode-derived', | |
| name: 'Xcode DerivedData', | |
| description: '~/Library/Developer/Xcode/DerivedData', | |
| risk: 'dev', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Developer/Xcode/DerivedData')), | |
| cleanFn: async () => { | |
| const p = path.join(HOME, 'Library/Developer/Xcode/DerivedData'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'xcode-devicesupport', | |
| name: 'Xcode DeviceSupport', | |
| description: '~/Library/Developer/Xcode/*DeviceSupport', | |
| risk: 'dev', | |
| sizeFn: async () => { | |
| const a = await pathSize(path.join(HOME, 'Library/Developer/Xcode/iOS DeviceSupport')); | |
| const b = await pathSize(path.join(HOME, 'Library/Developer/Xcode/watchOS DeviceSupport')); | |
| const c = await pathSize(path.join(HOME, 'Library/Developer/Xcode/tvOS DeviceSupport')); | |
| return a + b + c; | |
| }, | |
| cleanFn: async () => { | |
| for (const sub of ['iOS DeviceSupport', 'watchOS DeviceSupport', 'tvOS DeviceSupport']) { | |
| const p = path.join(HOME, 'Library/Developer/Xcode', sub); | |
| if (await exists(p)) await rmRf(p); | |
| } | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'xcode-archives', | |
| name: 'Xcode Archives (>60 days)', | |
| description: '~/Library/Developer/Xcode/Archives', | |
| risk: 'dev', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Developer/Xcode/Archives')), | |
| cleanFn: async () => { | |
| await cleanDirContents(path.join(HOME, 'Library/Developer/Xcode/Archives'), 60); | |
| }, | |
| }); | |
| targets.push({ | |
| id: 'coresimulator', | |
| name: 'CoreSimulator devices', | |
| description: '~/Library/Developer/CoreSimulator', | |
| risk: 'dev', | |
| sizeFn: () => pathSize(path.join(HOME, 'Library/Developer/CoreSimulator')), | |
| cleanFn: async () => { | |
| // Prefer official cleanup of unavailable simulators first | |
| try { await execAsync('xcrun simctl delete unavailable 2>/dev/null'); } catch {} | |
| // Then offer full wipe of the folder if user selected it | |
| const p = path.join(HOME, 'Library/Developer/CoreSimulator'); | |
| if (await exists(p)) await rmRf(p); | |
| }, | |
| }); | |
| return targets; | |
| } | |
| // ── Parallel size scan ─────────────────────────────────────────────────────── | |
| async function scanAll(targets) { | |
| log.title('Scanning (parallel, pure Node)'); | |
| const results = await Promise.all( | |
| targets.map(async (t) => { | |
| process.stdout.write(` ${c.dim}→ ${t.name}...${c.reset}\r`); | |
| const size = await t.sizeFn(); | |
| return { ...t, size }; | |
| }) | |
| ); | |
| process.stdout.write(' '.repeat(70) + '\r'); | |
| return results.filter(t => | |
| t.size > 0 || // 256 * 1024 || | |
| t.id === 'docker' || | |
| t.id === 'updates-library' || | |
| t.id === 'airdrop-temp' || | |
| t.id === 'tm-snapshots' || | |
| t.risk === 'dev' | |
| ); | |
| } | |
| // ── System Data diagnosis (what other tools actually do) ───────────────────── | |
| /** | |
| * System Data is largely a *remainder* + purgeable APFS space. | |
| * Real tools: | |
| * 1. Compare df free vs Finder free → gap ≈ purgeable (mostly snapshots) | |
| * 2. tmutil listlocalsnapshots / + thinlocalsnapshots | |
| * 3. Walk known Library paths | |
| * Snapshots are invisible to normal du/fs walks — that is why ordinary | |
| * cleaners find "not much" while System Data stays at 40–50 GB. | |
| */ | |
| async function diagnoseSystemData() { | |
| log.title('System Data diagnosis'); | |
| // 1. Snapshot list | |
| let snapCount = 0; | |
| let snapLines = []; | |
| try { | |
| const { stdout } = await execAsync('tmutil listlocalsnapshots / 2>/dev/null'); | |
| snapLines = stdout.split('\n').filter(l => l.includes('com.apple.TimeMachine')); | |
| snapCount = snapLines.length; | |
| } catch {} | |
| // 2. df free space | |
| let dfFree = null; | |
| try { | |
| const { stdout } = await execAsync("df -k / | tail -1 | awk '{print $4}'"); | |
| dfFree = parseInt(stdout.trim(), 10) * 1024; | |
| } catch {} | |
| console.log(` Local Time Machine snapshots : ${snapCount > 0 ? c.yellow + snapCount + c.reset : c.green + '0' + c.reset}`); | |
| if (snapCount > 0) { | |
| snapLines.slice(0, 5).forEach(l => console.log(` ${c.dim}${l.trim()}${c.reset}`)); | |
| if (snapCount > 5) console.log(` ${c.dim}… and ${snapCount - 5} more${c.reset}`); | |
| console.log(` ${c.yellow}→ These pin APFS blocks and inflate System Data until thinned.${c.reset}`); | |
| } | |
| if (dfFree != null) { | |
| console.log(` Kernel free (df) : ${hr(dfFree)}`); | |
| } | |
| console.log(` ${c.dim}Tip: if Finder free ≫ df free, the gap is purgeable (snapshots).${c.reset}`); | |
| console.log(` ${c.dim}After thinning snapshots, System Settings numbers can take a few minutes to update.${c.reset}\n`); | |
| } | |
| // ── Main interactive flow (loops after clean) ──────────────────────────────── | |
| async function runScanAndClean() { | |
| const targets = buildTargets(); | |
| const scanned = await scanAll(targets); | |
| if (scanned.length === 0) { | |
| log.ok('Nothing significant found in file-based targets.'); | |
| log.dim('If System Data is still large, the remainder is almost certainly local snapshots or purgeable space.'); | |
| return false; // signal to exit loop if desired | |
| } | |
| const normal = scanned.filter(t => t.risk !== 'dev').sort((a, b) => b.size - a.size); | |
| const dev = scanned.filter(t => t.risk === 'dev').sort((a, b) => b.size - a.size); | |
| log.title('Found candidates'); | |
| const headers = ['#', 'Name', 'Size', 'Risk', 'Path / Command']; | |
| const rows = []; | |
| let idx = 1; | |
| for (const t of normal) { | |
| const sizeStr = t.id === 'tm-snapshots' | |
| ? `${c.yellow}~snapshots${c.reset}` | |
| : hr(t.size); | |
| rows.push([ | |
| String(idx++), | |
| t.name, | |
| sizeStr, | |
| t.risk === 'safe' ? `${c.green}safe${c.reset}` : `${c.yellow}caution${c.reset}`, | |
| t.description.length > 44 ? t.description.slice(0, 41) + '…' : t.description, | |
| ]); | |
| } | |
| if (dev.length) { | |
| rows.push([`${c.dim}──${c.reset}`, `${c.dim}Developer (usually left alone)${c.reset}`, '', '', '']); | |
| for (const t of dev) { | |
| rows.push([ | |
| `${c.dim}${idx++}${c.reset}`, | |
| `${c.dim}${t.name}${c.reset}`, | |
| `${c.dim}${hr(t.size)}${c.reset}`, | |
| `${c.dim}dev${c.reset}`, | |
| `${c.dim}${t.description.length > 44 ? t.description.slice(0, 41) + '…' : t.description}${c.reset}`, | |
| ]); | |
| } | |
| } | |
| const totalAll = scanned.reduce((s, t) => s + (t.id === 'tm-snapshots' ? 0 : t.size), 0); | |
| const totalNormal = normal.reduce((s, t) => s + (t.id === 'tm-snapshots' ? 0 : t.size), 0); | |
| rows.push([ | |
| '', | |
| `${c.bold}TOTAL (file-based)${c.reset}`, | |
| `${c.bold}${c.green}${hr(totalAll)}${c.reset}`, | |
| '', | |
| totalNormal > 0 ? `${c.dim}(+ snapshots if present)${c.reset}` : '', | |
| ]); | |
| table(headers, rows); | |
| console.log(`\n${c.bold}File-based reclaimable ≈ ${c.green}${hr(totalNormal)}${c.reset}`); | |
| if (dev.length) { | |
| const totalDev = dev.reduce((s, t) => s + t.size, 0); | |
| console.log(`${c.dim}Developer items (optional) ≈ ${hr(totalDev)}${c.reset}`); | |
| } | |
| console.log(); | |
| console.log(`${c.cyan}Options:${c.reset}`); | |
| console.log(' • Enter numbers separated by space/comma (e.g. 1 3 5)'); | |
| console.log(' • Type "all" to select everything (including developer)'); | |
| console.log(' • Type "safe" to select only safe items'); | |
| console.log(' • Type "q" to quit\n'); | |
| const answer = (await ask(`${c.bold}Select targets to clean → ${c.reset}`)).trim().toLowerCase(); | |
| if (answer === 'q' || answer === 'quit' || answer === '') { | |
| log.info('Aborted this pass.'); | |
| return false; | |
| } | |
| const all = [...normal, ...dev]; | |
| let selected = []; | |
| if (answer === 'all') { | |
| selected = all; | |
| } else if (answer === 'safe') { | |
| selected = all.filter(t => t.risk === 'safe'); | |
| } else { | |
| const nums = answer.split(/[\s,]+/).map(n => parseInt(n, 10)).filter(n => n >= 1 && n <= all.length); | |
| selected = nums.map(n => all[n - 1]); | |
| } | |
| if (selected.length === 0) { | |
| log.warn('Nothing selected.'); | |
| return true; // stay in loop | |
| } | |
| log.title('Will clean'); | |
| table( | |
| ['Name', 'Size', 'Risk'], | |
| selected.map(t => [ | |
| t.risk === 'dev' ? `${c.dim}${t.name}${c.reset}` : t.name, | |
| t.id === 'tm-snapshots' ? `${c.yellow}snapshots${c.reset}` : | |
| (t.risk === 'dev' ? `${c.dim}${hr(t.size)}${c.reset}` : hr(t.size)), | |
| t.risk === 'safe' ? `${c.green}safe${c.reset}` : | |
| t.risk === 'caution' ? `${c.yellow}caution${c.reset}` : | |
| `${c.dim}dev${c.reset}`, | |
| ]) | |
| ); | |
| const go = await confirm(`Proceed to clean ${selected.length} item(s)?`, false); | |
| if (!go) { | |
| log.info('Cancelled this pass.'); | |
| return true; | |
| } | |
| log.title('Cleaning (parallel)'); | |
| let freed = 0; | |
| const results = await Promise.all( | |
| selected.map(async (t) => { | |
| try { | |
| process.stdout.write(` ${c.dim}Cleaning ${t.name}...${c.reset}\r`); | |
| await t.cleanFn(); | |
| freed += t.id === 'tm-snapshots' ? 0 : t.size; | |
| return { name: t.name, ok: true, size: t.size, risk: t.risk, id: t.id }; | |
| } catch (e) { | |
| return { name: t.name, ok: false, error: e.message, risk: t.risk, id: t.id }; | |
| } | |
| }) | |
| ); | |
| process.stdout.write(' '.repeat(70) + '\r'); | |
| log.title('Results'); | |
| table( | |
| ['Status', 'Name', 'Freed'], | |
| results.map(r => [ | |
| r.ok ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`, | |
| r.risk === 'dev' ? `${c.dim}${r.name}${c.reset}` : r.name, | |
| r.ok | |
| ? (r.id === 'tm-snapshots' ? `${c.yellow}thinned${c.reset}` : | |
| r.risk === 'dev' ? `${c.dim}${hr(r.size)}${c.reset}` : hr(r.size)) | |
| : (r.error || 'failed'), | |
| ]) | |
| ); | |
| console.log(`\n${c.bold}${c.bgGreen} File-based freed ≈ ${hr(freed)} ${c.reset}`); | |
| if (results.some(r => r.id === 'tm-snapshots' && r.ok)) { | |
| console.log(`${c.yellow} Snapshots thinned — System Data / free space may take a few minutes to update.${c.reset}`); | |
| } | |
| console.log(); | |
| log.ok('Pass complete. Re-open System Settings → General → Storage to refresh numbers.'); | |
| return true; // continue loop | |
| } | |
| async function main() { | |
| console.clear(); | |
| console.log(`${c.bold}${c.cyan} | |
| ███╗ ███╗ █████╗ ██████╗ ██████╗ ███████╗ ██████╗ ██████╗ | |
| ████╗ ████║██╔══██╗██╔════╝██╔═══██╗██╔════╝ ██╔════╝ ██╔════╝ | |
| ██╔████╔██║███████║██║ ██║ ██║███████╗ ██║ ███╗██║ | |
| ██║╚██╔╝██║██╔══██║██║ ██║ ██║╚════██║ ██║ ██║██║ | |
| ██║ ╚═╝ ██║██║ ██║╚██████╗╚██████╔╝███████║ ╚██████╔╝╚██████╗ | |
| ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ | |
| ${c.reset}`); | |
| console.log(`${c.bold}Precision Cleanup for macOS Tahoe 26+${c.reset} ${c.dim}v3.3${c.reset}`); | |
| console.log(`${c.dim}Targets what System Settings → Storage often leaves under “System Data”${c.reset}`); | |
| console.log(`${c.dim}Home: ${HOME}${RUNNING_AS_ROOT ? c.yellow + ' (running as root — home resolved from SUDO_USER / console)' + c.reset : ''}${c.reset}\n`); | |
| if (!IS_MAC) { | |
| log.err('This tool is for macOS only.'); | |
| process.exit(1); | |
| } | |
| if (RUNNING_AS_ROOT && HOME === '/var/root') { | |
| log.warn('Could not resolve real user home under sudo. Set CLEAN_HOME=/Users/YourName'); | |
| log.warn('User Library targets will be wrong until fixed.'); | |
| } | |
| // Always start with the diagnosis that explains the black box | |
| await diagnoseSystemData(); | |
| let keepGoing = true; | |
| while (keepGoing) { | |
| keepGoing = await runScanAndClean(); | |
| if (!keepGoing) break; | |
| console.log(); | |
| const again = await confirm('Scan again / return to menu?', true); | |
| if (!again) { | |
| log.info('Exiting.'); | |
| break; | |
| } | |
| console.log(); | |
| await diagnoseSystemData(); | |
| } | |
| rl.close(); | |
| } | |
| main().catch(e => { | |
| log.err(e.message || e); | |
| rl.close(); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment