@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/commands/analyze.js +371 -0
- package/dist/commands/backup.js +257 -0
- package/dist/commands/clean.js +255 -0
- package/dist/commands/completion.js +714 -0
- package/dist/commands/config.js +474 -0
- package/dist/commands/doctor.js +280 -0
- package/dist/commands/duplicates.js +325 -0
- package/dist/commands/help.js +34 -0
- package/dist/commands/index.js +22 -0
- package/dist/commands/installer.js +266 -0
- package/dist/commands/optimize.js +270 -0
- package/dist/commands/purge.js +271 -0
- package/dist/commands/remove.js +184 -0
- package/dist/commands/reports.js +173 -0
- package/dist/commands/schedule.js +249 -0
- package/dist/commands/status.js +468 -0
- package/dist/commands/touchid.js +230 -0
- package/dist/commands/uninstall.js +336 -0
- package/dist/commands/update.js +182 -0
- package/dist/commands/watch.js +258 -0
- package/dist/index.js +131 -0
- package/dist/scanners/base.js +21 -0
- package/dist/scanners/browser-cache.js +111 -0
- package/dist/scanners/dev-cache.js +64 -0
- package/dist/scanners/docker.js +96 -0
- package/dist/scanners/downloads.js +66 -0
- package/dist/scanners/homebrew.js +82 -0
- package/dist/scanners/index.js +126 -0
- package/dist/scanners/installer.js +87 -0
- package/dist/scanners/ios-backups.js +82 -0
- package/dist/scanners/node-modules.js +75 -0
- package/dist/scanners/temp-files.js +65 -0
- package/dist/scanners/trash.js +90 -0
- package/dist/scanners/user-cache.js +62 -0
- package/dist/scanners/user-logs.js +53 -0
- package/dist/scanners/xcode.js +124 -0
- package/dist/types/index.js +23 -0
- package/dist/ui/index.js +5 -0
- package/dist/ui/monitors.js +345 -0
- package/dist/ui/output.js +304 -0
- package/dist/ui/prompts.js +270 -0
- package/dist/utils/config.js +133 -0
- package/dist/utils/debug.js +119 -0
- package/dist/utils/fs.js +283 -0
- package/dist/utils/help.js +265 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/paths.js +142 -0
- package/dist/utils/report.js +404 -0
- 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
|
+
}
|