@tukuyomil032/broom 1.0.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/dist/commands/analyze.js +371 -0
  4. package/dist/commands/backup.js +257 -0
  5. package/dist/commands/clean.js +255 -0
  6. package/dist/commands/completion.js +714 -0
  7. package/dist/commands/config.js +474 -0
  8. package/dist/commands/doctor.js +280 -0
  9. package/dist/commands/duplicates.js +325 -0
  10. package/dist/commands/help.js +34 -0
  11. package/dist/commands/index.js +22 -0
  12. package/dist/commands/installer.js +266 -0
  13. package/dist/commands/optimize.js +270 -0
  14. package/dist/commands/purge.js +271 -0
  15. package/dist/commands/remove.js +184 -0
  16. package/dist/commands/reports.js +173 -0
  17. package/dist/commands/schedule.js +249 -0
  18. package/dist/commands/status.js +468 -0
  19. package/dist/commands/touchid.js +230 -0
  20. package/dist/commands/uninstall.js +336 -0
  21. package/dist/commands/update.js +182 -0
  22. package/dist/commands/watch.js +258 -0
  23. package/dist/index.js +131 -0
  24. package/dist/scanners/base.js +21 -0
  25. package/dist/scanners/browser-cache.js +111 -0
  26. package/dist/scanners/dev-cache.js +64 -0
  27. package/dist/scanners/docker.js +96 -0
  28. package/dist/scanners/downloads.js +66 -0
  29. package/dist/scanners/homebrew.js +82 -0
  30. package/dist/scanners/index.js +126 -0
  31. package/dist/scanners/installer.js +87 -0
  32. package/dist/scanners/ios-backups.js +82 -0
  33. package/dist/scanners/node-modules.js +75 -0
  34. package/dist/scanners/temp-files.js +65 -0
  35. package/dist/scanners/trash.js +90 -0
  36. package/dist/scanners/user-cache.js +62 -0
  37. package/dist/scanners/user-logs.js +53 -0
  38. package/dist/scanners/xcode.js +124 -0
  39. package/dist/types/index.js +23 -0
  40. package/dist/ui/index.js +5 -0
  41. package/dist/ui/monitors.js +345 -0
  42. package/dist/ui/output.js +304 -0
  43. package/dist/ui/prompts.js +270 -0
  44. package/dist/utils/config.js +133 -0
  45. package/dist/utils/debug.js +119 -0
  46. package/dist/utils/fs.js +283 -0
  47. package/dist/utils/help.js +265 -0
  48. package/dist/utils/index.js +6 -0
  49. package/dist/utils/paths.js +142 -0
  50. package/dist/utils/report.js +404 -0
  51. package/package.json +87 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Watch command - Monitor directory sizes
3
+ */
4
+ import chalk from 'chalk';
5
+ import { Command } from 'commander';
6
+ import { enhanceCommandHelp } from '../utils/help.js';
7
+ import { expandPath, formatSize, exists } from '../utils/fs.js';
8
+ import { printHeader, separator, success, error, warning } from '../ui/output.js';
9
+ import { mkdir, readFile, writeFile } from 'fs/promises';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+ const execAsync = promisify(exec);
13
+ const WATCH_CONFIG_FILE = expandPath('~/.config/broom/watch.json');
14
+ /**
15
+ * Parse size threshold
16
+ */
17
+ function parseThreshold(thresholdStr) {
18
+ const match = thresholdStr.match(/^(\d+(?:\.\d+)?)(KB|MB|GB|TB)?$/i);
19
+ if (!match) {
20
+ throw new Error(`Invalid threshold format: ${thresholdStr}`);
21
+ }
22
+ const value = parseFloat(match[1]);
23
+ const unit = (match[2] || 'MB').toUpperCase();
24
+ switch (unit) {
25
+ case 'KB':
26
+ return value * 1024;
27
+ case 'MB':
28
+ return value * 1024 * 1024;
29
+ case 'GB':
30
+ return value * 1024 * 1024 * 1024;
31
+ case 'TB':
32
+ return value * 1024 * 1024 * 1024 * 1024;
33
+ default:
34
+ return value * 1024 * 1024; // Default to MB
35
+ }
36
+ }
37
+ /**
38
+ * Load watch configuration
39
+ */
40
+ async function loadWatchConfig() {
41
+ try {
42
+ if (exists(WATCH_CONFIG_FILE)) {
43
+ const content = await readFile(WATCH_CONFIG_FILE, 'utf-8');
44
+ const data = JSON.parse(content);
45
+ // Parse dates
46
+ return data.map((w) => ({
47
+ ...w,
48
+ lastNotified: w.lastNotified ? new Date(w.lastNotified) : undefined,
49
+ }));
50
+ }
51
+ }
52
+ catch {
53
+ // Return empty array if cannot read
54
+ }
55
+ return [];
56
+ }
57
+ /**
58
+ * Save watch configuration
59
+ */
60
+ async function saveWatchConfig(watches) {
61
+ await mkdir(expandPath('~/.config/broom'), { recursive: true });
62
+ await writeFile(WATCH_CONFIG_FILE, JSON.stringify(watches, null, 2), 'utf-8');
63
+ }
64
+ /**
65
+ * Get directory size
66
+ */
67
+ async function getDirectorySize(path) {
68
+ try {
69
+ const { stdout } = await execAsync(`du -sk "${path}"`);
70
+ const size = parseInt(stdout.split('\t')[0]) * 1024;
71
+ return size;
72
+ }
73
+ catch {
74
+ return 0;
75
+ }
76
+ }
77
+ /**
78
+ * Send macOS notification
79
+ */
80
+ async function sendNotification(title, message) {
81
+ try {
82
+ const script = `display notification "${message}" with title "${title}" sound name "default"`;
83
+ await execAsync(`osascript -e '${script}'`);
84
+ }
85
+ catch {
86
+ // Notification failed
87
+ }
88
+ }
89
+ /**
90
+ * Add watch
91
+ */
92
+ async function addWatch(options) {
93
+ if (!options.path) {
94
+ error('--path is required');
95
+ return;
96
+ }
97
+ if (!options.threshold) {
98
+ error('--threshold is required (e.g., 5GB)');
99
+ return;
100
+ }
101
+ const path = expandPath(options.path);
102
+ if (!exists(path)) {
103
+ error(`Path does not exist: ${path}`);
104
+ return;
105
+ }
106
+ const threshold = parseThreshold(options.threshold);
107
+ const watches = await loadWatchConfig();
108
+ // Check if already watching
109
+ if (watches.some((w) => w.path === path)) {
110
+ warning(`Already watching: ${path}`);
111
+ return;
112
+ }
113
+ watches.push({
114
+ path,
115
+ threshold,
116
+ notify: options.notify ?? true,
117
+ autoClean: options.autoClean ?? false,
118
+ });
119
+ await saveWatchConfig(watches);
120
+ success(`Added watch: ${path}`);
121
+ console.log(` Threshold: ${formatSize(threshold)}`);
122
+ console.log(` Notify: ${options.notify ?? true}`);
123
+ console.log(` Auto-clean: ${options.autoClean ?? false}`);
124
+ }
125
+ /**
126
+ * Remove watch
127
+ */
128
+ async function removeWatch(pathToRemove) {
129
+ const expandedPath = expandPath(pathToRemove);
130
+ const watches = await loadWatchConfig();
131
+ const index = watches.findIndex((w) => w.path === expandedPath);
132
+ if (index === -1) {
133
+ error(`Not watching: ${expandedPath}`);
134
+ return;
135
+ }
136
+ watches.splice(index, 1);
137
+ await saveWatchConfig(watches);
138
+ success(`Removed watch: ${expandedPath}`);
139
+ }
140
+ /**
141
+ * List watches
142
+ */
143
+ async function listWatches() {
144
+ printHeader('👀 Directory Watches');
145
+ const watches = await loadWatchConfig();
146
+ if (watches.length === 0) {
147
+ warning('No watches configured');
148
+ console.log();
149
+ console.log(chalk.dim('Use "broom watch --add --path <path> --threshold 5GB" to add a watch'));
150
+ return;
151
+ }
152
+ console.log();
153
+ for (const watch of watches) {
154
+ console.log(chalk.bold(`📁 ${watch.path}`));
155
+ console.log(` Threshold: ${chalk.cyan(formatSize(watch.threshold))}`);
156
+ console.log(` Notify: ${watch.notify ? chalk.green('Yes') : chalk.red('No')}`);
157
+ console.log(` Auto-clean: ${watch.autoClean ? chalk.green('Yes') : chalk.red('No')}`);
158
+ if (watch.lastNotified) {
159
+ console.log(` Last notified: ${chalk.dim(watch.lastNotified.toLocaleString())}`);
160
+ }
161
+ console.log();
162
+ }
163
+ separator();
164
+ console.log();
165
+ console.log(chalk.dim('Use "broom watch --check" to check all watches'));
166
+ console.log(chalk.dim('Use "broom watch --remove <path>" to remove a watch'));
167
+ }
168
+ /**
169
+ * Check watches
170
+ */
171
+ async function checkWatches() {
172
+ printHeader('👀 Checking Watches');
173
+ const watches = await loadWatchConfig();
174
+ if (watches.length === 0) {
175
+ warning('No watches configured');
176
+ return;
177
+ }
178
+ console.log();
179
+ let needsSave = false;
180
+ for (const watch of watches) {
181
+ const size = await getDirectorySize(watch.path);
182
+ const percentage = (size / watch.threshold) * 100;
183
+ const exceeded = size > watch.threshold;
184
+ console.log(chalk.bold(`📁 ${watch.path}`));
185
+ console.log(` Current size: ${chalk.cyan(formatSize(size))}`);
186
+ console.log(` Threshold: ${formatSize(watch.threshold)}`);
187
+ if (exceeded) {
188
+ console.log(chalk.red(` ⚠️ EXCEEDED (${percentage.toFixed(1)}%)`));
189
+ // Send notification if enabled
190
+ if (watch.notify) {
191
+ const now = new Date();
192
+ const hoursSinceLastNotif = watch.lastNotified
193
+ ? (now.getTime() - watch.lastNotified.getTime()) / (1000 * 60 * 60)
194
+ : 24;
195
+ // Only notify once per 6 hours
196
+ if (hoursSinceLastNotif >= 6) {
197
+ await sendNotification('Broom Alert', `${watch.path} exceeded ${formatSize(watch.threshold)}`);
198
+ watch.lastNotified = now;
199
+ needsSave = true;
200
+ console.log(chalk.dim(' 📬 Notification sent'));
201
+ }
202
+ }
203
+ // Auto-clean if enabled
204
+ if (watch.autoClean) {
205
+ console.log(chalk.dim(' 🧹 Auto-clean triggered'));
206
+ // TODO: Trigger cleanup for this path
207
+ }
208
+ }
209
+ else {
210
+ console.log(chalk.green(` ✓ OK (${percentage.toFixed(1)}%)`));
211
+ }
212
+ console.log();
213
+ }
214
+ if (needsSave) {
215
+ await saveWatchConfig(watches);
216
+ }
217
+ separator();
218
+ }
219
+ /**
220
+ * Execute watch command
221
+ */
222
+ export async function watchCommand(options) {
223
+ if (options.add) {
224
+ await addWatch(options);
225
+ return;
226
+ }
227
+ if (options.remove) {
228
+ await removeWatch(options.remove);
229
+ return;
230
+ }
231
+ if (options.check) {
232
+ await checkWatches();
233
+ return;
234
+ }
235
+ if (options.list || Object.keys(options).length === 0) {
236
+ await listWatches();
237
+ return;
238
+ }
239
+ }
240
+ /**
241
+ * Create watch command
242
+ */
243
+ export function createWatchCommand() {
244
+ const cmd = new Command('watch')
245
+ .description('Monitor directory sizes')
246
+ .option('-a, --add', 'Add a new watch')
247
+ .option('-r, --remove <path>', 'Remove a watch')
248
+ .option('-l, --list', 'List all watches')
249
+ .option('-c, --check', 'Check all watches now')
250
+ .option('-p, --path <path>', 'Path to watch')
251
+ .option('-t, --threshold <size>', 'Size threshold (e.g., 5GB)')
252
+ .option('-n, --notify', 'Enable notifications (default: true)')
253
+ .option('--auto-clean', 'Automatically clean when threshold exceeded')
254
+ .action(async (options) => {
255
+ await watchCommand(options);
256
+ });
257
+ return enhanceCommandHelp(cmd);
258
+ }
package/dist/index.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * broom - macOS Disk Cleanup CLI
4
+ *
5
+ * A TypeScript rewrite of mole (https://github.com/tw93/Mole)
6
+ * with modern features and interactive interface.
7
+ */
8
+ import { Command } from 'commander';
9
+ import chalk from 'chalk';
10
+ import { createCleanCommand, createUninstallCommand, createOptimizeCommand, createAnalyzeCommand, createStatusCommand, createPurgeCommand, createInstallerCommand, createTouchIdCommand, createCompletionCommand, createUpdateCommand, createRemoveCommand, createConfigCommand, createDoctorCommand, createBackupCommand, createRestoreCommand, createDuplicatesCommand, createScheduleCommand, createWatchCommand, createReportsCommand, createHelpCommand, setCommandsList, } from './commands/index.js';
11
+ import { enableDebug, debug } from './utils/debug.js';
12
+ import { getGlobalOptionsTable } from './utils/help.js';
13
+ const VERSION = '1.0.0';
14
+ // ASCII art logo
15
+ const logo = chalk.cyan(`
16
+ ██████╗ ██████╗ ██████╗ ██████╗ ███╗ ███╗
17
+ ██╔══██╗██╔══██╗██╔═══██╗██╔═══██╗████╗ ████║
18
+ ██████╔╝██████╔╝██║ ██║██║ ██║██╔████╔██║
19
+ ██╔══██╗██╔══██╗██║ ██║██║ ██║██║╚██╔╝██║
20
+ ██████╔╝██║ ██║╚██████╔╝╚██████╔╝██║ ╚═╝ ██║
21
+ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝
22
+ `);
23
+ const description = `
24
+ ${logo}
25
+ ${chalk.bold('🧹 macOS Disk Cleanup CLI')}
26
+
27
+ Clean up your Mac with ease. Remove caches, logs, trash,
28
+ browser data, dev artifacts, and more.
29
+
30
+ ${chalk.bold('Commands:')}
31
+ clean Scan and clean up disk space
32
+ uninstall Remove apps and their leftovers
33
+ optimize System maintenance and optimization
34
+ analyze Analyze disk space usage
35
+ status Show system status and resource usage
36
+ purge Clean project-specific build artifacts
37
+ installer Find and remove installer files
38
+ touchid Configure Touch ID for sudo
39
+ completion Generate shell completion scripts
40
+ update Self-update broom to the latest version
41
+ remove Uninstall broom from the system
42
+ config Manage broom configuration
43
+ doctor Run system health diagnostics
44
+ backup Manage file backups
45
+ restore Restore files from backup
46
+ duplicates Find and remove duplicate files
47
+ schedule Schedule automated cleanups
48
+ watch Monitor directory sizes
49
+ reports Manage cleanup reports
50
+
51
+ ${chalk.bold('Examples:')}
52
+ ${chalk.dim('$')} broom clean Interactive cleanup
53
+ ${chalk.dim('$')} broom clean --dry-run Preview what would be cleaned
54
+ ${chalk.dim('$')} broom clean --all Clean all categories
55
+ ${chalk.dim('$')} broom uninstall Remove an app completely
56
+ ${chalk.dim('$')} broom optimize Run system optimization tasks
57
+ ${chalk.dim('$')} broom analyze See what's using disk space
58
+ ${chalk.dim('$')} broom status --watch Live system monitoring
59
+ ${chalk.dim('$')} broom purge Clean project artifacts
60
+
61
+ ${getGlobalOptionsTable()}
62
+ `;
63
+ // Create program
64
+ const program = new Command();
65
+ program
66
+ .name('broom')
67
+ .version(VERSION, '-v, --version', 'Output the current version')
68
+ .description(description)
69
+ .option('--debug', 'Enable debug mode with detailed logs')
70
+ .helpOption('-h, --help', 'Display help for command')
71
+ .hook('preAction', (thisCommand) => {
72
+ const opts = thisCommand.opts();
73
+ if (opts.debug) {
74
+ enableDebug();
75
+ debug('Debug mode enabled');
76
+ debug(`Version: ${VERSION}`);
77
+ debug(`Node: ${process.version}`);
78
+ debug(`Platform: ${process.platform} ${process.arch}`);
79
+ debug(`Args: ${process.argv.slice(2).join(' ')}`);
80
+ }
81
+ });
82
+ // Register commands
83
+ const helpCommand = createHelpCommand();
84
+ program.addCommand(helpCommand);
85
+ program.addCommand(createCleanCommand());
86
+ program.addCommand(createUninstallCommand());
87
+ program.addCommand(createOptimizeCommand());
88
+ program.addCommand(createAnalyzeCommand());
89
+ program.addCommand(createStatusCommand());
90
+ program.addCommand(createPurgeCommand());
91
+ program.addCommand(createInstallerCommand());
92
+ program.addCommand(createTouchIdCommand());
93
+ program.addCommand(createCompletionCommand());
94
+ program.addCommand(createUpdateCommand());
95
+ program.addCommand(createRemoveCommand());
96
+ program.addCommand(createConfigCommand());
97
+ program.addCommand(createDoctorCommand());
98
+ program.addCommand(createBackupCommand());
99
+ program.addCommand(createRestoreCommand());
100
+ program.addCommand(createDuplicatesCommand());
101
+ program.addCommand(createScheduleCommand());
102
+ program.addCommand(createWatchCommand());
103
+ program.addCommand(createReportsCommand());
104
+ // Set the commands list for the help command
105
+ setCommandsList([
106
+ createCleanCommand(),
107
+ createUninstallCommand(),
108
+ createOptimizeCommand(),
109
+ createAnalyzeCommand(),
110
+ createStatusCommand(),
111
+ createPurgeCommand(),
112
+ createInstallerCommand(),
113
+ createTouchIdCommand(),
114
+ createCompletionCommand(),
115
+ createUpdateCommand(),
116
+ createRemoveCommand(),
117
+ createConfigCommand(),
118
+ createDoctorCommand(),
119
+ createBackupCommand(),
120
+ createRestoreCommand(),
121
+ createDuplicatesCommand(),
122
+ createScheduleCommand(),
123
+ createWatchCommand(),
124
+ createReportsCommand(),
125
+ ]);
126
+ // Parse arguments
127
+ program.parse(process.argv);
128
+ // Show help if no command provided (only if no subcommand was executed)
129
+ if (process.argv.length === 2) {
130
+ console.log(description);
131
+ }
@@ -0,0 +1,21 @@
1
+ import { removeItems } from '../utils/fs.js';
2
+ export class BaseScanner {
3
+ async clean(items, dryRun = false) {
4
+ const result = await removeItems(items, dryRun);
5
+ return {
6
+ category: this.category,
7
+ cleanedItems: result.success,
8
+ freedSpace: result.freedSpace,
9
+ errors: result.failed > 0 ? [`Failed to remove ${result.failed} items`] : [],
10
+ };
11
+ }
12
+ createResult(items, error) {
13
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
14
+ return {
15
+ category: this.category,
16
+ items,
17
+ totalSize,
18
+ error,
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Browser cache scanner
3
+ */
4
+ import { readdir, stat } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { BaseScanner } from './base.js';
7
+ import { paths } from '../utils/paths.js';
8
+ import { exists, getSize } from '../utils/fs.js';
9
+ export class BrowserCacheScanner extends BaseScanner {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.category = {
13
+ id: 'browser-cache',
14
+ name: 'Browser Cache',
15
+ group: 'Browsers',
16
+ description: 'Cache from Chrome, Safari, Firefox, Edge, Brave, Arc',
17
+ safetyLevel: 'safe',
18
+ };
19
+ this.browsers = [
20
+ {
21
+ name: 'Chrome',
22
+ paths: [paths.browserCache.chrome, paths.browserCache.chromeProfile],
23
+ },
24
+ {
25
+ name: 'Safari',
26
+ paths: [paths.browserCache.safari],
27
+ },
28
+ {
29
+ name: 'Firefox',
30
+ paths: [paths.browserCache.firefox],
31
+ },
32
+ {
33
+ name: 'Edge',
34
+ paths: [paths.browserCache.edge, paths.browserCache.edgeProfile],
35
+ },
36
+ {
37
+ name: 'Brave',
38
+ paths: [paths.browserCache.brave, paths.browserCache.braveProfile],
39
+ },
40
+ {
41
+ name: 'Arc',
42
+ paths: [paths.browserCache.arc, paths.browserCache.arcProfile],
43
+ },
44
+ ];
45
+ }
46
+ async scan(_options) {
47
+ const items = [];
48
+ for (const browser of this.browsers) {
49
+ for (const browserPath of browser.paths) {
50
+ try {
51
+ if (!exists(browserPath)) {
52
+ continue;
53
+ }
54
+ // Check if it's a directory that contains cache
55
+ const stats = await stat(browserPath);
56
+ if (stats.isDirectory()) {
57
+ const entries = await readdir(browserPath);
58
+ // Look for cache-related directories
59
+ const cachePatterns = ['Cache', 'cache', 'GPUCache', 'ShaderCache', 'Code Cache'];
60
+ for (const entry of entries) {
61
+ if (cachePatterns.some((p) => entry.includes(p)) || entry === 'Cache') {
62
+ const entryPath = join(browserPath, entry);
63
+ const entryStats = await stat(entryPath);
64
+ const size = await getSize(entryPath);
65
+ if (size > 0) {
66
+ items.push({
67
+ path: entryPath,
68
+ size,
69
+ name: `${browser.name} - ${entry}`,
70
+ isDirectory: entryStats.isDirectory(),
71
+ modifiedAt: entryStats.mtime,
72
+ });
73
+ }
74
+ }
75
+ }
76
+ // Also add the main cache directory if it's not too nested
77
+ if (browserPath.includes('Cache')) {
78
+ const size = await getSize(browserPath);
79
+ if (size > 0) {
80
+ items.push({
81
+ path: browserPath,
82
+ size,
83
+ name: `${browser.name} Cache`,
84
+ isDirectory: true,
85
+ modifiedAt: stats.mtime,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+ catch {
92
+ // Skip if cannot access
93
+ }
94
+ }
95
+ }
96
+ // Remove duplicates (prefer larger paths)
97
+ const uniqueItems = this.deduplicateItems(items);
98
+ uniqueItems.sort((a, b) => b.size - a.size);
99
+ return this.createResult(uniqueItems);
100
+ }
101
+ deduplicateItems(items) {
102
+ const seen = new Map();
103
+ for (const item of items) {
104
+ const existing = seen.get(item.path);
105
+ if (!existing || item.size > existing.size) {
106
+ seen.set(item.path, item);
107
+ }
108
+ }
109
+ return Array.from(seen.values());
110
+ }
111
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Development cache scanner
3
+ */
4
+ import { stat } from 'fs/promises';
5
+ import { BaseScanner } from './base.js';
6
+ import { paths } from '../utils/paths.js';
7
+ import { exists, getSize } from '../utils/fs.js';
8
+ export class DevCacheScanner extends BaseScanner {
9
+ constructor() {
10
+ super(...arguments);
11
+ this.category = {
12
+ id: 'dev-cache',
13
+ name: 'Development Cache',
14
+ group: 'Development',
15
+ description: 'Package manager caches (npm, yarn, pip, cargo, etc.)',
16
+ safetyLevel: 'moderate',
17
+ safetyNote: 'May require reinstalling packages',
18
+ };
19
+ this.devTools = [
20
+ { name: 'npm cache', path: paths.devCache.npm, safeToClean: true },
21
+ { name: 'npm _cacache', path: paths.devCache.npmCache, safeToClean: true },
22
+ { name: 'Yarn cache', path: paths.devCache.yarn, safeToClean: true },
23
+ { name: 'pnpm store', path: paths.devCache.pnpm, safeToClean: true },
24
+ { name: 'Bun cache', path: paths.devCache.bun, safeToClean: true },
25
+ { name: 'pip cache', path: paths.devCache.pip, safeToClean: true },
26
+ { name: 'pip cache (alt)', path: paths.devCache.pipCache, safeToClean: true },
27
+ { name: 'Cargo cache', path: paths.devCache.cargo, safeToClean: true },
28
+ { name: 'Rustup downloads', path: paths.devCache.rustup, safeToClean: true },
29
+ { name: 'Go mod cache', path: paths.devCache.go, safeToClean: true },
30
+ { name: 'Gradle caches', path: paths.devCache.gradle, safeToClean: true },
31
+ { name: 'Maven repository', path: paths.devCache.maven, safeToClean: false },
32
+ { name: 'CocoaPods cache', path: paths.devCache.cocoapods, safeToClean: true },
33
+ { name: 'Carthage cache', path: paths.devCache.carthage, safeToClean: true },
34
+ { name: 'Composer cache', path: paths.devCache.composer, safeToClean: true },
35
+ ];
36
+ }
37
+ async scan(_options) {
38
+ const items = [];
39
+ for (const tool of this.devTools) {
40
+ try {
41
+ if (!exists(tool.path)) {
42
+ continue;
43
+ }
44
+ const stats = await stat(tool.path);
45
+ const size = await getSize(tool.path);
46
+ if (size > 1024 * 1024) {
47
+ // Only include if > 1MB
48
+ items.push({
49
+ path: tool.path,
50
+ size,
51
+ name: tool.name,
52
+ isDirectory: stats.isDirectory(),
53
+ modifiedAt: stats.mtime,
54
+ });
55
+ }
56
+ }
57
+ catch {
58
+ // Skip if cannot access
59
+ }
60
+ }
61
+ items.sort((a, b) => b.size - a.size);
62
+ return this.createResult(items);
63
+ }
64
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Docker cache scanner
3
+ */
4
+ import { stat } from 'fs/promises';
5
+ import { BaseScanner } from './base.js';
6
+ import { paths } from '../utils/paths.js';
7
+ import { exists, getSize } from '../utils/fs.js';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ const execAsync = promisify(exec);
11
+ export class DockerScanner extends BaseScanner {
12
+ constructor() {
13
+ super(...arguments);
14
+ this.category = {
15
+ id: 'docker',
16
+ name: 'Docker Data',
17
+ group: 'Development',
18
+ description: 'Docker images, containers, and volumes',
19
+ safetyLevel: 'risky',
20
+ safetyNote: 'Will remove all unused Docker data',
21
+ };
22
+ }
23
+ async scan(_options) {
24
+ const items = [];
25
+ try {
26
+ // Check Docker data directory
27
+ if (exists(paths.docker.data)) {
28
+ const stats = await stat(paths.docker.data);
29
+ const size = await getSize(paths.docker.data);
30
+ if (size > 0) {
31
+ items.push({
32
+ path: paths.docker.data,
33
+ size,
34
+ name: 'Docker Data',
35
+ isDirectory: true,
36
+ modifiedAt: stats.mtime,
37
+ });
38
+ }
39
+ }
40
+ // Check VM disk specifically
41
+ if (exists(paths.docker.vmDisk)) {
42
+ const stats = await stat(paths.docker.vmDisk);
43
+ const size = await getSize(paths.docker.vmDisk);
44
+ if (size > 0) {
45
+ items.push({
46
+ path: paths.docker.vmDisk,
47
+ size,
48
+ name: 'Docker VM Disk',
49
+ isDirectory: true,
50
+ modifiedAt: stats.mtime,
51
+ });
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ // Docker might not be installed
57
+ }
58
+ items.sort((a, b) => b.size - a.size);
59
+ // Deduplicate (vmDisk is inside data)
60
+ const uniqueItems = items.filter((item, index, self) => {
61
+ return !self.some((other, otherIndex) => otherIndex !== index && item.path.startsWith(other.path + '/'));
62
+ });
63
+ return this.createResult(uniqueItems);
64
+ }
65
+ async clean(items, dryRun = false) {
66
+ if (dryRun) {
67
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
68
+ return {
69
+ category: this.category,
70
+ cleanedItems: items.length,
71
+ freedSpace: totalSize,
72
+ errors: [],
73
+ };
74
+ }
75
+ try {
76
+ // Try using docker system prune
77
+ await execAsync('docker system prune -af --volumes 2>/dev/null || true');
78
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
79
+ return {
80
+ category: this.category,
81
+ cleanedItems: items.length,
82
+ freedSpace: totalSize,
83
+ errors: [],
84
+ };
85
+ }
86
+ catch {
87
+ // Docker not running or not installed
88
+ return {
89
+ category: this.category,
90
+ cleanedItems: 0,
91
+ freedSpace: 0,
92
+ errors: ['Docker is not running or not installed'],
93
+ };
94
+ }
95
+ }
96
+ }