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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prageeth Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # dev-disk-doctor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Why is my disk full?** A read-only disk and git-worktree doctor for developer machines. It finds the space hogs (Docker, Android emulators, Xcode, `node_modules`, package caches, AI models, orphaned git worktrees), explains each one, and gives you a ready-to-run fix with a warning when one is needed.
4
+
5
+ Works as a CLI and as an **MCP server**, so an AI agent (Claude Code, Cursor, Codex…) can run it, show you the options, and ask before touching anything.
6
+
7
+ ```bash
8
+ npx dev-disk-doctor # scan, read-only
9
+ npx dev-disk-doctor worktrees # how many git worktrees do I have, and which are leftovers?
10
+ ```
11
+
12
+ ## Getting started
13
+
14
+ You need [Node.js](https://nodejs.org) 18 or newer. Nothing else to install.
15
+
16
+ 1. **Scan** (read-only, about a minute on a big disk):
17
+ ```bash
18
+ npx dev-disk-doctor
19
+ ```
20
+ You get a ranked list. Each item says what it is, how big it is, how risky deleting it is, and the exact command to fix it.
21
+ 2. **Check the git worktrees** (also read-only):
22
+ ```bash
23
+ npx dev-disk-doctor worktrees
24
+ ```
25
+ 3. **Fix one item at a time.** Copy the `id:` shown under an action. This shows what would happen and changes nothing:
26
+ ```bash
27
+ npx dev-disk-doctor fix "docker:build-cache#prune"
28
+ ```
29
+ Add `--yes` to actually run it.
30
+ 4. **Or let an AI agent drive it**; see [Use it with an AI agent](#use-it-with-an-ai-agent-mcp) below.
31
+
32
+ To install it globally instead of using `npx`: `npm install -g dev-disk-doctor`, then run `dev-disk-doctor`.
33
+
34
+ ## Where it looks for your projects
35
+
36
+ Projects, `node_modules` and git worktrees are searched in your *workspace folders*. You don't have to configure them:
37
+
38
+ - Common names directly under your home folder are always used if they exist: `dev`, `code`, `src`, `projects`, `work`, `workspace`, `workspaces`, `Developer`, `repos`, `git`, `Sites`.
39
+ - **Any other visible folder directly under your home that contains a git repo or project** within three levels is picked up too. So a company folder such as `~/acme` is found automatically, even though it has an arbitrary name.
40
+ - Also checked automatically, for worktrees: the places AI coding tools park them (`~/.codex/worktrees`, `~/.cursor/worktrees`, `~/.claude/worktrees`, `~/conductor`), and `.claude/worktrees` inside any repo.
41
+ - Never searched by default: `~/Documents`, `~/Desktop`, `~/Downloads`, `~/Library`, `~/Applications`, hidden folders, and cloud-sync folders (Dropbox, OneDrive, Google Drive, iCloud…), because they are slow and can trigger permission prompts.
42
+
43
+ See what it will use:
44
+
45
+ ```bash
46
+ npx dev-disk-doctor roots
47
+ ```
48
+
49
+ Override it when your code lives somewhere else (for example `~/Documents/GitHub` or an external drive):
50
+
51
+ ```bash
52
+ npx dev-disk-doctor --root ~/Documents/GitHub --root /Volumes/Work/code
53
+ # or, for every run:
54
+ export DEV_DISK_DOCTOR_ROOTS="$HOME/Documents/GitHub:/Volumes/Work/code"
55
+ ```
56
+
57
+ When you pass `--root` or set `DEV_DISK_DOCTOR_ROOTS`, auto-discovery is turned off and only those folders are searched.
58
+
59
+ ## What it checks
60
+
61
+ | Area | Examples |
62
+ |---|---|
63
+ | Containers | Docker Desktop disk image (`Docker.raw`), unused images, build cache, stopped containers, volumes |
64
+ | Mobile | Android emulator system images (never one an AVD uses), AVD data, NDKs, Xcode DerivedData, DeviceSupport, simulators |
65
+ | Package caches | npm, pnpm (old store versions too), Yarn, Bun, pip, uv, conda, Homebrew, Cargo, Gradle, Maven, CocoaPods, Go |
66
+ | Test browsers | Playwright, Puppeteer, Cypress downloads |
67
+ | AI models | Hugging Face, Ollama, LM Studio, PyTorch hub, DiffusionBee |
68
+ | Projects | `node_modules`, `.next`, Rust `target/`, `Pods/`, Python venvs in your workspaces, with how long the project has been idle |
69
+ | **Git worktrees** | Every linked worktree in every repo under your workspaces, including those made by Claude Code, Codex, Cursor and Conductor: active, stale, dangling, locked, at-risk, or orphaned |
70
+ | System | Trash, big Downloads, logs, iOS backups, Time Machine local snapshots |
71
+ | Everything else | `--deep` sweeps your home folder for hogs nobody else recognises |
72
+
73
+ ## Safe by design
74
+
75
+ - **Scanning never changes anything.** No network, no telemetry.
76
+ - Every finding has a risk level: `SAFE` (regenerates on its own), `REVIEW` (probably fine, check first) or `CAREFUL` (could lose data, never run by the tool).
77
+ - `fix <action-id>` is a **dry run** unless you add `--yes`. Actions come from a fixed allowlist built by the scanners; the tool never executes free-form commands.
78
+ - Worktrees are offered for removal only when they are clean, unlocked, and merged or fully pushed. Dirty worktrees, unpushed commits and Docker volumes are reported with a warning and no apply path.
79
+ - It will not delete anything outside your home folder, your home folder itself, or `Documents`, `Desktop`, `Library` and other top-level folders.
80
+
81
+ ## Use it with an AI agent (MCP)
82
+
83
+ Add to your MCP client config:
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "dev-disk-doctor": { "command": "npx", "args": ["-y", "dev-disk-doctor", "mcp"] }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Claude Code: `claude mcp add dev-disk-doctor -- npx -y dev-disk-doctor mcp`
94
+
95
+ | Tool | What it does |
96
+ |---|---|
97
+ | `disk_scan` | Ranked findings with explanations, risk and available actions |
98
+ | `worktree_audit` | All repos, all worktrees, which are stale, dangling, orphaned or at risk |
99
+ | `explain_finding` | Re-checks one finding |
100
+ | `apply_action` | Runs one allowlisted action. Dry run unless `confirm: true`. Refuses anything risky |
101
+
102
+ No MCP? Give your agent the playbook in [`skills/dev-disk-doctor/SKILL.md`](skills/dev-disk-doctor/SKILL.md) and it can drive the CLI with `--json`.
103
+
104
+ ## CLI
105
+
106
+ ```text
107
+ dev-disk-doctor [scan] Scan for space hogs (read-only)
108
+ dev-disk-doctor worktrees Audit git worktrees across all your repos
109
+ dev-disk-doctor fix <action-id> Show what an action does; add --yes to run it
110
+ dev-disk-doctor roots Show which workspace folders will be searched
111
+ dev-disk-doctor mcp Start the MCP server on stdio
112
+
113
+ --json --root <dir> --min-size <MB> --stale-days <n> --only <detector> --deep --all --yes
114
+ ```
115
+
116
+ ## Why does macOS say "Documents" is 230 GB?
117
+
118
+ macOS Storage settings has a category called **Documents** that is not your `~/Documents` folder. It is everything macOS cannot file under Apps, Photos, Mail or System, which for a developer means `~/Library` (Docker's disk image, Android SDK images, app data), `~/.cache`, `~/.npm`, model downloads and your code. Run `npx dev-disk-doctor` to see what is actually in there.
119
+
120
+ ## Common questions
121
+
122
+ - **How do I shrink `Docker.raw`?** Prune inside Docker (`docker image prune -a`, `docker builder prune -a`), then compact the disk image in Docker Desktop → Settings → Resources. The tool reports what is reclaimable and warns about volumes.
123
+ - **How do I delete old Android system images?** The scan lists every image under `~/Library/Android/sdk/system-images`, marks the ones your emulators use, and gives a removal command for the rest.
124
+ - **How do I find stale git worktrees?** `npx dev-disk-doctor worktrees` lists every linked worktree across your repos with its state and size. AI coding tools create many; some get orphaned.
125
+ - **Is the pnpm store safe to delete?** It is a cache. Old store versions (`v3`, `v10`) that your installed pnpm does not use can go; the active one is pruned rather than deleted.
126
+
127
+ ## Platform support
128
+
129
+ macOS is the primary target and is tested. Linux works for the cross-platform checks (Docker, package caches, models, projects, worktrees). Windows is not supported yet.
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ npm install
135
+ npm test # builds, then runs the node:test suite
136
+ npm run scan # build and run against your machine
137
+ ```
138
+
139
+ The scanner core is in `src/core`, one file per area in `src/detectors`, the worktree audit in `src/worktrees`, and the MCP server in `src/mcp.ts`. See [PLAN.md](PLAN.md) for the design.
140
+
141
+ ## Releasing (maintainers)
142
+
143
+ Pushing a `v*` tag publishes to npm through GitHub Actions. Setup, the release steps, how to verify a release and troubleshooting are in [docs/RELEASING.md](docs/RELEASING.md).
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { applyAction } from './core/fix.js';
4
+ import { buildOptions, scan } from './core/scan.js';
5
+ import { version } from './core/version.js';
6
+ import { expandHome } from './core/util.js';
7
+ import { renderScan, renderWorktrees } from './report/text.js';
8
+ import { auditWorktrees } from './worktrees/audit.js';
9
+ const HELP = `dev-disk-doctor: find out why your developer machine's disk is full
10
+
11
+ Usage
12
+ dev-disk-doctor [scan] Scan for space hogs (read-only)
13
+ dev-disk-doctor worktrees Audit git worktrees across all your repos (read-only)
14
+ dev-disk-doctor fix <action-id> Show what an action would do; add --yes to run it
15
+ dev-disk-doctor roots Show which workspace folders will be searched
16
+ dev-disk-doctor mcp Start the MCP server on stdio (for AI agents)
17
+
18
+ Options
19
+ --json Machine-readable output (stable schema)
20
+ --root <dir> Workspace folder to search for projects and repos (repeatable).
21
+ Default: ~/dev ~/code ~/src ... plus any other folder directly under
22
+ your home that contains a git repo (e.g. ~/acme). Also settable with
23
+ DEV_DISK_DOCTOR_ROOTS=/path/a:/path/b. Run "roots" to see the result.
24
+ --min-size <MB> Ignore items smaller than this (default 100)
25
+ --stale-days <n> Days idle before a project or worktree counts as stale (default 30)
26
+ --only <detector> Run one detector (docker, android, xcode, pkgcaches, browsers,
27
+ models, projects, worktrees, system, biggest). Repeatable.
28
+ --deep Also sweep your whole home folder for unknown space hogs (slow)
29
+ --all Show every finding instead of the top 25
30
+ --yes With "fix": actually run the action
31
+ --no-color Plain output
32
+ -v, --version Print the version
33
+
34
+ Nothing is deleted unless you run "fix <id> --yes". Actions marked CAREFUL are never run by the tool.`;
35
+ function parse(argv) {
36
+ const p = { command: 'scan', positional: [], json: false, roots: [], only: [], deep: false, all: false, yes: false, color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR };
37
+ const args = [...argv];
38
+ const need = (flag) => {
39
+ const v = args.shift();
40
+ if (v === undefined)
41
+ throw new Error(`${flag} needs a value`);
42
+ return v;
43
+ };
44
+ let first = true;
45
+ while (args.length) {
46
+ const a = args.shift();
47
+ if (a === '--json')
48
+ p.json = true;
49
+ else if (a === '--root')
50
+ p.roots.push(need(a));
51
+ else if (a === '--min-size')
52
+ p.minMb = Number(need(a));
53
+ else if (a === '--stale-days')
54
+ p.staleDays = Number(need(a));
55
+ else if (a === '--only')
56
+ p.only.push(need(a));
57
+ else if (a === '--deep')
58
+ p.deep = true;
59
+ else if (a === '--all')
60
+ p.all = true;
61
+ else if (a === '--yes' || a === '-y')
62
+ p.yes = true;
63
+ else if (a === '--no-color')
64
+ p.color = false;
65
+ else if (a === '-h' || a === '--help' || a === 'help')
66
+ p.command = 'help';
67
+ else if (a === '-v' || a === '--version')
68
+ p.command = 'version';
69
+ else if (a.startsWith('-'))
70
+ throw new Error(`Unknown option ${a}. Try --help.`);
71
+ else if (first)
72
+ p.command = a;
73
+ else
74
+ p.positional.push(a);
75
+ first = false;
76
+ }
77
+ return p;
78
+ }
79
+ async function options(p) {
80
+ const home = process.env.HOME ?? '';
81
+ return buildOptions({
82
+ roots: p.roots.map((r) => path.resolve(expandHome(r, home))),
83
+ minBytes: p.minMb !== undefined ? p.minMb * 1024 * 1024 : undefined,
84
+ staleDays: p.staleDays,
85
+ deep: p.deep,
86
+ only: p.only.length ? p.only : undefined,
87
+ });
88
+ }
89
+ async function main() {
90
+ const p = parse(process.argv.slice(2));
91
+ switch (p.command) {
92
+ case 'help':
93
+ console.log(HELP);
94
+ return;
95
+ case 'version':
96
+ console.log(version());
97
+ return;
98
+ case 'mcp': {
99
+ const { startMcp } = await import('./mcp.js');
100
+ await startMcp();
101
+ return;
102
+ }
103
+ case 'roots': {
104
+ const opts = await options(p);
105
+ if (p.json)
106
+ console.log(JSON.stringify({ roots: opts.roots }, null, 2));
107
+ else if (opts.roots.length)
108
+ console.log(opts.roots.join('\n'));
109
+ else
110
+ console.log('No workspace folders found under your home. Pass --root <dir> or set DEV_DISK_DOCTOR_ROOTS.');
111
+ return;
112
+ }
113
+ case 'scan': {
114
+ const opts = await options(p);
115
+ if (!p.json)
116
+ console.error('Scanning (read-only)… this can take a minute on a big disk.');
117
+ const result = await scan(opts);
118
+ console.log(p.json ? JSON.stringify(result, null, 2) : renderScan(result, { color: p.color }, { limit: p.all ? Infinity : 25 }));
119
+ return;
120
+ }
121
+ case 'worktrees': {
122
+ const opts = await options(p);
123
+ if (!p.json)
124
+ console.error('Auditing worktrees (read-only)…');
125
+ const audit = await auditWorktrees(opts);
126
+ console.log(p.json ? JSON.stringify(audit, null, 2) : renderWorktrees(audit, { color: p.color }, opts.home));
127
+ return;
128
+ }
129
+ case 'fix': {
130
+ const id = p.positional[0];
131
+ if (!id)
132
+ throw new Error('fix needs an action id, e.g. dev-disk-doctor fix "docker:build-cache#prune"');
133
+ const opts = await options(p);
134
+ const res = await applyAction(opts, id, p.yes);
135
+ if (p.json)
136
+ console.log(JSON.stringify(res, null, 2));
137
+ else {
138
+ console.log(res.message);
139
+ if (res.command)
140
+ console.log(`$ ${res.command}`);
141
+ if (res.warning)
142
+ console.log(`! ${res.warning}`);
143
+ if (res.output)
144
+ console.log(res.output);
145
+ if (res.freedBytes !== undefined && !res.dryRun)
146
+ console.log(`Free space changed by ${(res.freedBytes / 1024 ** 3).toFixed(2)} GB.`);
147
+ }
148
+ if (!res.ok)
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ default:
153
+ throw new Error(`Unknown command "${p.command}". Try --help.`);
154
+ }
155
+ }
156
+ main().catch((e) => {
157
+ console.error(e instanceof Error ? e.message : e);
158
+ process.exitCode = 1;
159
+ });
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process';
2
+ /** Run a program without a shell. Never throws. */
3
+ export function run(file, args, opts = {}) {
4
+ return new Promise((resolve) => {
5
+ execFile(file, args, {
6
+ cwd: opts.cwd,
7
+ timeout: opts.timeoutMs ?? 30_000,
8
+ maxBuffer: opts.maxBuffer ?? 16 * 1024 * 1024,
9
+ encoding: 'utf8',
10
+ env: { ...process.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0' },
11
+ }, (err, stdout, stderr) => {
12
+ if (!err)
13
+ return resolve({ ok: true, code: 0, stdout, stderr, timedOut: false });
14
+ const e = err;
15
+ resolve({
16
+ ok: false,
17
+ code: typeof e.code === 'number' ? e.code : null,
18
+ stdout: stdout ?? '',
19
+ stderr: stderr || e.message,
20
+ timedOut: Boolean(e.killed),
21
+ });
22
+ });
23
+ });
24
+ }
25
+ export async function commandExists(file) {
26
+ const r = await run(process.platform === 'win32' ? 'where' : 'which', [file], { timeoutMs: 5000 });
27
+ return r.ok;
28
+ }
@@ -0,0 +1,74 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { run } from './exec.js';
4
+ import { diskInfo, scan } from './scan.js';
5
+ import { isInside } from './util.js';
6
+ const PROTECTED_HOME_CHILDREN = new Set(['Documents', 'Desktop', 'Pictures', 'Movies', 'Music', 'Public', 'Applications', 'Library', 'Downloads']);
7
+ /** Defence in depth: detectors only ever produce safe paths, but never trust that blindly. */
8
+ export function assertSafeToRemove(target, opts) {
9
+ if (!path.isAbsolute(target))
10
+ throw new Error('Refusing to delete a relative path.');
11
+ const normalized = path.resolve(target);
12
+ if (normalized !== target.replace(/\/+$/, '') && normalized !== target)
13
+ throw new Error('Refusing to delete a non-normalised path.');
14
+ if (!isInside(normalized, opts.home) || normalized === opts.home)
15
+ throw new Error('Refusing to delete anything outside your home folder.');
16
+ const rel = path.relative(opts.home, normalized);
17
+ if (PROTECTED_HOME_CHILDREN.has(rel))
18
+ throw new Error(`Refusing to delete ~/${rel} itself.`);
19
+ if (opts.roots.some((r) => path.resolve(r) === normalized))
20
+ throw new Error('Refusing to delete a workspace root.');
21
+ }
22
+ export function findAction(findings, actionId) {
23
+ for (const finding of findings) {
24
+ const action = finding.actions.find((a) => a.id === actionId);
25
+ if (action)
26
+ return { finding, action };
27
+ }
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Re-scans just the owning detector (so state is fresh), then runs one allowlisted action.
32
+ * Dry run unless `confirm` is true.
33
+ */
34
+ export async function applyAction(opts, actionId, confirm = false) {
35
+ const hash = actionId.lastIndexOf('#');
36
+ const findingId = hash > 0 ? actionId.slice(0, hash) : actionId;
37
+ const detector = findingId.split(':')[0];
38
+ const base = { dryRun: !confirm, findingId, actionId };
39
+ if (detector === 'biggest')
40
+ return { ...base, ok: false, dryRun: true, message: 'Findings from the whole-home sweep have no automatic actions.' };
41
+ const result = await scan({ ...opts, only: [detector], minBytes: 0 });
42
+ const hit = findAction(result.findings, actionId);
43
+ if (!hit) {
44
+ return { ...base, ok: false, dryRun: true, message: `No action "${actionId}" exists any more. It may already be done, or the state changed. Run a new scan.` };
45
+ }
46
+ const { finding, action } = hit;
47
+ const common = { command: action.display, warning: action.warning };
48
+ if (action.kind.type === 'manual')
49
+ return { ...base, ...common, ok: false, dryRun: true, message: 'This one is instructions only; the tool never applies it. Run it yourself if you decide to.' };
50
+ if (action.risk === 'danger' || finding.risk === 'danger')
51
+ return { ...base, ...common, ok: false, dryRun: true, message: 'Refused: this could lose data that cannot be recovered.' };
52
+ if (!confirm)
53
+ return { ...base, ...common, ok: true, dryRun: true, message: `Dry run. Would run: ${action.display}. Re-run with --yes (CLI) or confirm: true (MCP) to apply.` };
54
+ const before = await diskInfo(opts.home);
55
+ try {
56
+ let output = '';
57
+ if (action.kind.type === 'remove-path') {
58
+ assertSafeToRemove(action.kind.path, opts);
59
+ await fs.rm(action.kind.path, { recursive: true, force: true, maxRetries: 2 });
60
+ }
61
+ else {
62
+ const r = await run(action.kind.file, action.kind.args, { cwd: action.kind.cwd, timeoutMs: 15 * 60_000 });
63
+ output = (r.stdout + r.stderr).trim().slice(-4000);
64
+ if (!r.ok)
65
+ return { ...base, ...common, ok: false, dryRun: false, message: `Command failed${r.timedOut ? ' (timed out)' : ''}.`, output };
66
+ }
67
+ const after = await diskInfo(opts.home);
68
+ const freed = before && after ? Math.max(0, after.freeBytes - before.freeBytes) : undefined;
69
+ return { ...base, ...common, ok: true, dryRun: false, message: 'Done.', output: output || undefined, freedBytes: freed };
70
+ }
71
+ catch (e) {
72
+ return { ...base, ...common, ok: false, dryRun: false, message: e instanceof Error ? e.message : String(e) };
73
+ }
74
+ }
@@ -0,0 +1,81 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { expandHome, isDir } from './util.js';
4
+ /** Folder names people commonly keep code in. Always included when present. */
5
+ const KNOWN = ['dev', 'code', 'src', 'projects', 'work', 'workspace', 'workspaces', 'Developer', 'repos', 'git', 'Sites'];
6
+ /** Top-level folders that are never workspaces: macOS standard folders and cloud-sync mounts (slow, may prompt). */
7
+ const NEVER = new Set([
8
+ 'Applications', 'Library', 'Documents', 'Desktop', 'Downloads', 'Movies', 'Music', 'Pictures', 'Public',
9
+ 'node_modules', 'miniconda3', 'anaconda3', 'miniforge3', 'mambaforge', 'go', 'snap',
10
+ ]);
11
+ const CLOUD = /^(Dropbox|OneDrive|Box|Google Drive|iCloud|Creative Cloud Files|pCloud|MEGA|Sync)/i;
12
+ const MARKERS = new Set(['.git', 'package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle', 'composer.json', 'Gemfile', 'Package.swift']);
13
+ /** Does this folder hold a git repo or project within a few levels? Bounded so it stays cheap. */
14
+ async function looksLikeWorkspace(dir) {
15
+ let budget = 3000;
16
+ async function walk(d, depth) {
17
+ if (budget-- <= 0)
18
+ return false;
19
+ let entries;
20
+ try {
21
+ entries = await fs.readdir(d, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ if (entries.some((e) => MARKERS.has(e.name)))
27
+ return true;
28
+ if (depth >= 3)
29
+ return false;
30
+ for (const e of entries) {
31
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
32
+ continue;
33
+ if (await walk(path.join(d, e.name), depth + 1))
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ return walk(dir, 0);
39
+ }
40
+ /**
41
+ * Where to look for projects and git repos.
42
+ *
43
+ * 1. `DEV_DISK_DOCTOR_ROOTS` (colon-separated) replaces everything below.
44
+ * 2. Otherwise: common names (`~/dev`, `~/code`, `~/src`…) plus every other visible folder directly
45
+ * under your home that contains a git repo or project within three levels. That is how a company
46
+ * folder such as `~/acme` is found without configuration.
47
+ *
48
+ * `~/Documents`, `~/Desktop` and cloud-sync folders are never searched unless you pass them with `--root`.
49
+ */
50
+ export async function defaultRoots(home, env = process.env) {
51
+ const fromEnv = env.DEV_DISK_DOCTOR_ROOTS?.split(':').map((s) => s.trim()).filter(Boolean);
52
+ if (fromEnv?.length) {
53
+ const resolved = fromEnv.map((r) => path.resolve(expandHome(r, home)));
54
+ const existing = [];
55
+ for (const r of resolved)
56
+ if (await isDir(r))
57
+ existing.push(r);
58
+ return existing;
59
+ }
60
+ const roots = [];
61
+ let names = [];
62
+ try {
63
+ names = await fs.readdir(home);
64
+ }
65
+ catch {
66
+ return roots;
67
+ }
68
+ const candidates = names.filter((n) => !n.startsWith('.') && !NEVER.has(n) && !CLOUD.test(n));
69
+ const results = await Promise.all(candidates.map(async (n) => {
70
+ const p = path.join(home, n);
71
+ if (!(await isDir(p)))
72
+ return undefined;
73
+ if (KNOWN.includes(n) || (await looksLikeWorkspace(p)))
74
+ return p;
75
+ return undefined;
76
+ }));
77
+ for (const r of results)
78
+ if (r)
79
+ roots.push(r);
80
+ return roots.sort();
81
+ }
@@ -0,0 +1,99 @@
1
+ import os from 'node:os';
2
+ import { detectors } from '../detectors/index.js';
3
+ import { run } from './exec.js';
4
+ import { defaultRoots } from './roots.js';
5
+ import { isInside } from './util.js';
6
+ export async function buildOptions(partial = {}) {
7
+ const home = partial.home ?? os.homedir();
8
+ return {
9
+ home,
10
+ platform: partial.platform ?? process.platform,
11
+ roots: partial.roots?.length ? partial.roots : await defaultRoots(home),
12
+ minBytes: partial.minBytes ?? 100 * 1024 * 1024,
13
+ staleDays: partial.staleDays ?? 30,
14
+ deep: partial.deep ?? false,
15
+ only: partial.only,
16
+ detectorTimeoutMs: partial.detectorTimeoutMs ?? 120_000,
17
+ };
18
+ }
19
+ export async function diskInfo(home) {
20
+ const r = await run('df', ['-k', home], { timeoutMs: 10_000 });
21
+ const line = r.stdout.trim().split('\n')[1];
22
+ if (!line)
23
+ return undefined;
24
+ const cols = line.trim().split(/\s+/);
25
+ const total = Number(cols[1]) * 1024;
26
+ const free = Number(cols[3]) * 1024;
27
+ return Number.isFinite(total) && Number.isFinite(free) ? { totalBytes: total, freeBytes: free } : undefined;
28
+ }
29
+ /** Drop findings that live inside another finding's folder so nothing is counted twice. */
30
+ function outermost(findings) {
31
+ const withPath = findings.filter((f) => f.path);
32
+ return findings.filter((f) => {
33
+ if (!f.path)
34
+ return true;
35
+ return !withPath.some((g) => g !== f && g.path !== f.path && isInside(f.path, g.path));
36
+ });
37
+ }
38
+ export function computeTotals(findings) {
39
+ // A container such as Docker's disk image holds the other Docker findings; count it only when it stands alone.
40
+ const counted = outermost(findings).filter((f) => !(f.meta?.envelope && findings.some((g) => g !== f && g.detector === f.detector && !g.meta?.envelope && g.bytes > 0)));
41
+ const bytesByRisk = { safe: 0, review: 0, danger: 0 };
42
+ let bytes = 0;
43
+ let reclaimable = 0;
44
+ for (const f of counted) {
45
+ bytes += f.bytes;
46
+ bytesByRisk[f.risk] += f.bytes;
47
+ if (f.risk !== 'danger') {
48
+ // Only count what an action says it frees; "delete unavailable simulators" etc. do not free the whole folder.
49
+ const runnable = f.actions.filter((a) => a.kind.type !== 'manual' && a.risk !== 'danger');
50
+ reclaimable += Math.max(0, ...runnable.map((a) => a.reclaimBytes ?? 0));
51
+ }
52
+ }
53
+ return { findings: findings.length, bytes, reclaimableBytes: reclaimable, bytesByRisk };
54
+ }
55
+ function withTimeout(p, ms, label) {
56
+ let timer;
57
+ const t = new Promise((_, rej) => {
58
+ timer = setTimeout(() => rej(new Error(`${label} exceeded ${Math.round(ms / 1000)}s`)), ms);
59
+ });
60
+ return Promise.race([p, t]).finally(() => clearTimeout(timer));
61
+ }
62
+ export async function scan(opts) {
63
+ const findings = [];
64
+ const skipped = [];
65
+ const timings = {};
66
+ const selected = detectors.filter((d) => (opts.only ? opts.only.includes(d.id) : true));
67
+ const runOne = async (d) => {
68
+ const t0 = Date.now();
69
+ try {
70
+ const res = await withTimeout(d.run(opts, () => findings), opts.detectorTimeoutMs * 4, d.id);
71
+ findings.push(...res.filter((f) => f.bytes >= opts.minBytes || f.bytes === 0 || f.detector === 'worktrees'));
72
+ }
73
+ catch (e) {
74
+ skipped.push({ detector: d.id, reason: e instanceof Error ? e.message : String(e) });
75
+ }
76
+ finally {
77
+ timings[d.id] = Date.now() - t0;
78
+ }
79
+ };
80
+ const quick = selected.filter((d) => !d.deepOnly);
81
+ const deep = selected.filter((d) => d.deepOnly);
82
+ await Promise.all(quick.map(runOne));
83
+ if (opts.deep || (opts.only && deep.length))
84
+ await Promise.all(deep.map(runOne));
85
+ else if (deep.length)
86
+ skipped.push(...deep.map((d) => ({ detector: d.id, reason: 'Only runs with --deep (slow whole-home sweep).' })));
87
+ findings.sort((a, b) => b.bytes - a.bytes);
88
+ return {
89
+ schema: 1,
90
+ generatedAt: new Date().toISOString(),
91
+ host: { platform: opts.platform, home: opts.home },
92
+ disk: await diskInfo(opts.home),
93
+ options: { roots: opts.roots, minBytes: opts.minBytes, staleDays: opts.staleDays, deep: opts.deep },
94
+ totals: computeTotals(findings),
95
+ findings,
96
+ skipped,
97
+ timingsMs: timings,
98
+ };
99
+ }
@@ -0,0 +1,34 @@
1
+ import { run } from './exec.js';
2
+ import { pathExists } from './util.js';
3
+ const MAX_PARALLEL = 4;
4
+ let active = 0;
5
+ const waiting = [];
6
+ async function slot(fn) {
7
+ if (active >= MAX_PARALLEL)
8
+ await new Promise((r) => waiting.push(r));
9
+ active++;
10
+ try {
11
+ return await fn();
12
+ }
13
+ finally {
14
+ active--;
15
+ waiting.shift()?.();
16
+ }
17
+ }
18
+ /**
19
+ * Allocated size in bytes (what the disk actually spends, so sparse files such as
20
+ * Docker.raw report their real footprint). Returns undefined if missing or too slow.
21
+ */
22
+ export async function sizeOf(p, timeoutMs = 120_000) {
23
+ if (!(await pathExists(p)))
24
+ return undefined;
25
+ return slot(async () => {
26
+ // -k: 1024-byte blocks, -s: summary, -x: stay on one filesystem. Exit code can be
27
+ // non-zero on unreadable subfolders while stdout is still the right total.
28
+ const r = await run('du', ['-skx', p], { timeoutMs });
29
+ if (r.timedOut)
30
+ return undefined;
31
+ const kb = Number(r.stdout.split(/\s/, 1)[0]);
32
+ return Number.isFinite(kb) ? kb * 1024 : undefined;
33
+ });
34
+ }
@@ -0,0 +1 @@
1
+ export {};