@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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor command - System health diagnostics
|
|
3
|
+
*/
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { enhanceCommandHelp } from '../utils/help.js';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { expandPath, exists, formatSize } from '../utils/fs.js';
|
|
9
|
+
import { printHeader, separator, warning, success, error } from '../ui/output.js';
|
|
10
|
+
/**
|
|
11
|
+
* Check disk usage
|
|
12
|
+
*/
|
|
13
|
+
async function checkDiskUsage() {
|
|
14
|
+
try {
|
|
15
|
+
const output = execSync("df -k / | tail -1 | awk '{print $2, $3, $4, $5}'").toString().trim();
|
|
16
|
+
const [total, used, free, percentStr] = output.split(' ');
|
|
17
|
+
const percent = parseInt(percentStr);
|
|
18
|
+
let status = 'healthy';
|
|
19
|
+
let message = `Disk usage: ${percent}%`;
|
|
20
|
+
let recommendation;
|
|
21
|
+
if (percent >= 90) {
|
|
22
|
+
status = 'critical';
|
|
23
|
+
recommendation = 'Critically low disk space! Run "broom clean" immediately';
|
|
24
|
+
}
|
|
25
|
+
else if (percent >= 80) {
|
|
26
|
+
status = 'warning';
|
|
27
|
+
recommendation = 'Disk space running low. Consider cleaning up';
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
name: 'Disk Usage',
|
|
31
|
+
status,
|
|
32
|
+
message,
|
|
33
|
+
recommendation,
|
|
34
|
+
value: `${formatSize(parseInt(used) * 1024)} / ${formatSize(parseInt(total) * 1024)}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {
|
|
39
|
+
name: 'Disk Usage',
|
|
40
|
+
status: 'warning',
|
|
41
|
+
message: 'Unable to check disk usage',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find large files (>1GB)
|
|
47
|
+
*/
|
|
48
|
+
async function checkLargeFiles() {
|
|
49
|
+
try {
|
|
50
|
+
const homePath = expandPath('~');
|
|
51
|
+
const output = execSync(`find "${homePath}" -type f -size +1G 2>/dev/null | head -20`).toString();
|
|
52
|
+
const files = output
|
|
53
|
+
.trim()
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter((f) => f);
|
|
56
|
+
let status = 'healthy';
|
|
57
|
+
let message = 'No large files found';
|
|
58
|
+
let recommendation;
|
|
59
|
+
if (files.length > 0) {
|
|
60
|
+
status = files.length > 10 ? 'warning' : 'healthy';
|
|
61
|
+
message = `${files.length} files larger than 1GB`;
|
|
62
|
+
if (files.length > 10) {
|
|
63
|
+
recommendation = 'Review and clean up large files';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
name: 'Large Files',
|
|
68
|
+
status,
|
|
69
|
+
message,
|
|
70
|
+
recommendation,
|
|
71
|
+
value: files.length > 0 ? `${files.length} files` : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {
|
|
76
|
+
name: 'Large Files',
|
|
77
|
+
status: 'healthy',
|
|
78
|
+
message: 'No large files detected',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check for broken symlinks
|
|
84
|
+
*/
|
|
85
|
+
async function checkBrokenSymlinks() {
|
|
86
|
+
try {
|
|
87
|
+
const homePath = expandPath('~');
|
|
88
|
+
const output = execSync(`find "${homePath}" -type l ! -exec test -e {} \\; -print 2>/dev/null | head -50`).toString();
|
|
89
|
+
const brokenLinks = output
|
|
90
|
+
.trim()
|
|
91
|
+
.split('\n')
|
|
92
|
+
.filter((f) => f);
|
|
93
|
+
let status = 'healthy';
|
|
94
|
+
let message = 'No broken symlinks';
|
|
95
|
+
let recommendation;
|
|
96
|
+
if (brokenLinks.length > 0) {
|
|
97
|
+
status = brokenLinks.length > 10 ? 'warning' : 'healthy';
|
|
98
|
+
message = `${brokenLinks.length} broken symlinks detected`;
|
|
99
|
+
if (brokenLinks.length > 10) {
|
|
100
|
+
recommendation = 'Clean up broken symlinks';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
name: 'Broken Symlinks',
|
|
105
|
+
status,
|
|
106
|
+
message,
|
|
107
|
+
recommendation,
|
|
108
|
+
value: brokenLinks.length > 0 ? `${brokenLinks.length} links` : undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {
|
|
113
|
+
name: 'Broken Symlinks',
|
|
114
|
+
status: 'healthy',
|
|
115
|
+
message: 'No broken symlinks found',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check for old files (not accessed in 1 year)
|
|
121
|
+
*/
|
|
122
|
+
async function checkOldFiles() {
|
|
123
|
+
try {
|
|
124
|
+
const homePath = expandPath('~');
|
|
125
|
+
const output = execSync(`find "${homePath}/Downloads" "${homePath}/Documents" -type f -atime +365 2>/dev/null | head -100`).toString();
|
|
126
|
+
const oldFiles = output
|
|
127
|
+
.trim()
|
|
128
|
+
.split('\n')
|
|
129
|
+
.filter((f) => f);
|
|
130
|
+
let status = 'healthy';
|
|
131
|
+
let message = 'No old files found';
|
|
132
|
+
let recommendation;
|
|
133
|
+
if (oldFiles.length > 0) {
|
|
134
|
+
status = oldFiles.length > 50 ? 'warning' : 'healthy';
|
|
135
|
+
message = `${oldFiles.length}+ files not accessed in 1 year`;
|
|
136
|
+
if (oldFiles.length > 50) {
|
|
137
|
+
recommendation = 'Consider archiving or removing old files';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
name: 'Old Files',
|
|
142
|
+
status,
|
|
143
|
+
message,
|
|
144
|
+
recommendation,
|
|
145
|
+
value: oldFiles.length > 0 ? `${oldFiles.length}+ files` : undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return {
|
|
150
|
+
name: 'Old Files',
|
|
151
|
+
status: 'healthy',
|
|
152
|
+
message: 'No old files detected',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Check common cache directories
|
|
158
|
+
*/
|
|
159
|
+
async function checkCacheSizes() {
|
|
160
|
+
const cachePaths = [
|
|
161
|
+
expandPath('~/Library/Caches'),
|
|
162
|
+
expandPath('~/Library/Logs'),
|
|
163
|
+
expandPath('~/.npm'),
|
|
164
|
+
expandPath('~/.cache'),
|
|
165
|
+
];
|
|
166
|
+
let totalSize = 0;
|
|
167
|
+
for (const path of cachePaths) {
|
|
168
|
+
if (exists(path)) {
|
|
169
|
+
try {
|
|
170
|
+
const output = execSync(`du -sk "${path}" 2>/dev/null`).toString();
|
|
171
|
+
const size = parseInt(output.split('\t')[0]) * 1024;
|
|
172
|
+
totalSize += size;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Skip if cannot read
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let status = 'healthy';
|
|
180
|
+
let message = 'Cache size is reasonable';
|
|
181
|
+
let recommendation;
|
|
182
|
+
const GB = 1024 * 1024 * 1024;
|
|
183
|
+
if (totalSize > 10 * GB) {
|
|
184
|
+
status = 'warning';
|
|
185
|
+
message = `Large cache detected: ${formatSize(totalSize)}`;
|
|
186
|
+
recommendation = 'Run "broom clean" to free up cache space';
|
|
187
|
+
}
|
|
188
|
+
else if (totalSize > 5 * GB) {
|
|
189
|
+
status = 'healthy';
|
|
190
|
+
message = `Cache size: ${formatSize(totalSize)}`;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
name: 'Cache Size',
|
|
194
|
+
status,
|
|
195
|
+
message,
|
|
196
|
+
recommendation,
|
|
197
|
+
value: formatSize(totalSize),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get status icon
|
|
202
|
+
*/
|
|
203
|
+
function getStatusIcon(status) {
|
|
204
|
+
switch (status) {
|
|
205
|
+
case 'healthy':
|
|
206
|
+
return chalk.green('✓');
|
|
207
|
+
case 'warning':
|
|
208
|
+
return chalk.yellow('⚠️ ');
|
|
209
|
+
case 'critical':
|
|
210
|
+
return chalk.red('❌');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Execute doctor command
|
|
215
|
+
*/
|
|
216
|
+
export async function doctorCommand(options) {
|
|
217
|
+
printHeader('🏥 System Health Check');
|
|
218
|
+
console.log(chalk.dim('Running diagnostics...'));
|
|
219
|
+
console.log();
|
|
220
|
+
// Run all health checks
|
|
221
|
+
const checks = await Promise.all([
|
|
222
|
+
checkDiskUsage(),
|
|
223
|
+
checkCacheSizes(),
|
|
224
|
+
checkLargeFiles(),
|
|
225
|
+
checkBrokenSymlinks(),
|
|
226
|
+
checkOldFiles(),
|
|
227
|
+
]);
|
|
228
|
+
// Display results
|
|
229
|
+
for (const check of checks) {
|
|
230
|
+
const icon = getStatusIcon(check.status);
|
|
231
|
+
const statusColor = check.status === 'healthy'
|
|
232
|
+
? chalk.green
|
|
233
|
+
: check.status === 'warning'
|
|
234
|
+
? chalk.yellow
|
|
235
|
+
: chalk.red;
|
|
236
|
+
console.log(`${icon} ${chalk.bold(check.name)}: ${check.message}`);
|
|
237
|
+
if (check.value && options.verbose) {
|
|
238
|
+
console.log(chalk.dim(` Value: ${check.value}`));
|
|
239
|
+
}
|
|
240
|
+
if (check.recommendation) {
|
|
241
|
+
console.log(chalk.dim(` → ${check.recommendation}`));
|
|
242
|
+
}
|
|
243
|
+
console.log();
|
|
244
|
+
}
|
|
245
|
+
// Summary
|
|
246
|
+
separator();
|
|
247
|
+
console.log();
|
|
248
|
+
const healthyCount = checks.filter((c) => c.status === 'healthy').length;
|
|
249
|
+
const warningCount = checks.filter((c) => c.status === 'warning').length;
|
|
250
|
+
const criticalCount = checks.filter((c) => c.status === 'critical').length;
|
|
251
|
+
console.log(chalk.bold('📊 Summary:'));
|
|
252
|
+
console.log(` ${chalk.green('●')} Healthy: ${healthyCount}`);
|
|
253
|
+
console.log(` ${chalk.yellow('●')} Warning: ${warningCount}`);
|
|
254
|
+
console.log(` ${chalk.red('●')} Critical: ${criticalCount}`);
|
|
255
|
+
console.log();
|
|
256
|
+
if (criticalCount > 0) {
|
|
257
|
+
error('Critical issues detected! Please take action immediately.');
|
|
258
|
+
}
|
|
259
|
+
else if (warningCount > 0) {
|
|
260
|
+
warning('Some issues detected. Consider cleaning up.');
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
success('Your system looks healthy!');
|
|
264
|
+
}
|
|
265
|
+
console.log();
|
|
266
|
+
console.log(chalk.dim('Tip: Run "broom clean" to free up space'));
|
|
267
|
+
console.log(chalk.dim(' Run "broom doctor --verbose" for detailed information'));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Create doctor command
|
|
271
|
+
*/
|
|
272
|
+
export function createDoctorCommand() {
|
|
273
|
+
const cmd = new Command('doctor')
|
|
274
|
+
.description('Run system health diagnostics')
|
|
275
|
+
.option('-v, --verbose', 'Show detailed information')
|
|
276
|
+
.action(async (options) => {
|
|
277
|
+
await doctorCommand(options);
|
|
278
|
+
});
|
|
279
|
+
return enhanceCommandHelp(cmd);
|
|
280
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicates command - Find and remove duplicate files
|
|
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, createSpinner, succeedSpinner, } from '../ui/output.js';
|
|
9
|
+
import { readdir, stat, unlink } from 'fs/promises';
|
|
10
|
+
import { join, relative } from 'path';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { createReadStream } from 'fs';
|
|
13
|
+
/**
|
|
14
|
+
* Parse size string (e.g., "1MB", "500KB", "2GB")
|
|
15
|
+
*/
|
|
16
|
+
function parseSize(sizeStr) {
|
|
17
|
+
const match = sizeStr.match(/^(\d+(?:\.\d+)?)(KB|MB|GB)?$/i);
|
|
18
|
+
if (!match) {
|
|
19
|
+
throw new Error(`Invalid size format: ${sizeStr}`);
|
|
20
|
+
}
|
|
21
|
+
const value = parseFloat(match[1]);
|
|
22
|
+
const unit = (match[2] || 'B').toUpperCase();
|
|
23
|
+
switch (unit) {
|
|
24
|
+
case 'KB':
|
|
25
|
+
return value * 1024;
|
|
26
|
+
case 'MB':
|
|
27
|
+
return value * 1024 * 1024;
|
|
28
|
+
case 'GB':
|
|
29
|
+
return value * 1024 * 1024 * 1024;
|
|
30
|
+
default:
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Calculate file hash
|
|
36
|
+
*/
|
|
37
|
+
async function calculateFileHash(filePath, algorithm = 'sha256') {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const hash = createHash(algorithm);
|
|
40
|
+
const stream = createReadStream(filePath);
|
|
41
|
+
stream.on('data', (data) => hash.update(data));
|
|
42
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
43
|
+
stream.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Optimized hash calculation for large files
|
|
48
|
+
*/
|
|
49
|
+
async function calculateSmartHash(filePath, fileSize) {
|
|
50
|
+
// For small files (<1MB), use full hash
|
|
51
|
+
if (fileSize < 1024 * 1024) {
|
|
52
|
+
return await calculateFileHash(filePath, 'sha256');
|
|
53
|
+
}
|
|
54
|
+
// For large files, use size + partial hash
|
|
55
|
+
const partialHash = await calculatePartialHash(filePath);
|
|
56
|
+
return `${fileSize}:${partialHash}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Calculate partial hash (first and last 64KB)
|
|
60
|
+
*/
|
|
61
|
+
async function calculatePartialHash(filePath) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const hash = createHash('sha256');
|
|
64
|
+
const stream = createReadStream(filePath, {
|
|
65
|
+
start: 0,
|
|
66
|
+
end: 64 * 1024, // First 64KB
|
|
67
|
+
});
|
|
68
|
+
stream.on('data', (data) => hash.update(data));
|
|
69
|
+
stream.on('end', () => {
|
|
70
|
+
// Also read last 64KB
|
|
71
|
+
const endStream = createReadStream(filePath, {
|
|
72
|
+
start: -64 * 1024,
|
|
73
|
+
});
|
|
74
|
+
endStream.on('data', (data) => hash.update(data));
|
|
75
|
+
endStream.on('end', () => resolve(hash.digest('hex')));
|
|
76
|
+
endStream.on('error', reject);
|
|
77
|
+
});
|
|
78
|
+
stream.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get all files recursively
|
|
83
|
+
*/
|
|
84
|
+
async function getAllFiles(dirPath, minSize = 0, files = []) {
|
|
85
|
+
try {
|
|
86
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const fullPath = join(dirPath, entry.name);
|
|
89
|
+
// Skip hidden files and system directories
|
|
90
|
+
if (entry.name.startsWith('.'))
|
|
91
|
+
continue;
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
await getAllFiles(fullPath, minSize, files);
|
|
94
|
+
}
|
|
95
|
+
else if (entry.isFile()) {
|
|
96
|
+
const stats = await stat(fullPath);
|
|
97
|
+
if (stats.size >= minSize) {
|
|
98
|
+
files.push(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Skip directories we cannot read
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find duplicates
|
|
110
|
+
*/
|
|
111
|
+
async function findDuplicates(paths, minSize = 0, hashAlgorithm = 'sha256') {
|
|
112
|
+
const spinner = createSpinner('Scanning files...');
|
|
113
|
+
// Get all files
|
|
114
|
+
const allFiles = [];
|
|
115
|
+
for (const path of paths) {
|
|
116
|
+
await getAllFiles(path, minSize, allFiles);
|
|
117
|
+
}
|
|
118
|
+
succeedSpinner(spinner, `Found ${allFiles.length} files`);
|
|
119
|
+
// Group by size first (optimization)
|
|
120
|
+
const sizeGroups = new Map();
|
|
121
|
+
for (const file of allFiles) {
|
|
122
|
+
const stats = await stat(file);
|
|
123
|
+
const size = stats.size;
|
|
124
|
+
if (!sizeGroups.has(size)) {
|
|
125
|
+
sizeGroups.set(size, []);
|
|
126
|
+
}
|
|
127
|
+
sizeGroups.get(size).push(file);
|
|
128
|
+
}
|
|
129
|
+
// Only hash files with duplicate sizes
|
|
130
|
+
const hashSpinner = createSpinner('Calculating hashes...');
|
|
131
|
+
const hashMap = new Map();
|
|
132
|
+
let processed = 0;
|
|
133
|
+
for (const [size, files] of sizeGroups.entries()) {
|
|
134
|
+
if (files.length > 1) {
|
|
135
|
+
// Multiple files with same size - need to hash
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const hash = await calculateSmartHash(file, size);
|
|
138
|
+
if (!hashMap.has(hash)) {
|
|
139
|
+
hashMap.set(hash, []);
|
|
140
|
+
}
|
|
141
|
+
hashMap.get(hash).push(file);
|
|
142
|
+
processed++;
|
|
143
|
+
if (processed % 10 === 0) {
|
|
144
|
+
hashSpinner.text = `Hashing files... (${processed}/${files.length * sizeGroups.size})`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
succeedSpinner(hashSpinner, `Hashed ${processed} files`);
|
|
150
|
+
// Return only groups with duplicates
|
|
151
|
+
return new Map([...hashMap.entries()].filter(([_, files]) => files.length > 1));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Interactive duplicate handler
|
|
155
|
+
*/
|
|
156
|
+
async function handleDuplicatesInteractive(duplicates) {
|
|
157
|
+
const { select } = await import('../ui/prompts.js');
|
|
158
|
+
const cwd = process.cwd();
|
|
159
|
+
let groupIndex = 1;
|
|
160
|
+
for (const [hash, files] of duplicates.entries()) {
|
|
161
|
+
console.log();
|
|
162
|
+
console.log(chalk.bold(`Group ${groupIndex} (${files.length} files, ${formatSize((await stat(files[0])).size)} each):`));
|
|
163
|
+
console.log();
|
|
164
|
+
for (let i = 0; i < files.length; i++) {
|
|
165
|
+
const file = files[i];
|
|
166
|
+
const stats = await stat(file);
|
|
167
|
+
const relPath = relative(cwd, file);
|
|
168
|
+
const modTime = new Date(stats.mtime).toLocaleString('ja-JP');
|
|
169
|
+
// Create clickable file link (Cmd+Click in terminal opens in Finder)
|
|
170
|
+
console.log(` ${i + 1}. ${chalk.cyan.underline(`file://${file}`)}`);
|
|
171
|
+
console.log(` ${chalk.dim(`└─ ${relPath}`)}`);
|
|
172
|
+
console.log(` ${chalk.dim(` Modified: ${modTime}`)}`);
|
|
173
|
+
}
|
|
174
|
+
console.log();
|
|
175
|
+
const action = await select({
|
|
176
|
+
message: 'What would you like to do?',
|
|
177
|
+
choices: [
|
|
178
|
+
{ name: 'Keep first, delete others', value: 'delete-others' },
|
|
179
|
+
{ name: 'Keep last, delete others', value: 'delete-except-last' },
|
|
180
|
+
{ name: 'Skip this group', value: 'skip' },
|
|
181
|
+
{ name: 'Hardlink duplicates', value: 'hardlink' },
|
|
182
|
+
{ name: 'Exit', value: 'exit' },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
if (action === 'exit') {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (action === 'skip') {
|
|
189
|
+
groupIndex++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (action === 'delete-others') {
|
|
193
|
+
for (let i = 1; i < files.length; i++) {
|
|
194
|
+
await unlink(files[i]);
|
|
195
|
+
console.log(chalk.dim(` Deleted: ${files[i]}`));
|
|
196
|
+
}
|
|
197
|
+
success(`Kept: ${files[0]}`);
|
|
198
|
+
}
|
|
199
|
+
else if (action === 'delete-except-last') {
|
|
200
|
+
for (let i = 0; i < files.length - 1; i++) {
|
|
201
|
+
await unlink(files[i]);
|
|
202
|
+
console.log(chalk.dim(` Deleted: ${files[i]}`));
|
|
203
|
+
}
|
|
204
|
+
success(`Kept: ${files[files.length - 1]}`);
|
|
205
|
+
}
|
|
206
|
+
else if (action === 'hardlink') {
|
|
207
|
+
// Keep first, hardlink others
|
|
208
|
+
for (let i = 1; i < files.length; i++) {
|
|
209
|
+
await unlink(files[i]);
|
|
210
|
+
const { execSync } = await import('child_process');
|
|
211
|
+
execSync(`ln "${files[0]}" "${files[i]}"`);
|
|
212
|
+
console.log(chalk.dim(` Hardlinked: ${files[i]} -> ${files[0]}`));
|
|
213
|
+
}
|
|
214
|
+
success('Created hardlinks');
|
|
215
|
+
}
|
|
216
|
+
groupIndex++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Validate and get scan path
|
|
221
|
+
*/
|
|
222
|
+
async function getValidPath(initialPath) {
|
|
223
|
+
const { input } = await import('../ui/prompts.js');
|
|
224
|
+
let path = initialPath ? expandPath(initialPath) : expandPath('~');
|
|
225
|
+
// If path provided via option, validate it
|
|
226
|
+
if (initialPath) {
|
|
227
|
+
const pathExists = await exists(path);
|
|
228
|
+
if (!pathExists) {
|
|
229
|
+
error(`Path does not exist: ${path}`);
|
|
230
|
+
console.log();
|
|
231
|
+
// Prompt for valid path
|
|
232
|
+
while (true) {
|
|
233
|
+
const newPath = await input({
|
|
234
|
+
message: 'Enter a valid path to scan:',
|
|
235
|
+
default: expandPath('~'),
|
|
236
|
+
});
|
|
237
|
+
const expandedPath = expandPath(newPath);
|
|
238
|
+
const pathExists = await exists(expandedPath);
|
|
239
|
+
if (pathExists) {
|
|
240
|
+
path = expandedPath;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
error(`Path does not exist: ${expandedPath}`);
|
|
245
|
+
console.log();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return path;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Execute duplicates command
|
|
254
|
+
*/
|
|
255
|
+
export async function duplicatesCommand(options) {
|
|
256
|
+
const path = await getValidPath(options.path);
|
|
257
|
+
const paths = [path];
|
|
258
|
+
const minSize = options.minSize ? parseSize(options.minSize) : 1024 * 1024; // Default 1MB
|
|
259
|
+
const hashAlgorithm = options.hash || 'sha256';
|
|
260
|
+
printHeader('🔍 Duplicate File Finder');
|
|
261
|
+
console.log(chalk.dim(`Scanning: ${paths.join(', ')}`));
|
|
262
|
+
console.log(chalk.dim(`Min size: ${formatSize(minSize)}`));
|
|
263
|
+
console.log(chalk.dim(`Hash algorithm: ${hashAlgorithm}`));
|
|
264
|
+
console.log();
|
|
265
|
+
const duplicates = await findDuplicates(paths, minSize, hashAlgorithm);
|
|
266
|
+
if (duplicates.size === 0) {
|
|
267
|
+
success('No duplicates found!');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Calculate potential space savings
|
|
271
|
+
let totalSavings = 0;
|
|
272
|
+
for (const [_, files] of duplicates.entries()) {
|
|
273
|
+
const fileSize = (await stat(files[0])).size;
|
|
274
|
+
totalSavings += fileSize * (files.length - 1);
|
|
275
|
+
}
|
|
276
|
+
console.log();
|
|
277
|
+
separator();
|
|
278
|
+
console.log();
|
|
279
|
+
console.log(chalk.bold('📊 Summary:'));
|
|
280
|
+
console.log(` Duplicate groups: ${duplicates.size}`);
|
|
281
|
+
console.log(` Potential savings: ${chalk.yellow(formatSize(totalSavings))}`);
|
|
282
|
+
console.log();
|
|
283
|
+
if (options.interactive || options.delete) {
|
|
284
|
+
await handleDuplicatesInteractive(duplicates);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Just list duplicates with details
|
|
288
|
+
const cwd = process.cwd();
|
|
289
|
+
let groupIndex = 1;
|
|
290
|
+
for (const [_, files] of duplicates.entries()) {
|
|
291
|
+
const fileSize = (await stat(files[0])).size;
|
|
292
|
+
console.log(chalk.bold(`Group ${groupIndex} (${files.length} files, ${formatSize(fileSize)} each):`));
|
|
293
|
+
console.log();
|
|
294
|
+
for (let i = 0; i < files.length; i++) {
|
|
295
|
+
const file = files[i];
|
|
296
|
+
const stats = await stat(file);
|
|
297
|
+
const relPath = relative(cwd, file);
|
|
298
|
+
const modTime = new Date(stats.mtime).toLocaleString('ja-JP');
|
|
299
|
+
// Create clickable file link
|
|
300
|
+
console.log(` ${i + 1}. ${chalk.cyan.underline(`file://${file}`)}`);
|
|
301
|
+
console.log(` ${chalk.dim(`└─ ${relPath}`)}`);
|
|
302
|
+
console.log(` ${chalk.dim(` Modified: ${modTime}`)}`);
|
|
303
|
+
}
|
|
304
|
+
console.log();
|
|
305
|
+
groupIndex++;
|
|
306
|
+
}
|
|
307
|
+
console.log(chalk.dim('Use --interactive to select which files to keep/delete'));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Create duplicates command
|
|
312
|
+
*/
|
|
313
|
+
export function createDuplicatesCommand() {
|
|
314
|
+
const cmd = new Command('duplicates')
|
|
315
|
+
.description('Find and remove duplicate files')
|
|
316
|
+
.option('-p, --path <path>', 'Path to scan (default: home directory)')
|
|
317
|
+
.option('--min-size <size>', 'Minimum file size (e.g., 1MB, 500KB)', '1MB')
|
|
318
|
+
.option('--hash <algorithm>', 'Hash algorithm (md5 or sha256)', 'sha256')
|
|
319
|
+
.option('-i, --interactive', 'Interactive mode to choose which files to delete')
|
|
320
|
+
.option('-d, --delete', 'Automatically delete duplicates (keep first)')
|
|
321
|
+
.action(async (options) => {
|
|
322
|
+
await duplicatesCommand(options);
|
|
323
|
+
});
|
|
324
|
+
return enhanceCommandHelp(cmd);
|
|
325
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help command - Display help for any command
|
|
3
|
+
*/
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { formatCommandHelp } from '../utils/help.js';
|
|
6
|
+
import { info } from '../ui/output.js';
|
|
7
|
+
let allCommands = [];
|
|
8
|
+
export function setCommandsList(commands) {
|
|
9
|
+
allCommands = commands;
|
|
10
|
+
}
|
|
11
|
+
export function createHelpCommand() {
|
|
12
|
+
return new Command('help')
|
|
13
|
+
.description('Display help for a command')
|
|
14
|
+
.argument('[command]', 'The command to get help for')
|
|
15
|
+
.action((commandName) => {
|
|
16
|
+
if (!commandName) {
|
|
17
|
+
info('Usage: broom help <command>');
|
|
18
|
+
info('');
|
|
19
|
+
info('Examples:');
|
|
20
|
+
info(' broom help clean');
|
|
21
|
+
info(' broom help analyze');
|
|
22
|
+
info(' broom help uninstall');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Find the specified command
|
|
26
|
+
const command = allCommands.find((cmd) => cmd.name() === commandName);
|
|
27
|
+
if (!command) {
|
|
28
|
+
info(`Error: Unknown command "${commandName}"`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// Display the command help
|
|
32
|
+
info(formatCommandHelp(command));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands module exports
|
|
3
|
+
*/
|
|
4
|
+
export { createCleanCommand } from './clean.js';
|
|
5
|
+
export { createUninstallCommand } from './uninstall.js';
|
|
6
|
+
export { createOptimizeCommand } from './optimize.js';
|
|
7
|
+
export { createAnalyzeCommand } from './analyze.js';
|
|
8
|
+
export { createStatusCommand } from './status.js';
|
|
9
|
+
export { createPurgeCommand } from './purge.js';
|
|
10
|
+
export { createInstallerCommand } from './installer.js';
|
|
11
|
+
export { createTouchIdCommand } from './touchid.js';
|
|
12
|
+
export { createCompletionCommand } from './completion.js';
|
|
13
|
+
export { createUpdateCommand } from './update.js';
|
|
14
|
+
export { createRemoveCommand } from './remove.js';
|
|
15
|
+
export { createConfigCommand } from './config.js';
|
|
16
|
+
export { createDoctorCommand } from './doctor.js';
|
|
17
|
+
export { createBackupCommand, createRestoreCommand } from './backup.js';
|
|
18
|
+
export { createDuplicatesCommand } from './duplicates.js';
|
|
19
|
+
export { createScheduleCommand } from './schedule.js';
|
|
20
|
+
export { createWatchCommand } from './watch.js';
|
|
21
|
+
export { createReportsCommand } from './reports.js';
|
|
22
|
+
export { createHelpCommand, setCommandsList } from './help.js';
|