@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,66 @@
1
+ /**
2
+ * Downloads 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, isExcludedPath } from '../utils/fs.js';
9
+ export class DownloadsScanner extends BaseScanner {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.category = {
13
+ id: 'downloads',
14
+ name: 'Old Downloads',
15
+ group: 'Storage',
16
+ description: 'Files in Downloads folder older than 30 days',
17
+ safetyLevel: 'risky',
18
+ safetyNote: 'Review files before deleting',
19
+ };
20
+ }
21
+ async scan(options) {
22
+ const items = [];
23
+ const daysOld = options?.daysOld ?? 30;
24
+ const cutoffDate = new Date();
25
+ cutoffDate.setDate(cutoffDate.getDate() - daysOld);
26
+ try {
27
+ if (!exists(paths.downloads)) {
28
+ return this.createResult([]);
29
+ }
30
+ const entries = await readdir(paths.downloads);
31
+ for (const entry of entries) {
32
+ // Skip hidden files and .localized
33
+ if (entry.startsWith('.')) {
34
+ continue;
35
+ }
36
+ const entryPath = join(paths.downloads, entry);
37
+ // Skip excluded paths (iCloud Drive, etc.)
38
+ if (isExcludedPath(entryPath)) {
39
+ continue;
40
+ }
41
+ try {
42
+ const stats = await stat(entryPath);
43
+ // Only include files older than cutoff
44
+ if (stats.mtime < cutoffDate) {
45
+ const size = await getSize(entryPath);
46
+ items.push({
47
+ path: entryPath,
48
+ size,
49
+ name: entry,
50
+ isDirectory: stats.isDirectory(),
51
+ modifiedAt: stats.mtime,
52
+ });
53
+ }
54
+ }
55
+ catch {
56
+ // Skip if cannot access
57
+ }
58
+ }
59
+ items.sort((a, b) => b.size - a.size);
60
+ return this.createResult(items);
61
+ }
62
+ catch (error) {
63
+ return this.createResult([], error.message);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Homebrew 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, removeItems } from '../utils/fs.js';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ const execAsync = promisify(exec);
11
+ export class HomebrewScanner extends BaseScanner {
12
+ constructor() {
13
+ super(...arguments);
14
+ this.category = {
15
+ id: 'homebrew',
16
+ name: 'Homebrew Cache',
17
+ group: 'Development',
18
+ description: 'Homebrew downloads and logs',
19
+ safetyLevel: 'safe',
20
+ };
21
+ }
22
+ async scan(_options) {
23
+ const items = [];
24
+ const homebrewPaths = [paths.homebrew.cache, paths.homebrew.logs, paths.homebrew.downloads];
25
+ for (const brewPath of homebrewPaths) {
26
+ try {
27
+ if (!exists(brewPath)) {
28
+ continue;
29
+ }
30
+ const stats = await stat(brewPath);
31
+ const size = await getSize(brewPath);
32
+ if (size > 0) {
33
+ items.push({
34
+ path: brewPath,
35
+ size,
36
+ name: brewPath.includes('Logs') ? 'Homebrew Logs' : 'Homebrew Cache',
37
+ isDirectory: stats.isDirectory(),
38
+ modifiedAt: stats.mtime,
39
+ });
40
+ }
41
+ }
42
+ catch {
43
+ // Skip if cannot access
44
+ }
45
+ }
46
+ items.sort((a, b) => b.size - a.size);
47
+ return this.createResult(items);
48
+ }
49
+ async clean(items, dryRun = false) {
50
+ if (dryRun) {
51
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
52
+ return {
53
+ category: this.category,
54
+ cleanedItems: items.length,
55
+ freedSpace: totalSize,
56
+ errors: [],
57
+ };
58
+ }
59
+ try {
60
+ // Try using brew cleanup first
61
+ await execAsync('brew cleanup --prune=all 2>/dev/null || true');
62
+ // Then remove remaining items
63
+ const result = await removeItems(items, false);
64
+ return {
65
+ category: this.category,
66
+ cleanedItems: result.success,
67
+ freedSpace: result.freedSpace,
68
+ errors: result.failed > 0 ? [`Failed to remove ${result.failed} items`] : [],
69
+ };
70
+ }
71
+ catch (error) {
72
+ // Fallback to manual removal
73
+ const result = await removeItems(items, false);
74
+ return {
75
+ category: this.category,
76
+ cleanedItems: result.success,
77
+ freedSpace: result.freedSpace,
78
+ errors: result.failed > 0 ? [`Failed to remove ${result.failed} items`] : [],
79
+ };
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,126 @@
1
+ import { UserCacheScanner } from './user-cache.js';
2
+ import { UserLogsScanner } from './user-logs.js';
3
+ import { TrashScanner } from './trash.js';
4
+ import { BrowserCacheScanner } from './browser-cache.js';
5
+ import { DevCacheScanner } from './dev-cache.js';
6
+ import { XcodeScanner } from './xcode.js';
7
+ import { DownloadsScanner } from './downloads.js';
8
+ import { HomebrewScanner } from './homebrew.js';
9
+ import { DockerScanner } from './docker.js';
10
+ import { IosBackupsScanner } from './ios-backups.js';
11
+ import { TempFilesScanner } from './temp-files.js';
12
+ import { NodeModulesScanner } from './node-modules.js';
13
+ import { InstallerScanner } from './installer.js';
14
+ import pLimit from 'p-limit';
15
+ // All available scanners
16
+ const scanners = [
17
+ new UserCacheScanner(),
18
+ new UserLogsScanner(),
19
+ new TrashScanner(),
20
+ new BrowserCacheScanner(),
21
+ new DevCacheScanner(),
22
+ new XcodeScanner(),
23
+ new DownloadsScanner(),
24
+ new HomebrewScanner(),
25
+ new DockerScanner(),
26
+ new IosBackupsScanner(),
27
+ new TempFilesScanner(),
28
+ new NodeModulesScanner(),
29
+ new InstallerScanner(),
30
+ ];
31
+ /**
32
+ * Get all scanners
33
+ */
34
+ export function getAllScanners() {
35
+ return scanners;
36
+ }
37
+ /**
38
+ * Get scanner by category ID
39
+ */
40
+ export function getScanner(categoryId) {
41
+ return scanners.find((s) => s.category.id === categoryId);
42
+ }
43
+ /**
44
+ * Run all scanners
45
+ */
46
+ export async function runAllScans(options) {
47
+ const { parallel = true, concurrency = 4, onProgress } = options || {};
48
+ const results = [];
49
+ let completed = 0;
50
+ if (parallel) {
51
+ const limit = pLimit(concurrency);
52
+ const promises = scanners.map((scanner) => limit(async () => {
53
+ const result = await scanner.scan();
54
+ completed++;
55
+ onProgress?.(completed, scanners.length, scanner);
56
+ return result;
57
+ }));
58
+ const scanResults = await Promise.all(promises);
59
+ results.push(...scanResults);
60
+ }
61
+ else {
62
+ for (const scanner of scanners) {
63
+ const result = await scanner.scan();
64
+ results.push(result);
65
+ completed++;
66
+ onProgress?.(completed, scanners.length, scanner);
67
+ }
68
+ }
69
+ const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0);
70
+ const totalItems = results.reduce((sum, r) => sum + r.items.length, 0);
71
+ return {
72
+ results,
73
+ totalSize,
74
+ totalItems,
75
+ };
76
+ }
77
+ /**
78
+ * Run specific scanners
79
+ */
80
+ export async function runScans(categoryIds, options) {
81
+ const selectedScanners = scanners.filter((s) => categoryIds.includes(s.category.id));
82
+ const { parallel = true, concurrency = 4, onProgress } = options || {};
83
+ const results = [];
84
+ let completed = 0;
85
+ if (parallel) {
86
+ const limit = pLimit(concurrency);
87
+ const promises = selectedScanners.map((scanner) => limit(async () => {
88
+ const result = await scanner.scan();
89
+ completed++;
90
+ onProgress?.(completed, selectedScanners.length, scanner);
91
+ return result;
92
+ }));
93
+ const scanResults = await Promise.all(promises);
94
+ results.push(...scanResults);
95
+ }
96
+ else {
97
+ for (const scanner of selectedScanners) {
98
+ const result = await scanner.scan();
99
+ results.push(result);
100
+ completed++;
101
+ onProgress?.(completed, selectedScanners.length, scanner);
102
+ }
103
+ }
104
+ const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0);
105
+ const totalItems = results.reduce((sum, r) => sum + r.items.length, 0);
106
+ return {
107
+ results,
108
+ totalSize,
109
+ totalItems,
110
+ };
111
+ }
112
+ // Export individual scanners
113
+ export { UserCacheScanner } from './user-cache.js';
114
+ export { UserLogsScanner } from './user-logs.js';
115
+ export { TrashScanner } from './trash.js';
116
+ export { BrowserCacheScanner } from './browser-cache.js';
117
+ export { DevCacheScanner } from './dev-cache.js';
118
+ export { XcodeScanner } from './xcode.js';
119
+ export { DownloadsScanner } from './downloads.js';
120
+ export { HomebrewScanner } from './homebrew.js';
121
+ export { DockerScanner } from './docker.js';
122
+ export { IosBackupsScanner } from './ios-backups.js';
123
+ export { TempFilesScanner } from './temp-files.js';
124
+ export { NodeModulesScanner } from './node-modules.js';
125
+ export { InstallerScanner } from './installer.js';
126
+ export { BaseScanner } from './base.js';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Installer files scanner
3
+ */
4
+ import { readdir, stat } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { BaseScanner } from './base.js';
8
+ import { exists } from '../utils/fs.js';
9
+ export class InstallerScanner extends BaseScanner {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.category = {
13
+ id: 'app-data',
14
+ name: 'Installer Files',
15
+ group: 'Storage',
16
+ description: '.dmg, .pkg, .iso, .xip, .zip files',
17
+ safetyLevel: 'moderate',
18
+ safetyNote: 'Installer files that may no longer be needed',
19
+ };
20
+ this.searchPaths = [
21
+ join(homedir(), 'Downloads'),
22
+ join(homedir(), 'Desktop'),
23
+ join(homedir(), 'Documents'),
24
+ ];
25
+ this.installerExtensions = ['.dmg', '.pkg', '.mpkg', '.iso', '.xip'];
26
+ }
27
+ async scan(_options) {
28
+ const items = [];
29
+ for (const searchPath of this.searchPaths) {
30
+ try {
31
+ if (!exists(searchPath)) {
32
+ continue;
33
+ }
34
+ await this.scanDirectory(searchPath, items, 2);
35
+ }
36
+ catch {
37
+ // Skip if cannot access
38
+ }
39
+ }
40
+ items.sort((a, b) => b.size - a.size);
41
+ return this.createResult(items);
42
+ }
43
+ async scanDirectory(dirPath, items, depth) {
44
+ if (depth <= 0) {
45
+ return;
46
+ }
47
+ try {
48
+ const entries = await readdir(dirPath);
49
+ for (const entry of entries) {
50
+ // Skip hidden files
51
+ if (entry.startsWith('.')) {
52
+ continue;
53
+ }
54
+ const entryPath = join(dirPath, entry);
55
+ try {
56
+ const stats = await stat(entryPath);
57
+ if (stats.isDirectory()) {
58
+ await this.scanDirectory(entryPath, items, depth - 1);
59
+ }
60
+ else if (this.isInstallerFile(entry)) {
61
+ const size = stats.size;
62
+ if (size > 1024 * 1024) {
63
+ // > 1MB
64
+ items.push({
65
+ path: entryPath,
66
+ size,
67
+ name: entry,
68
+ isDirectory: false,
69
+ modifiedAt: stats.mtime,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // Skip if cannot access
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ // Skip if cannot access
81
+ }
82
+ }
83
+ isInstallerFile(filename) {
84
+ const lowerName = filename.toLowerCase();
85
+ return this.installerExtensions.some((ext) => lowerName.endsWith(ext));
86
+ }
87
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * iOS Backups scanner
3
+ */
4
+ import { readdir, stat, readFile } 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 IosBackupsScanner extends BaseScanner {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.category = {
13
+ id: 'ios-backups',
14
+ name: 'iOS Backups',
15
+ group: 'Storage',
16
+ description: 'Local iPhone/iPad backup files',
17
+ safetyLevel: 'risky',
18
+ safetyNote: 'Backup data cannot be recovered after deletion',
19
+ };
20
+ }
21
+ async scan(_options) {
22
+ const items = [];
23
+ try {
24
+ if (!exists(paths.iosBackups)) {
25
+ return this.createResult([]);
26
+ }
27
+ const entries = await readdir(paths.iosBackups);
28
+ for (const entry of entries) {
29
+ // Skip .DS_Store
30
+ if (entry.startsWith('.')) {
31
+ continue;
32
+ }
33
+ const backupPath = join(paths.iosBackups, entry);
34
+ try {
35
+ const stats = await stat(backupPath);
36
+ if (!stats.isDirectory()) {
37
+ continue;
38
+ }
39
+ const size = await getSize(backupPath);
40
+ const info = await this.getBackupInfo(backupPath);
41
+ const name = info.deviceName
42
+ ? `${info.deviceName} Backup`
43
+ : `iOS Backup (${entry.substring(0, 8)})`;
44
+ items.push({
45
+ path: backupPath,
46
+ size,
47
+ name,
48
+ isDirectory: true,
49
+ modifiedAt: info.lastBackup || stats.mtime,
50
+ });
51
+ }
52
+ catch {
53
+ // Skip if cannot access
54
+ }
55
+ }
56
+ items.sort((a, b) => b.size - a.size);
57
+ return this.createResult(items);
58
+ }
59
+ catch (error) {
60
+ return this.createResult([], error.message);
61
+ }
62
+ }
63
+ async getBackupInfo(backupPath) {
64
+ try {
65
+ const infoPath = join(backupPath, 'Info.plist');
66
+ if (!exists(infoPath)) {
67
+ return {};
68
+ }
69
+ const content = await readFile(infoPath, 'utf-8');
70
+ // Parse plist to get device name
71
+ const nameMatch = content.match(/<key>Device Name<\/key>\s*<string>([^<]+)<\/string>/);
72
+ const dateMatch = content.match(/<key>Last Backup Date<\/key>\s*<date>([^<]+)<\/date>/);
73
+ return {
74
+ deviceName: nameMatch?.[1],
75
+ lastBackup: dateMatch?.[1] ? new Date(dateMatch[1]) : undefined,
76
+ };
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Node modules scanner (project artifacts)
3
+ */
4
+ import { stat, access } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { BaseScanner } from './base.js';
8
+ import { getSize } from '../utils/fs.js';
9
+ import fg from 'fast-glob';
10
+ export class NodeModulesScanner extends BaseScanner {
11
+ constructor() {
12
+ super(...arguments);
13
+ this.category = {
14
+ id: 'node-modules',
15
+ name: 'Node Modules',
16
+ group: 'Development',
17
+ description: 'node_modules directories in projects',
18
+ safetyLevel: 'moderate',
19
+ safetyNote: 'Can be reinstalled with npm/yarn/pnpm install',
20
+ };
21
+ this.defaultSearchPaths = [
22
+ join(homedir(), 'Projects'),
23
+ join(homedir(), 'Developer'),
24
+ join(homedir(), 'Code'),
25
+ join(homedir(), 'Documents'),
26
+ join(homedir(), 'Desktop'),
27
+ ];
28
+ }
29
+ async scan(options) {
30
+ const items = [];
31
+ const minSize = options?.minSize ?? 10 * 1024 * 1024; // 10MB default
32
+ // Find all node_modules directories
33
+ for (const searchPath of this.defaultSearchPaths) {
34
+ try {
35
+ await access(searchPath);
36
+ // Use fast-glob to find node_modules
37
+ const nodeModulesDirs = await fg('**/node_modules', {
38
+ cwd: searchPath,
39
+ onlyDirectories: true,
40
+ deep: 5,
41
+ followSymbolicLinks: false,
42
+ ignore: ['**/node_modules/**/node_modules'], // Don't scan nested node_modules
43
+ });
44
+ for (const relPath of nodeModulesDirs) {
45
+ const fullPath = join(searchPath, relPath);
46
+ try {
47
+ const stats = await stat(fullPath);
48
+ const size = await getSize(fullPath);
49
+ if (size >= minSize) {
50
+ // Get project name from parent directory
51
+ const projectPath = fullPath.replace('/node_modules', '');
52
+ const projectName = projectPath.split('/').pop() || 'Unknown';
53
+ items.push({
54
+ path: fullPath,
55
+ size,
56
+ name: `${projectName}/node_modules`,
57
+ isDirectory: true,
58
+ modifiedAt: stats.mtime,
59
+ });
60
+ }
61
+ }
62
+ catch {
63
+ // Skip if cannot access
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ // Search path doesn't exist
69
+ }
70
+ }
71
+ items.sort((a, b) => b.size - a.size);
72
+ // Limit to top 100 to avoid overwhelming the user
73
+ return this.createResult(items.slice(0, 100));
74
+ }
75
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Temporary files 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 TempFilesScanner extends BaseScanner {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.category = {
13
+ id: 'temp-files',
14
+ name: 'Temporary Files',
15
+ group: 'System Junk',
16
+ description: 'System and user temporary files',
17
+ safetyLevel: 'safe',
18
+ };
19
+ this.tempPaths = Object.values(paths.tempFiles);
20
+ }
21
+ async scan(_options) {
22
+ const items = [];
23
+ for (const tempPath of this.tempPaths) {
24
+ try {
25
+ if (!exists(tempPath)) {
26
+ continue;
27
+ }
28
+ const entries = await readdir(tempPath);
29
+ for (const entry of entries) {
30
+ // Skip important system files
31
+ if (entry.startsWith('.') || entry === 'com.apple.launchd') {
32
+ continue;
33
+ }
34
+ const entryPath = join(tempPath, entry);
35
+ try {
36
+ const stats = await stat(entryPath);
37
+ // Only include files older than 1 day
38
+ const oneDayAgo = new Date();
39
+ oneDayAgo.setDate(oneDayAgo.getDate() - 1);
40
+ if (stats.mtime < oneDayAgo) {
41
+ const size = await getSize(entryPath);
42
+ if (size > 0) {
43
+ items.push({
44
+ path: entryPath,
45
+ size,
46
+ name: entry,
47
+ isDirectory: stats.isDirectory(),
48
+ modifiedAt: stats.mtime,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ catch {
54
+ // Skip if cannot access
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // Skip if cannot access directory
60
+ }
61
+ }
62
+ items.sort((a, b) => b.size - a.size);
63
+ return this.createResult(items);
64
+ }
65
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Trash 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, removeItems } from '../utils/fs.js';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ const execAsync = promisify(exec);
12
+ export class TrashScanner extends BaseScanner {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.category = {
16
+ id: 'trash',
17
+ name: 'Trash',
18
+ group: 'Storage',
19
+ description: 'Files in Trash',
20
+ safetyLevel: 'safe',
21
+ };
22
+ }
23
+ async scan(_options) {
24
+ const items = [];
25
+ try {
26
+ if (!exists(paths.trash)) {
27
+ return this.createResult([]);
28
+ }
29
+ const entries = await readdir(paths.trash);
30
+ for (const entry of entries) {
31
+ // Skip .DS_Store
32
+ if (entry === '.DS_Store') {
33
+ continue;
34
+ }
35
+ const entryPath = join(paths.trash, entry);
36
+ try {
37
+ const stats = await stat(entryPath);
38
+ const size = await getSize(entryPath);
39
+ items.push({
40
+ path: entryPath,
41
+ size,
42
+ name: entry,
43
+ isDirectory: stats.isDirectory(),
44
+ modifiedAt: stats.mtime,
45
+ });
46
+ }
47
+ catch {
48
+ // Skip if cannot access
49
+ }
50
+ }
51
+ items.sort((a, b) => b.size - a.size);
52
+ return this.createResult(items);
53
+ }
54
+ catch (error) {
55
+ return this.createResult([], error.message);
56
+ }
57
+ }
58
+ async clean(items, dryRun = false) {
59
+ if (dryRun) {
60
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
61
+ return {
62
+ category: this.category,
63
+ cleanedItems: items.length,
64
+ freedSpace: totalSize,
65
+ errors: [],
66
+ };
67
+ }
68
+ try {
69
+ // Use AppleScript to empty trash properly
70
+ await execAsync('osascript -e \'tell application "Finder" to empty trash\'');
71
+ const totalSize = items.reduce((sum, item) => sum + item.size, 0);
72
+ return {
73
+ category: this.category,
74
+ cleanedItems: items.length,
75
+ freedSpace: totalSize,
76
+ errors: [],
77
+ };
78
+ }
79
+ catch (error) {
80
+ // Fallback to manual removal
81
+ const result = await removeItems(items, dryRun);
82
+ return {
83
+ category: this.category,
84
+ cleanedItems: result.success,
85
+ freedSpace: result.freedSpace,
86
+ errors: result.failed > 0 ? [`Failed to remove ${result.failed} items`] : [],
87
+ };
88
+ }
89
+ }
90
+ }