Skip to content

Instantly share code, notes, and snippets.

@oxygen
Created September 17, 2026 22:00
Show Gist options
  • Select an option

  • Save oxygen/3531e47f0ac165ae7ab16e32b0fd7738 to your computer and use it in GitHub Desktop.

Select an option

Save oxygen/3531e47f0ac165ae7ab16e32b0fd7738 to your computer and use it in GitHub Desktop.
Mac GC
#!/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