dev-disk-doctor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/src/cli.js +159 -0
- package/dist/src/core/exec.js +28 -0
- package/dist/src/core/fix.js +74 -0
- package/dist/src/core/roots.js +81 -0
- package/dist/src/core/scan.js +99 -0
- package/dist/src/core/size.js +34 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/util.js +74 -0
- package/dist/src/core/version.js +16 -0
- package/dist/src/detectors/android.js +94 -0
- package/dist/src/detectors/biggest.js +67 -0
- package/dist/src/detectors/browsers-models.js +33 -0
- package/dist/src/detectors/docker.js +140 -0
- package/dist/src/detectors/helpers.js +56 -0
- package/dist/src/detectors/index.js +11 -0
- package/dist/src/detectors/pkgcaches.js +99 -0
- package/dist/src/detectors/projects.js +133 -0
- package/dist/src/detectors/system.js +102 -0
- package/dist/src/detectors/xcode.js +77 -0
- package/dist/src/mcp.js +98 -0
- package/dist/src/report/text.js +83 -0
- package/dist/src/worktrees/audit.js +231 -0
- package/dist/src/worktrees/detector.js +93 -0
- package/dist/src/worktrees/discover.js +81 -0
- package/dist/src/worktrees/types.js +1 -0
- package/package.json +57 -4
- package/skills/dev-disk-doctor/SKILL.md +51 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export async function pathExists(p) {
|
|
4
|
+
try {
|
|
5
|
+
await fs.lstat(p);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function isDir(p) {
|
|
13
|
+
try {
|
|
14
|
+
return (await fs.stat(p)).isDirectory();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function listDir(p) {
|
|
21
|
+
try {
|
|
22
|
+
return await fs.readdir(p);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function readText(p) {
|
|
29
|
+
try {
|
|
30
|
+
return await fs.readFile(p, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function fmtBytes(n) {
|
|
37
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
38
|
+
return '0 B';
|
|
39
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
40
|
+
let i = 0;
|
|
41
|
+
let v = n;
|
|
42
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
43
|
+
v /= 1024;
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
return `${v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
|
47
|
+
}
|
|
48
|
+
/** Parse sizes printed by Docker ("196.2GB", "58.38MB", "0B"). Docker uses decimal units. */
|
|
49
|
+
export function parseDockerSize(s) {
|
|
50
|
+
const m = /^\s*(-?[\d.]+)\s*([kKmMgGtT]?)B/.exec(s);
|
|
51
|
+
if (!m)
|
|
52
|
+
return 0;
|
|
53
|
+
const n = Number(m[1]);
|
|
54
|
+
const mult = { '': 1, k: 1e3, m: 1e6, g: 1e9, t: 1e12 };
|
|
55
|
+
return Math.max(0, Math.round(n * (mult[m[2].toLowerCase()] ?? 1)));
|
|
56
|
+
}
|
|
57
|
+
export function expandHome(p, home) {
|
|
58
|
+
if (p === '~')
|
|
59
|
+
return home;
|
|
60
|
+
if (p.startsWith('~/'))
|
|
61
|
+
return path.join(home, p.slice(2));
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
/** True when `child` is `parent` or lives inside it. */
|
|
65
|
+
export function isInside(child, parent) {
|
|
66
|
+
const rel = path.relative(parent, child);
|
|
67
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
68
|
+
}
|
|
69
|
+
export function shellQuote(s) {
|
|
70
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
71
|
+
}
|
|
72
|
+
export function daysAgo(ms, now = Date.now()) {
|
|
73
|
+
return Math.floor((now - ms) / 86_400_000);
|
|
74
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/** The published package version, read from package.json next to the compiled files. */
|
|
5
|
+
export function version() {
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
for (const rel of ['../../package.json', '../../../package.json']) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path.join(here, rel), 'utf8')).version;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* try next */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { sizeOf } from '../core/size.js';
|
|
3
|
+
import { isDir, listDir, readText } from '../core/util.js';
|
|
4
|
+
import { pathFinding, removeAction } from './helpers.js';
|
|
5
|
+
function sdkRoots(home) {
|
|
6
|
+
const roots = [process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME].filter((v) => Boolean(v));
|
|
7
|
+
roots.push(path.join(home, 'Library/Android/sdk'), path.join(home, 'Android/Sdk'));
|
|
8
|
+
return [...new Set(roots)];
|
|
9
|
+
}
|
|
10
|
+
/** Image folders referenced by AVDs, as `android-36/google_apis_playstore/arm64-v8a`. */
|
|
11
|
+
async function imagesInUse(home) {
|
|
12
|
+
const used = new Set();
|
|
13
|
+
const avdDir = process.env.ANDROID_AVD_HOME ?? path.join(home, '.android/avd');
|
|
14
|
+
for (const entry of await listDir(avdDir)) {
|
|
15
|
+
if (!entry.endsWith('.avd'))
|
|
16
|
+
continue;
|
|
17
|
+
const cfg = await readText(path.join(avdDir, entry, 'config.ini'));
|
|
18
|
+
const m = cfg && /^image\.sysdir\.1\s*=\s*(.+)$/m.exec(cfg);
|
|
19
|
+
if (m)
|
|
20
|
+
used.add(m[1].trim().replace(/^system-images\//, '').replace(/\/+$/, ''));
|
|
21
|
+
}
|
|
22
|
+
return used;
|
|
23
|
+
}
|
|
24
|
+
export const android = {
|
|
25
|
+
id: 'android',
|
|
26
|
+
title: 'Android SDK and emulators',
|
|
27
|
+
async run(opts) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
const used = await imagesInUse(opts.home);
|
|
30
|
+
for (const sdk of sdkRoots(opts.home)) {
|
|
31
|
+
const imagesRoot = path.join(sdk, 'system-images');
|
|
32
|
+
if (!(await isDir(imagesRoot)))
|
|
33
|
+
continue;
|
|
34
|
+
for (const api of await listDir(imagesRoot)) {
|
|
35
|
+
for (const tag of await listDir(path.join(imagesRoot, api))) {
|
|
36
|
+
for (const abi of await listDir(path.join(imagesRoot, api, tag))) {
|
|
37
|
+
const dir = path.join(imagesRoot, api, tag, abi);
|
|
38
|
+
if (!(await isDir(dir)))
|
|
39
|
+
continue;
|
|
40
|
+
const key = `${api}/${tag}/${abi}`;
|
|
41
|
+
const bytes = await sizeOf(dir, opts.detectorTimeoutMs);
|
|
42
|
+
if (bytes === undefined || bytes < opts.minBytes)
|
|
43
|
+
continue;
|
|
44
|
+
const id = `android:image:${key}`;
|
|
45
|
+
const inUse = used.has(key);
|
|
46
|
+
findings.push({
|
|
47
|
+
id,
|
|
48
|
+
detector: 'android',
|
|
49
|
+
category: 'mobile',
|
|
50
|
+
title: `Android emulator image ${key}${inUse ? ' (used by an AVD)' : ''}`,
|
|
51
|
+
path: dir,
|
|
52
|
+
bytes,
|
|
53
|
+
risk: inUse ? 'danger' : 'review',
|
|
54
|
+
regenerable: true,
|
|
55
|
+
why: inUse
|
|
56
|
+
? 'An existing virtual device boots from this image. Removing it breaks that emulator.'
|
|
57
|
+
: 'System image no virtual device uses. Re-downloadable from the SDK Manager.',
|
|
58
|
+
actions: inUse ? [] : [removeAction(id, dir, 'review', bytes, 'You will need to re-download it if you create an emulator for this API level later.')],
|
|
59
|
+
meta: { inUse },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const extras = await Promise.all([
|
|
65
|
+
pathFinding({
|
|
66
|
+
detector: 'android',
|
|
67
|
+
key: 'ndk',
|
|
68
|
+
category: 'mobile',
|
|
69
|
+
title: 'Android NDK versions',
|
|
70
|
+
path: path.join(sdk, 'ndk'),
|
|
71
|
+
risk: 'review',
|
|
72
|
+
regenerable: true,
|
|
73
|
+
why: 'Native toolchains. Several versions pile up as projects pin different ones; keep the ones your projects use.',
|
|
74
|
+
removable: false,
|
|
75
|
+
}, opts),
|
|
76
|
+
]);
|
|
77
|
+
findings.push(...extras.filter((f) => f !== undefined));
|
|
78
|
+
}
|
|
79
|
+
const avds = await pathFinding({
|
|
80
|
+
detector: 'android',
|
|
81
|
+
key: 'avd',
|
|
82
|
+
category: 'mobile',
|
|
83
|
+
title: 'Android virtual devices (AVD data)',
|
|
84
|
+
path: process.env.ANDROID_AVD_HOME ?? path.join(opts.home, '.android/avd'),
|
|
85
|
+
risk: 'review',
|
|
86
|
+
regenerable: false,
|
|
87
|
+
why: 'Disk images and snapshots for your emulators, including any app data installed on them. Wipe a device in Android Studio rather than deleting the folder.',
|
|
88
|
+
removable: false,
|
|
89
|
+
}, opts);
|
|
90
|
+
if (avds)
|
|
91
|
+
findings.push(avds);
|
|
92
|
+
return findings;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sizeOf } from '../core/size.js';
|
|
4
|
+
import { isInside } from '../core/util.js';
|
|
5
|
+
import { manualAction } from './helpers.js';
|
|
6
|
+
/** Folders we look inside of, instead of reporting as one opaque lump. */
|
|
7
|
+
function expandable(home) {
|
|
8
|
+
const lib = path.join(home, 'Library');
|
|
9
|
+
return new Set(['Containers', 'Application Support', 'Caches', 'Group Containers', 'Android'].map((n) => path.join(lib, n)).concat(lib));
|
|
10
|
+
}
|
|
11
|
+
async function children(dir) {
|
|
12
|
+
try {
|
|
13
|
+
return (await fs.readdir(dir)).map((n) => path.join(dir, n));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Sweep for space the specific detectors do not know about. Reports each top-level
|
|
21
|
+
* folder's *uncovered remainder* (its size minus what other findings already explain).
|
|
22
|
+
*/
|
|
23
|
+
export const biggest = {
|
|
24
|
+
id: 'biggest',
|
|
25
|
+
title: 'Everything else: unknown space hogs',
|
|
26
|
+
deepOnly: true,
|
|
27
|
+
async run(opts, covered) {
|
|
28
|
+
const expand = expandable(opts.home);
|
|
29
|
+
const queue = await children(opts.home);
|
|
30
|
+
const entries = [];
|
|
31
|
+
while (queue.length) {
|
|
32
|
+
const p = queue.pop();
|
|
33
|
+
if (expand.has(p))
|
|
34
|
+
queue.push(...(await children(p)));
|
|
35
|
+
else
|
|
36
|
+
entries.push(p);
|
|
37
|
+
}
|
|
38
|
+
const known = covered().filter((f) => f.path && f.bytes > 0);
|
|
39
|
+
const findings = [];
|
|
40
|
+
await Promise.all(entries.map(async (p) => {
|
|
41
|
+
const total = await sizeOf(p, opts.detectorTimeoutMs * 2);
|
|
42
|
+
if (total === undefined)
|
|
43
|
+
return;
|
|
44
|
+
const inside = known.filter((f) => isInside(f.path, p));
|
|
45
|
+
// Findings that are themselves nested only count once.
|
|
46
|
+
const outer = inside.filter((f) => !inside.some((g) => g !== f && g.path !== f.path && isInside(f.path, g.path)));
|
|
47
|
+
const explained = outer.reduce((s, f) => s + f.bytes, 0);
|
|
48
|
+
const remainder = total - explained;
|
|
49
|
+
if (remainder < opts.minBytes)
|
|
50
|
+
return;
|
|
51
|
+
const id = `biggest:${p}`;
|
|
52
|
+
findings.push({
|
|
53
|
+
id,
|
|
54
|
+
detector: 'biggest',
|
|
55
|
+
category: 'unknown',
|
|
56
|
+
title: `${p.replace(opts.home, '~')}${explained > 0 ? ` (excluding ${outer.length} item${outer.length === 1 ? '' : 's'} listed above)` : ''}`,
|
|
57
|
+
path: p,
|
|
58
|
+
bytes: remainder,
|
|
59
|
+
risk: 'review',
|
|
60
|
+
regenerable: false,
|
|
61
|
+
why: 'Not recognised by any specific check. Look inside before deleting; an app\'s own settings, or its in-app "clear cache" option, is usually the safe route.',
|
|
62
|
+
actions: [manualAction(id, 'inspect', 'See what is inside', `du -sh ${JSON.stringify(p)}/* | sort -rh | head -20`, 'safe')],
|
|
63
|
+
});
|
|
64
|
+
}));
|
|
65
|
+
return findings;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathFindings } from './helpers.js';
|
|
3
|
+
export const browsers = {
|
|
4
|
+
id: 'browsers',
|
|
5
|
+
title: 'Test browsers (Playwright, Puppeteer, Cypress)',
|
|
6
|
+
async run(opts) {
|
|
7
|
+
const h = opts.home;
|
|
8
|
+
const mac = opts.platform === 'darwin';
|
|
9
|
+
const why = 'Browser binaries downloaded for automated testing, one set per tool version, so old versions pile up. Re-downloaded by the tool when needed (e.g. `npx playwright install`).';
|
|
10
|
+
const specs = [
|
|
11
|
+
{ detector: 'browsers', key: 'playwright', category: 'browsers', title: 'Playwright browsers', path: mac ? path.join(h, 'Library/Caches/ms-playwright') : path.join(h, '.cache/ms-playwright'), risk: 'safe', regenerable: true, why },
|
|
12
|
+
{ detector: 'browsers', key: 'puppeteer', category: 'browsers', title: 'Puppeteer browsers', path: path.join(h, '.cache/puppeteer'), risk: 'safe', regenerable: true, why },
|
|
13
|
+
{ detector: 'browsers', key: 'cypress', category: 'browsers', title: 'Cypress binaries', path: mac ? path.join(h, 'Library/Caches/Cypress') : path.join(h, '.cache/Cypress'), risk: 'safe', regenerable: true, why },
|
|
14
|
+
];
|
|
15
|
+
return pathFindings(specs, opts);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export const models = {
|
|
19
|
+
id: 'models',
|
|
20
|
+
title: 'AI and ML model caches',
|
|
21
|
+
async run(opts) {
|
|
22
|
+
const h = opts.home;
|
|
23
|
+
const why = (what) => `${what} Model files are large and can take hours to download again, so only delete what you no longer use.`;
|
|
24
|
+
const specs = [
|
|
25
|
+
{ detector: 'models', key: 'huggingface', category: 'ai-models', title: 'Hugging Face model cache', path: path.join(h, '.cache/huggingface'), risk: 'review', regenerable: true, why: why('Models and datasets downloaded by transformers, diffusers and similar libraries.') },
|
|
26
|
+
{ detector: 'models', key: 'ollama', category: 'ai-models', title: 'Ollama models', path: path.join(h, '.ollama/models'), risk: 'review', regenerable: true, why: why('Local LLMs pulled with `ollama pull`.'), warning: 'Prefer `ollama rm <model>` to remove individual models.' },
|
|
27
|
+
{ detector: 'models', key: 'lmstudio', category: 'ai-models', title: 'LM Studio models', path: path.join(h, '.lmstudio/models'), risk: 'review', regenerable: true, why: why('Models downloaded through LM Studio.') },
|
|
28
|
+
{ detector: 'models', key: 'torch', category: 'ai-models', title: 'PyTorch hub cache', path: path.join(h, '.cache/torch'), risk: 'review', regenerable: true, why: why('Pretrained weights fetched by torch and torchvision.') },
|
|
29
|
+
{ detector: 'models', key: 'diffusionbee', category: 'ai-models', title: 'DiffusionBee model assets', path: path.join(h, '.diffusionbee/downloaded_assets'), risk: 'review', regenerable: true, why: why('Stable Diffusion weights downloaded by the DiffusionBee app.') },
|
|
30
|
+
];
|
|
31
|
+
return pathFindings(specs, opts);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { run } from '../core/exec.js';
|
|
3
|
+
import { sizeOf } from '../core/size.js';
|
|
4
|
+
import { parseDockerSize, pathExists } from '../core/util.js';
|
|
5
|
+
import { execAction, manualAction } from './helpers.js';
|
|
6
|
+
function parseDf(stdout) {
|
|
7
|
+
const rows = [];
|
|
8
|
+
for (const line of stdout.split('\n')) {
|
|
9
|
+
if (!line.trim().startsWith('{'))
|
|
10
|
+
continue;
|
|
11
|
+
try {
|
|
12
|
+
rows.push(JSON.parse(line));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
/* ignore partial lines */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return rows;
|
|
19
|
+
}
|
|
20
|
+
export const docker = {
|
|
21
|
+
id: 'docker',
|
|
22
|
+
title: 'Docker',
|
|
23
|
+
async run(opts) {
|
|
24
|
+
const findings = [];
|
|
25
|
+
const candidates = [
|
|
26
|
+
path.join(opts.home, 'Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw'),
|
|
27
|
+
path.join(opts.home, 'Library/Containers/com.docker.docker/Data/vms/0/data/Docker.qcow2'),
|
|
28
|
+
path.join(opts.home, '.docker/desktop/vms/0/data/Docker.raw'),
|
|
29
|
+
];
|
|
30
|
+
let diskImage;
|
|
31
|
+
let diskBytes = 0;
|
|
32
|
+
for (const c of candidates) {
|
|
33
|
+
if (!(await pathExists(c)))
|
|
34
|
+
continue;
|
|
35
|
+
const b = (await sizeOf(c, opts.detectorTimeoutMs)) ?? 0;
|
|
36
|
+
if (b > diskBytes) {
|
|
37
|
+
diskBytes = b;
|
|
38
|
+
diskImage = c;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const df = await run('docker', ['system', 'df', '--format', '{{json .}}'], { timeoutMs: 20_000 });
|
|
42
|
+
const rows = df.ok ? parseDf(df.stdout) : [];
|
|
43
|
+
if (diskImage && diskBytes >= opts.minBytes) {
|
|
44
|
+
const id = 'docker:disk-image';
|
|
45
|
+
findings.push({
|
|
46
|
+
id,
|
|
47
|
+
detector: 'docker',
|
|
48
|
+
category: 'containers',
|
|
49
|
+
title: 'Docker Desktop disk image',
|
|
50
|
+
path: diskImage,
|
|
51
|
+
bytes: diskBytes,
|
|
52
|
+
risk: 'danger',
|
|
53
|
+
regenerable: false,
|
|
54
|
+
meta: { envelope: true },
|
|
55
|
+
why: rows.length
|
|
56
|
+
? 'All images, containers, volumes and build cache live inside this one file. It grows but never shrinks on its own, so pruning inside Docker may not give the space back until Docker Desktop compacts it.'
|
|
57
|
+
: 'All images, containers, volumes and build cache live inside this one file. Docker is not running, so the contents could not be inspected: start Docker Desktop and scan again.',
|
|
58
|
+
actions: [
|
|
59
|
+
manualAction(id, 'shrink', 'Prune inside Docker first, then compact the image', 'Docker Desktop → Settings → Resources → Disk image size (or Troubleshoot → Clean / Purge data)', 'danger', 'Never delete this file by hand and "Clean / Purge data" removes every image, container and volume, including databases.'),
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const reclaim = parseDockerSize(row.Reclaimable.split(' ')[0] ?? '');
|
|
65
|
+
if (reclaim < opts.minBytes)
|
|
66
|
+
continue;
|
|
67
|
+
if (row.Type === 'Images') {
|
|
68
|
+
const id = 'docker:images';
|
|
69
|
+
findings.push({
|
|
70
|
+
id,
|
|
71
|
+
detector: 'docker',
|
|
72
|
+
category: 'containers',
|
|
73
|
+
title: `Unused Docker images (${row.TotalCount} total, ${row.Active} in use)`,
|
|
74
|
+
bytes: reclaim,
|
|
75
|
+
risk: 'review',
|
|
76
|
+
regenerable: true,
|
|
77
|
+
why: 'Images no container uses. Anything pulled from a registry can be pulled again; images you built locally and never pushed must be rebuilt.',
|
|
78
|
+
actions: [
|
|
79
|
+
execAction(id, 'prune', 'Remove all images not used by any container', 'docker', ['image', 'prune', '-a', '-f'], 'review', {
|
|
80
|
+
bytes: reclaim,
|
|
81
|
+
warning: 'Images that exist only on this machine (local builds, unpushed tags) are lost.',
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (row.Type === 'Build Cache') {
|
|
87
|
+
const id = 'docker:build-cache';
|
|
88
|
+
findings.push({
|
|
89
|
+
id,
|
|
90
|
+
detector: 'docker',
|
|
91
|
+
category: 'containers',
|
|
92
|
+
title: 'Docker build cache',
|
|
93
|
+
bytes: reclaim,
|
|
94
|
+
risk: 'safe',
|
|
95
|
+
regenerable: true,
|
|
96
|
+
why: 'Layers cached from previous builds. Deleting them only makes the next build slower.',
|
|
97
|
+
actions: [
|
|
98
|
+
execAction(id, 'prune', 'Clear the build cache', 'docker', ['builder', 'prune', '-a', '-f'], 'safe', { bytes: reclaim }),
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else if (row.Type === 'Containers') {
|
|
103
|
+
const id = 'docker:containers';
|
|
104
|
+
findings.push({
|
|
105
|
+
id,
|
|
106
|
+
detector: 'docker',
|
|
107
|
+
category: 'containers',
|
|
108
|
+
title: 'Stopped Docker containers',
|
|
109
|
+
bytes: reclaim,
|
|
110
|
+
risk: 'review',
|
|
111
|
+
regenerable: false,
|
|
112
|
+
why: 'Stopped containers keep their writable layer. Data written inside a container (not in a volume) is lost when it is removed.',
|
|
113
|
+
actions: [
|
|
114
|
+
execAction(id, 'prune', 'Remove all stopped containers', 'docker', ['container', 'prune', '-f'], 'review', {
|
|
115
|
+
bytes: reclaim,
|
|
116
|
+
warning: 'Check that nothing you care about lives in a stopped container.',
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else if (row.Type === 'Local Volumes') {
|
|
122
|
+
const id = 'docker:volumes';
|
|
123
|
+
findings.push({
|
|
124
|
+
id,
|
|
125
|
+
detector: 'docker',
|
|
126
|
+
category: 'containers',
|
|
127
|
+
title: 'Unused Docker volumes',
|
|
128
|
+
bytes: reclaim,
|
|
129
|
+
risk: 'danger',
|
|
130
|
+
regenerable: false,
|
|
131
|
+
why: 'Volumes hold persistent data such as local databases. "Unused" means no container is attached right now, not that the data is disposable.',
|
|
132
|
+
actions: [
|
|
133
|
+
manualAction(id, 'prune', 'Review volumes, then prune', 'docker volume ls # inspect first, then: docker volume rm <name>', 'danger', 'Deleted volumes cannot be recovered. The tool will not run this for you.'),
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return findings;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { sizeOf } from '../core/size.js';
|
|
2
|
+
import { shellQuote } from '../core/util.js';
|
|
3
|
+
export function removeAction(findingId, p, risk, bytes, warning, label = 'Delete this folder') {
|
|
4
|
+
return {
|
|
5
|
+
id: `${findingId}#remove`,
|
|
6
|
+
label,
|
|
7
|
+
display: `rm -rf ${shellQuote(p)}`,
|
|
8
|
+
risk,
|
|
9
|
+
kind: { type: 'remove-path', path: p },
|
|
10
|
+
reclaimBytes: bytes,
|
|
11
|
+
warning,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function execAction(findingId, name, label, file, args, risk, extra = {}) {
|
|
15
|
+
return {
|
|
16
|
+
id: `${findingId}#${name}`,
|
|
17
|
+
label,
|
|
18
|
+
display: [file, ...args].map(shellQuote).join(' '),
|
|
19
|
+
risk,
|
|
20
|
+
kind: { type: 'exec', file, args, cwd: extra.cwd },
|
|
21
|
+
reclaimBytes: extra.bytes,
|
|
22
|
+
warning: extra.warning,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function manualAction(findingId, name, label, display, risk, warning) {
|
|
26
|
+
return { id: `${findingId}#${name}`, label, display, risk, kind: { type: 'manual' }, warning };
|
|
27
|
+
}
|
|
28
|
+
/** Build a finding for one directory or file, or undefined if absent or below the size floor. */
|
|
29
|
+
export async function pathFinding(spec, opts) {
|
|
30
|
+
const bytes = await sizeOf(spec.path, opts.detectorTimeoutMs);
|
|
31
|
+
if (bytes === undefined || bytes < opts.minBytes)
|
|
32
|
+
return undefined;
|
|
33
|
+
const id = `${spec.detector}:${spec.key}`;
|
|
34
|
+
const actions = spec.actions
|
|
35
|
+
? spec.actions(id, bytes)
|
|
36
|
+
: spec.removable === false
|
|
37
|
+
? []
|
|
38
|
+
: [removeAction(id, spec.path, spec.risk, bytes, spec.warning)];
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
detector: spec.detector,
|
|
42
|
+
category: spec.category,
|
|
43
|
+
title: spec.title,
|
|
44
|
+
path: spec.path,
|
|
45
|
+
bytes,
|
|
46
|
+
risk: spec.risk,
|
|
47
|
+
regenerable: spec.regenerable,
|
|
48
|
+
why: spec.why,
|
|
49
|
+
actions,
|
|
50
|
+
meta: spec.meta,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function pathFindings(specs, opts) {
|
|
54
|
+
const out = await Promise.all(specs.map((s) => pathFinding(s, opts)));
|
|
55
|
+
return out.filter((f) => f !== undefined);
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { android } from './android.js';
|
|
2
|
+
import { biggest } from './biggest.js';
|
|
3
|
+
import { browsers, models } from './browsers-models.js';
|
|
4
|
+
import { docker } from './docker.js';
|
|
5
|
+
import { pkgcaches } from './pkgcaches.js';
|
|
6
|
+
import { projects } from './projects.js';
|
|
7
|
+
import { system } from './system.js';
|
|
8
|
+
import { xcode } from './xcode.js';
|
|
9
|
+
import { worktrees } from '../worktrees/detector.js';
|
|
10
|
+
/** `biggest` must stay last: it subtracts what the others already explain. */
|
|
11
|
+
export const detectors = [docker, android, xcode, pkgcaches, browsers, models, projects, worktrees, system, biggest];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { run } from '../core/exec.js';
|
|
3
|
+
import { listDir } from '../core/util.js';
|
|
4
|
+
import { execAction, pathFinding, pathFindings } from './helpers.js';
|
|
5
|
+
const REGEN = 'A cache: deleting it only means packages are downloaded again on the next install.';
|
|
6
|
+
async function pnpmFindings(opts) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const active = await run('pnpm', ['store', 'path'], { timeoutMs: 15_000 });
|
|
9
|
+
const activePath = active.ok ? active.stdout.trim() : undefined;
|
|
10
|
+
const storeRoots = [path.join(opts.home, 'Library/pnpm/store'), path.join(opts.home, '.local/share/pnpm/store')];
|
|
11
|
+
for (const root of storeRoots) {
|
|
12
|
+
for (const v of await listDir(root)) {
|
|
13
|
+
const dir = path.join(root, v);
|
|
14
|
+
const isActive = activePath === dir;
|
|
15
|
+
const f = await pathFinding({
|
|
16
|
+
detector: 'pkgcaches',
|
|
17
|
+
key: `pnpm-store:${v}`,
|
|
18
|
+
category: 'package-caches',
|
|
19
|
+
title: `pnpm store ${v}${isActive ? ' (active for your installed pnpm)' : ''}`,
|
|
20
|
+
path: dir,
|
|
21
|
+
risk: 'safe',
|
|
22
|
+
regenerable: true,
|
|
23
|
+
why: isActive
|
|
24
|
+
? 'Content-addressable package store shared by every pnpm project. Pruning removes only packages no project links to anymore.'
|
|
25
|
+
: 'Store from an older (or newer) pnpm major version that your installed pnpm does not use. Projects keep working: node_modules hard-link copies are independent of the store.',
|
|
26
|
+
actions: (id, bytes) => isActive
|
|
27
|
+
? [execAction(id, 'prune', 'Prune unreferenced packages', 'pnpm', ['store', 'prune'], 'safe')]
|
|
28
|
+
: [
|
|
29
|
+
{
|
|
30
|
+
id: `${id}#remove`,
|
|
31
|
+
label: 'Delete this store',
|
|
32
|
+
display: `rm -rf '${dir}'`,
|
|
33
|
+
risk: 'safe',
|
|
34
|
+
kind: { type: 'remove-path', path: dir },
|
|
35
|
+
reclaimBytes: bytes,
|
|
36
|
+
warning: 'Next install with that pnpm version re-downloads packages.',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}, opts);
|
|
40
|
+
if (f)
|
|
41
|
+
out.push(f);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export const pkgcaches = {
|
|
47
|
+
id: 'pkgcaches',
|
|
48
|
+
title: 'Package manager and toolchain caches',
|
|
49
|
+
async run(opts) {
|
|
50
|
+
const h = opts.home;
|
|
51
|
+
const mac = opts.platform === 'darwin';
|
|
52
|
+
const lib = (p) => path.join(h, 'Library/Caches', p);
|
|
53
|
+
const specs = [
|
|
54
|
+
{
|
|
55
|
+
detector: 'pkgcaches',
|
|
56
|
+
key: 'npm',
|
|
57
|
+
category: 'package-caches',
|
|
58
|
+
title: 'npm cache (~/.npm)',
|
|
59
|
+
path: path.join(h, '.npm'),
|
|
60
|
+
risk: 'safe',
|
|
61
|
+
regenerable: true,
|
|
62
|
+
why: 'Downloaded tarballs plus the npx cache. Only speeds up installs.',
|
|
63
|
+
actions: (id, bytes) => [execAction(id, 'clean', 'Clear the npm cache', 'npm', ['cache', 'clean', '--force'], 'safe', { bytes })],
|
|
64
|
+
},
|
|
65
|
+
{ detector: 'pkgcaches', key: 'pnpm-meta', category: 'package-caches', title: 'pnpm metadata cache', path: lib('pnpm'), risk: 'safe', regenerable: true, why: REGEN },
|
|
66
|
+
{ detector: 'pkgcaches', key: 'yarn', category: 'package-caches', title: 'Yarn cache', path: lib('Yarn'), risk: 'safe', regenerable: true, why: REGEN },
|
|
67
|
+
{ detector: 'pkgcaches', key: 'yarn-berry', category: 'package-caches', title: 'Yarn Berry global cache', path: path.join(h, '.yarn/berry/cache'), risk: 'safe', regenerable: true, why: REGEN },
|
|
68
|
+
{ detector: 'pkgcaches', key: 'bun', category: 'package-caches', title: 'Bun install cache', path: path.join(h, '.bun/install/cache'), risk: 'safe', regenerable: true, why: REGEN },
|
|
69
|
+
{ detector: 'pkgcaches', key: 'pip', category: 'package-caches', title: 'pip cache', path: mac ? lib('pip') : path.join(h, '.cache/pip'), risk: 'safe', regenerable: true, why: REGEN },
|
|
70
|
+
{ detector: 'pkgcaches', key: 'uv', category: 'package-caches', title: 'uv cache', path: path.join(h, '.cache/uv'), risk: 'safe', regenerable: true, why: REGEN },
|
|
71
|
+
{ detector: 'pkgcaches', key: 'homebrew', category: 'package-caches', title: 'Homebrew downloads cache', path: mac ? lib('Homebrew') : path.join(h, '.cache/Homebrew'), risk: 'safe', regenerable: true, why: 'Old bottles and downloads Homebrew keeps after installing.', actions: (id, bytes) => [execAction(id, 'cleanup', 'Run brew cleanup', 'brew', ['cleanup', '--prune=all', '-s'], 'safe', { bytes })] },
|
|
72
|
+
{ detector: 'pkgcaches', key: 'cargo-registry', category: 'package-caches', title: 'Cargo registry cache', path: path.join(h, '.cargo/registry'), risk: 'safe', regenerable: true, why: REGEN },
|
|
73
|
+
{ detector: 'pkgcaches', key: 'cargo-git', category: 'package-caches', title: 'Cargo git checkouts', path: path.join(h, '.cargo/git'), risk: 'safe', regenerable: true, why: REGEN },
|
|
74
|
+
{ detector: 'pkgcaches', key: 'gradle-caches', category: 'package-caches', title: 'Gradle caches', path: path.join(h, '.gradle/caches'), risk: 'safe', regenerable: true, why: 'Dependency and build caches. The next Gradle build re-downloads what it needs, which can take a while.', warning: 'Stop Gradle daemons first (`./gradlew --stop`).' },
|
|
75
|
+
{ detector: 'pkgcaches', key: 'gradle-wrapper', category: 'package-caches', title: 'Gradle wrapper distributions', path: path.join(h, '.gradle/wrapper/dists'), risk: 'safe', regenerable: true, why: 'One Gradle install per version your projects asked for. Re-downloaded on demand.' },
|
|
76
|
+
{ detector: 'pkgcaches', key: 'maven', category: 'package-caches', title: 'Maven local repository', path: path.join(h, '.m2/repository'), risk: 'review', regenerable: true, why: 'Maven artifacts, including any you installed locally from your own builds, which cannot be downloaded again.' },
|
|
77
|
+
{ detector: 'pkgcaches', key: 'cocoapods', category: 'package-caches', title: 'CocoaPods cache', path: lib('CocoaPods'), risk: 'safe', regenerable: true, why: REGEN },
|
|
78
|
+
{ detector: 'pkgcaches', key: 'go-build', category: 'package-caches', title: 'Go build cache', path: mac ? lib('go-build') : path.join(h, '.cache/go-build'), risk: 'safe', regenerable: true, why: 'Compiled package cache. Rebuilt on the next build.' },
|
|
79
|
+
{ detector: 'pkgcaches', key: 'jetbrains', category: 'package-caches', title: 'JetBrains IDE caches', path: lib('JetBrains'), risk: 'safe', regenerable: true, why: 'Indexes and caches for IntelliJ, WebStorm, Android Studio and friends. Rebuilt when you reopen projects.', warning: 'Quit the IDE first.' },
|
|
80
|
+
{ detector: 'pkgcaches', key: 'chrome-cache', category: 'package-caches', title: 'Google Chrome cache', path: lib('Google'), risk: 'safe', regenerable: true, why: 'Browser cache. Chrome refills it as you browse.', warning: 'Quit Chrome first.' },
|
|
81
|
+
];
|
|
82
|
+
const conda = ['miniconda3', 'anaconda3', 'miniforge3', 'mambaforge'].map((d) => path.join(h, d));
|
|
83
|
+
for (const base of conda) {
|
|
84
|
+
specs.push({
|
|
85
|
+
detector: 'pkgcaches',
|
|
86
|
+
key: `conda-pkgs:${path.basename(base)}`,
|
|
87
|
+
category: 'package-caches',
|
|
88
|
+
title: `Conda package cache (${path.basename(base)})`,
|
|
89
|
+
path: path.join(base, 'pkgs'),
|
|
90
|
+
risk: 'safe',
|
|
91
|
+
regenerable: true,
|
|
92
|
+
why: 'Downloaded conda packages and extracted copies. `conda clean` removes the ones no environment uses.',
|
|
93
|
+
actions: (id, bytes) => [execAction(id, 'clean', 'Run conda clean --all', path.join(base, 'bin/conda'), ['clean', '--all', '--yes'], 'safe', { bytes })],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const found = await pathFindings(specs, opts);
|
|
97
|
+
return [...found, ...(await pnpmFindings(opts))];
|
|
98
|
+
},
|
|
99
|
+
};
|