oh-my-node-modules 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/AGENTS.md +267 -0
- package/README.md +89 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1674 -0
- package/dist/index.d.ts +562 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +882 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { promises } from 'fs';
|
|
2
|
+
import { join, basename, dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
/** Default color configuration using standard terminal colors */ const DEFAULT_COLORS = {
|
|
5
|
+
huge: 'red',
|
|
6
|
+
large: 'yellow',
|
|
7
|
+
small: 'green',
|
|
8
|
+
stale: 'gray',
|
|
9
|
+
fresh: 'white',
|
|
10
|
+
selected: 'cyan',
|
|
11
|
+
error: 'red',
|
|
12
|
+
success: 'green',
|
|
13
|
+
warning: 'yellow',
|
|
14
|
+
info: 'blue'
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Thresholds for size categorization in bytes.
|
|
18
|
+
* Used to determine visual styling and smart selection rules.
|
|
19
|
+
*/ const SIZE_THRESHOLDS = {
|
|
20
|
+
/** 100 MB - upper bound for "small" category */ SMALL: 100 * 1024 * 1024,
|
|
21
|
+
/** 500 MB - upper bound for "medium" category */ MEDIUM: 500 * 1024 * 1024,
|
|
22
|
+
/** 1 GB - upper bound for "large" category */ LARGE: 1024 * 1024 * 1024
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Thresholds for age categorization in days.
|
|
26
|
+
* Used to identify stale node_modules that might be safe to delete.
|
|
27
|
+
*/ const AGE_THRESHOLDS = {
|
|
28
|
+
/** 7 days - still considered fresh */ FRESH: 7,
|
|
29
|
+
/** 30 days - warning threshold */ RECENT: 30,
|
|
30
|
+
/** 90 days - stale threshold */ OLD: 90
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format bytes into human-readable string.
|
|
35
|
+
* Uses binary units (MiB, GiB) for accuracy.
|
|
36
|
+
*
|
|
37
|
+
* @param bytes - Number of bytes to format
|
|
38
|
+
* @returns Formatted string like "1.2 GB" or "456 MB"
|
|
39
|
+
*/ function formatBytes(bytes) {
|
|
40
|
+
if (bytes === 0) return '0 B';
|
|
41
|
+
const units = [
|
|
42
|
+
'B',
|
|
43
|
+
'KB',
|
|
44
|
+
'MB',
|
|
45
|
+
'GB',
|
|
46
|
+
'TB'
|
|
47
|
+
];
|
|
48
|
+
const base = 1024;
|
|
49
|
+
const exponent = Math.floor(Math.log(bytes) / Math.log(base));
|
|
50
|
+
const unit = units[Math.min(exponent, units.length - 1)];
|
|
51
|
+
const value = bytes / Math.pow(base, exponent);
|
|
52
|
+
// Show 1 decimal place for MB and above, 0 for smaller
|
|
53
|
+
const decimals = exponent >= 2 ? 1 : 0;
|
|
54
|
+
return `${value.toFixed(decimals)} ${unit}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse human-readable size string into bytes.
|
|
58
|
+
* Supports formats like "1gb", "500MB", "10mb"
|
|
59
|
+
*
|
|
60
|
+
* @param sizeStr - Size string to parse
|
|
61
|
+
* @returns Size in bytes, or undefined if invalid
|
|
62
|
+
*/ function parseSize(sizeStr) {
|
|
63
|
+
const match = sizeStr.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
|
|
64
|
+
if (!match) return undefined;
|
|
65
|
+
const value = parseFloat(match[1]);
|
|
66
|
+
const unit = match[2] || 'b';
|
|
67
|
+
const multipliers = {
|
|
68
|
+
b: 1,
|
|
69
|
+
kb: 1024,
|
|
70
|
+
mb: 1024 * 1024,
|
|
71
|
+
gb: 1024 * 1024 * 1024,
|
|
72
|
+
tb: 1024 * 1024 * 1024 * 1024
|
|
73
|
+
};
|
|
74
|
+
return Math.floor(value * (multipliers[unit] || 1));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Format a date into "X days ago" string.
|
|
78
|
+
* Provides more readable relative time than raw dates.
|
|
79
|
+
*
|
|
80
|
+
* @param date - Date to format
|
|
81
|
+
* @returns Formatted string like "30d ago" or "2d ago"
|
|
82
|
+
*/ function formatRelativeTime(date) {
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const diffMs = now.getTime() - date.getTime();
|
|
85
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
86
|
+
if (diffDays === 0) {
|
|
87
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
88
|
+
if (diffHours === 0) {
|
|
89
|
+
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
90
|
+
return diffMinutes <= 1 ? 'just now' : `${diffMinutes}m ago`;
|
|
91
|
+
}
|
|
92
|
+
return `${diffHours}h ago`;
|
|
93
|
+
}
|
|
94
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
95
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
96
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Determine size category based on bytes.
|
|
100
|
+
* Used for color coding and smart selection.
|
|
101
|
+
*
|
|
102
|
+
* @param bytes - Size in bytes
|
|
103
|
+
* @returns Size category
|
|
104
|
+
*/ function getSizeCategory(bytes) {
|
|
105
|
+
if (bytes > SIZE_THRESHOLDS.LARGE) return 'huge';
|
|
106
|
+
if (bytes > SIZE_THRESHOLDS.MEDIUM) return 'large';
|
|
107
|
+
if (bytes > SIZE_THRESHOLDS.SMALL) return 'medium';
|
|
108
|
+
return 'small';
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Determine age category based on days since modification.
|
|
112
|
+
* Used to identify potentially stale node_modules.
|
|
113
|
+
*
|
|
114
|
+
* @param lastModified - Last modification date
|
|
115
|
+
* @returns Age category
|
|
116
|
+
*/ function getAgeCategory(lastModified) {
|
|
117
|
+
const now = new Date();
|
|
118
|
+
const diffDays = Math.floor((now.getTime() - lastModified.getTime()) / (1000 * 60 * 60 * 24));
|
|
119
|
+
if (diffDays > AGE_THRESHOLDS.OLD) return 'stale';
|
|
120
|
+
if (diffDays > AGE_THRESHOLDS.RECENT) return 'old';
|
|
121
|
+
if (diffDays > AGE_THRESHOLDS.FRESH) return 'recent';
|
|
122
|
+
return 'fresh';
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Calculate age in days from a date.
|
|
126
|
+
*
|
|
127
|
+
* @param date - Date to calculate age from
|
|
128
|
+
* @returns Number of days
|
|
129
|
+
*/ function getAgeInDays$1(date) {
|
|
130
|
+
const now = new Date();
|
|
131
|
+
return Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Sort node_modules by the specified option.
|
|
135
|
+
* Pure function that returns a new sorted array.
|
|
136
|
+
*
|
|
137
|
+
* @param items - Array to sort
|
|
138
|
+
* @param sortBy - Sort option
|
|
139
|
+
* @returns New sorted array
|
|
140
|
+
*/ function sortNodeModules(items, sortBy) {
|
|
141
|
+
const sorted = [
|
|
142
|
+
...items
|
|
143
|
+
]; // Create copy to avoid mutation
|
|
144
|
+
switch(sortBy){
|
|
145
|
+
case 'size-desc':
|
|
146
|
+
return sorted.sort((a, b)=>b.sizeBytes - a.sizeBytes);
|
|
147
|
+
case 'size-asc':
|
|
148
|
+
return sorted.sort((a, b)=>a.sizeBytes - b.sizeBytes);
|
|
149
|
+
case 'date-desc':
|
|
150
|
+
return sorted.sort((a, b)=>b.lastModified.getTime() - a.lastModified.getTime());
|
|
151
|
+
case 'date-asc':
|
|
152
|
+
return sorted.sort((a, b)=>a.lastModified.getTime() - b.lastModified.getTime());
|
|
153
|
+
case 'name-asc':
|
|
154
|
+
return sorted.sort((a, b)=>a.projectName.localeCompare(b.projectName));
|
|
155
|
+
case 'name-desc':
|
|
156
|
+
return sorted.sort((a, b)=>b.projectName.localeCompare(a.projectName));
|
|
157
|
+
case 'packages-desc':
|
|
158
|
+
return sorted.sort((a, b)=>b.totalPackageCount - a.totalPackageCount);
|
|
159
|
+
case 'packages-asc':
|
|
160
|
+
return sorted.sort((a, b)=>a.totalPackageCount - b.totalPackageCount);
|
|
161
|
+
default:
|
|
162
|
+
return sorted;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Filter node_modules by search query.
|
|
167
|
+
* Matches against project name and path.
|
|
168
|
+
*
|
|
169
|
+
* @param items - Array to filter
|
|
170
|
+
* @param query - Search query (case-insensitive)
|
|
171
|
+
* @returns Filtered array
|
|
172
|
+
*/ function filterNodeModules(items, query) {
|
|
173
|
+
if (!query.trim()) return items;
|
|
174
|
+
const lowerQuery = query.toLowerCase();
|
|
175
|
+
return items.filter((item)=>item.projectName.toLowerCase().includes(lowerQuery) || item.path.toLowerCase().includes(lowerQuery));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Calculate statistics from node_modules list.
|
|
179
|
+
* Used for overview displays and summaries.
|
|
180
|
+
*
|
|
181
|
+
* @param items - Node modules to analyze
|
|
182
|
+
* @returns Calculated statistics
|
|
183
|
+
*/ function calculateStatistics(items) {
|
|
184
|
+
const selectedItems = items.filter((item)=>item.selected);
|
|
185
|
+
const totalSize = items.reduce((sum, item)=>sum + item.sizeBytes, 0);
|
|
186
|
+
const selectedSize = selectedItems.reduce((sum, item)=>sum + item.sizeBytes, 0);
|
|
187
|
+
const totalAge = items.reduce((sum, item)=>{
|
|
188
|
+
return sum + getAgeInDays$1(item.lastModified);
|
|
189
|
+
}, 0);
|
|
190
|
+
const staleCount = items.filter((item)=>item.ageCategory === 'stale').length;
|
|
191
|
+
return {
|
|
192
|
+
totalProjects: new Set(items.map((item)=>item.projectPath)).size,
|
|
193
|
+
totalNodeModules: items.length,
|
|
194
|
+
totalSizeBytes: totalSize,
|
|
195
|
+
totalSizeFormatted: formatBytes(totalSize),
|
|
196
|
+
selectedCount: selectedItems.length,
|
|
197
|
+
selectedSizeBytes: selectedSize,
|
|
198
|
+
selectedSizeFormatted: formatBytes(selectedSize),
|
|
199
|
+
averageAgeDays: items.length > 0 ? Math.round(totalAge / items.length) : 0,
|
|
200
|
+
staleCount
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check if a path should be excluded based on patterns.
|
|
205
|
+
* Supports glob-like patterns with * and ? wildcards.
|
|
206
|
+
*
|
|
207
|
+
* @param path - Path to check
|
|
208
|
+
* @param patterns - Exclusion patterns
|
|
209
|
+
* @returns True if path should be excluded
|
|
210
|
+
*/ function shouldExcludePath(path, patterns) {
|
|
211
|
+
return patterns.some((pattern)=>{
|
|
212
|
+
// Convert glob pattern to regex
|
|
213
|
+
const regexPattern = pattern.replace(/\*\*/g, '{{GLOBSTAR}}').replace(/\*/g, '[^/]*').replace(/\?/g, '.').replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
214
|
+
const regex = new RegExp(regexPattern, 'i');
|
|
215
|
+
return regex.test(path);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Toggle selection state for a node_modules item.
|
|
220
|
+
* Returns new array with toggled item (immutable update).
|
|
221
|
+
*
|
|
222
|
+
* @param items - Current items array
|
|
223
|
+
* @param index - Index of item to toggle
|
|
224
|
+
* @returns New array with toggled selection
|
|
225
|
+
*/ function toggleSelection(items, index) {
|
|
226
|
+
if (index < 0 || index >= items.length) return items;
|
|
227
|
+
return items.map((item, i)=>i === index ? {
|
|
228
|
+
...item,
|
|
229
|
+
selected: !item.selected
|
|
230
|
+
} : item);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Select or deselect all items matching a predicate.
|
|
234
|
+
* Useful for "select all >500MB" or "select all stale" operations.
|
|
235
|
+
*
|
|
236
|
+
* @param items - Current items array
|
|
237
|
+
* @param predicate - Function to determine which items to select
|
|
238
|
+
* @param selected - Whether to select (true) or deselect (false)
|
|
239
|
+
* @returns New array with updated selections
|
|
240
|
+
*/ function selectByPredicate(items, predicate, selected) {
|
|
241
|
+
return items.map((item)=>predicate(item) ? {
|
|
242
|
+
...item,
|
|
243
|
+
selected
|
|
244
|
+
} : item);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Safe file existence check that doesn't throw.
|
|
248
|
+
* Useful for checking if package.json exists before parsing.
|
|
249
|
+
*
|
|
250
|
+
* @param path - Path to check
|
|
251
|
+
* @returns True if file exists
|
|
252
|
+
*/ async function fileExists(path) {
|
|
253
|
+
try {
|
|
254
|
+
await promises.access(path);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Read and parse package.json safely.
|
|
262
|
+
* Returns undefined if file doesn't exist or is invalid.
|
|
263
|
+
*
|
|
264
|
+
* @param projectPath - Path to project directory
|
|
265
|
+
* @returns Parsed package.json or undefined
|
|
266
|
+
*/ async function readPackageJson(projectPath) {
|
|
267
|
+
const packagePath = join(projectPath, 'package.json');
|
|
268
|
+
try {
|
|
269
|
+
const content = await promises.readFile(packagePath, 'utf-8');
|
|
270
|
+
const parsed = JSON.parse(content);
|
|
271
|
+
return parsed;
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Recursively scan for node_modules directories starting from root path.
|
|
279
|
+
*
|
|
280
|
+
* This is the main entry point for discovery. It walks the directory tree,
|
|
281
|
+
* identifies node_modules folders, and collects metadata about each one.
|
|
282
|
+
*
|
|
283
|
+
* @param options - Scan configuration options
|
|
284
|
+
* @param onProgress - Optional callback for progress updates (0-100)
|
|
285
|
+
* @returns Scan results with all discovered node_modules
|
|
286
|
+
*/ async function scanForNodeModules(options, onProgress) {
|
|
287
|
+
const result = {
|
|
288
|
+
nodeModules: [],
|
|
289
|
+
directoriesScanned: 0,
|
|
290
|
+
errors: []
|
|
291
|
+
};
|
|
292
|
+
const visitedPaths = new Set();
|
|
293
|
+
const pathsToScan = [
|
|
294
|
+
{
|
|
295
|
+
path: options.rootPath,
|
|
296
|
+
depth: 0
|
|
297
|
+
}
|
|
298
|
+
];
|
|
299
|
+
let processedCount = 0;
|
|
300
|
+
let totalEstimate = 1; // Start with 1, will adjust as we discover
|
|
301
|
+
while(pathsToScan.length > 0){
|
|
302
|
+
const { path: currentPath, depth } = pathsToScan.shift();
|
|
303
|
+
// Skip if already visited or exceeds max depth
|
|
304
|
+
if (visitedPaths.has(currentPath)) continue;
|
|
305
|
+
if (options.maxDepth !== undefined && depth > options.maxDepth) continue;
|
|
306
|
+
if (shouldExcludePath(currentPath, options.excludePatterns)) continue;
|
|
307
|
+
visitedPaths.add(currentPath);
|
|
308
|
+
result.directoriesScanned++;
|
|
309
|
+
try {
|
|
310
|
+
const entries = await promises.readdir(currentPath, {
|
|
311
|
+
withFileTypes: true
|
|
312
|
+
});
|
|
313
|
+
// Check if current directory has node_modules
|
|
314
|
+
const hasNodeModules = entries.some((entry)=>entry.isDirectory() && entry.name === 'node_modules');
|
|
315
|
+
if (hasNodeModules) {
|
|
316
|
+
const nodeModulesPath = join(currentPath, 'node_modules');
|
|
317
|
+
const info = await analyzeNodeModules(nodeModulesPath, currentPath);
|
|
318
|
+
// Apply filters
|
|
319
|
+
if (options.minSizeBytes && info.sizeBytes < options.minSizeBytes) {
|
|
320
|
+
// Skip - too small
|
|
321
|
+
} else if (options.olderThanDays && getAgeInDays(info.lastModified) < options.olderThanDays) {
|
|
322
|
+
// Skip - too recent
|
|
323
|
+
} else {
|
|
324
|
+
result.nodeModules.push(info);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Add subdirectories to scan queue (excluding node_modules itself)
|
|
328
|
+
for (const entry of entries){
|
|
329
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
|
|
330
|
+
const subPath = join(currentPath, entry.name);
|
|
331
|
+
if (!shouldExcludePath(subPath, options.excludePatterns)) {
|
|
332
|
+
pathsToScan.push({
|
|
333
|
+
path: subPath,
|
|
334
|
+
depth: depth + 1
|
|
335
|
+
});
|
|
336
|
+
totalEstimate++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
342
|
+
result.errors.push(`Error scanning ${currentPath}: ${errorMessage}`);
|
|
343
|
+
}
|
|
344
|
+
// Report progress
|
|
345
|
+
processedCount++;
|
|
346
|
+
if (onProgress) {
|
|
347
|
+
const progress = Math.min(100, Math.round(processedCount / totalEstimate * 100));
|
|
348
|
+
onProgress(progress);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Ensure we report 100% at the end
|
|
352
|
+
if (onProgress) {
|
|
353
|
+
onProgress(100);
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Analyze a specific node_modules directory and extract all metadata.
|
|
359
|
+
*
|
|
360
|
+
* This function performs the heavy lifting of:
|
|
361
|
+
* - Calculating total size (recursive)
|
|
362
|
+
* - Counting packages
|
|
363
|
+
* - Reading parent project info
|
|
364
|
+
* - Determining age and size categories
|
|
365
|
+
*
|
|
366
|
+
* @param nodeModulesPath - Path to node_modules directory
|
|
367
|
+
* @param projectPath - Path to parent project (containing package.json)
|
|
368
|
+
* @returns Complete metadata for the node_modules
|
|
369
|
+
*/ async function analyzeNodeModules(nodeModulesPath, projectPath) {
|
|
370
|
+
// Get basic stats
|
|
371
|
+
const stats = await promises.stat(nodeModulesPath);
|
|
372
|
+
// Calculate size and count packages
|
|
373
|
+
const { totalSize, packageCount, totalPackageCount } = await calculateDirectorySize(nodeModulesPath);
|
|
374
|
+
// Read project info from package.json
|
|
375
|
+
const packageJson = await readPackageJson(projectPath);
|
|
376
|
+
const projectName = packageJson?.name || basename(projectPath);
|
|
377
|
+
const projectVersion = packageJson?.version;
|
|
378
|
+
// Determine categories
|
|
379
|
+
const sizeCategory = getSizeCategory(totalSize);
|
|
380
|
+
const ageCategory = getAgeCategory(stats.mtime);
|
|
381
|
+
return {
|
|
382
|
+
path: nodeModulesPath,
|
|
383
|
+
projectPath,
|
|
384
|
+
projectName,
|
|
385
|
+
projectVersion,
|
|
386
|
+
sizeBytes: totalSize,
|
|
387
|
+
sizeFormatted: formatBytes(totalSize),
|
|
388
|
+
packageCount,
|
|
389
|
+
totalPackageCount,
|
|
390
|
+
lastModified: stats.mtime,
|
|
391
|
+
lastModifiedFormatted: formatRelativeTime(stats.mtime),
|
|
392
|
+
selected: false,
|
|
393
|
+
isFavorite: false,
|
|
394
|
+
ageCategory,
|
|
395
|
+
sizeCategory
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Recursively calculate directory size and package counts.
|
|
400
|
+
*
|
|
401
|
+
* This is an expensive operation for large node_modules directories.
|
|
402
|
+
* We optimize by:
|
|
403
|
+
* - Using iterative approach (avoid stack overflow)
|
|
404
|
+
* - Counting only top-level packages for packageCount
|
|
405
|
+
* - Counting all packages for totalPackageCount
|
|
406
|
+
*
|
|
407
|
+
* @param dirPath - Directory to analyze
|
|
408
|
+
* @returns Size in bytes and package counts
|
|
409
|
+
*/ async function calculateDirectorySize(dirPath) {
|
|
410
|
+
let totalSize = 0;
|
|
411
|
+
let packageCount = 0;
|
|
412
|
+
let totalPackageCount = 0;
|
|
413
|
+
let isTopLevel = true;
|
|
414
|
+
const pathsToProcess = [
|
|
415
|
+
dirPath
|
|
416
|
+
];
|
|
417
|
+
const processedPaths = new Set();
|
|
418
|
+
while(pathsToProcess.length > 0){
|
|
419
|
+
const currentPath = pathsToProcess.pop();
|
|
420
|
+
if (processedPaths.has(currentPath)) continue;
|
|
421
|
+
processedPaths.add(currentPath);
|
|
422
|
+
try {
|
|
423
|
+
const stats = await promises.stat(currentPath);
|
|
424
|
+
if (stats.isFile()) {
|
|
425
|
+
totalSize += stats.size;
|
|
426
|
+
} else if (stats.isDirectory()) {
|
|
427
|
+
// Add directory entry size (approximate)
|
|
428
|
+
totalSize += 4096; // Typical directory entry size
|
|
429
|
+
// Count packages at top level only
|
|
430
|
+
if (isTopLevel && currentPath !== dirPath) {
|
|
431
|
+
const entryName = basename(currentPath);
|
|
432
|
+
// Skip hidden directories and special directories
|
|
433
|
+
if (!entryName.startsWith('.') && entryName !== '.bin') {
|
|
434
|
+
packageCount++;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Count all packages for total
|
|
438
|
+
if (currentPath !== dirPath) {
|
|
439
|
+
const entryName = basename(currentPath);
|
|
440
|
+
if (!entryName.startsWith('.') && entryName !== '.bin') {
|
|
441
|
+
totalPackageCount++;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Read directory contents
|
|
445
|
+
try {
|
|
446
|
+
const entries = await promises.readdir(currentPath, {
|
|
447
|
+
withFileTypes: true
|
|
448
|
+
});
|
|
449
|
+
for (const entry of entries){
|
|
450
|
+
const entryPath = join(currentPath, entry.name);
|
|
451
|
+
pathsToProcess.push(entryPath);
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// Permission denied or other error - skip this directory
|
|
455
|
+
}
|
|
456
|
+
} else if (stats.isSymbolicLink()) {
|
|
457
|
+
// Skip symbolic links to avoid cycles
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
// File not accessible - skip
|
|
461
|
+
}
|
|
462
|
+
if (currentPath === dirPath) {
|
|
463
|
+
isTopLevel = false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
totalSize,
|
|
468
|
+
packageCount,
|
|
469
|
+
totalPackageCount
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Helper to get age in days from a date.
|
|
474
|
+
*/ function getAgeInDays(date) {
|
|
475
|
+
const now = new Date();
|
|
476
|
+
return Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Load ignore patterns from .onmignore file.
|
|
480
|
+
*
|
|
481
|
+
* Looks for .onmignore in:
|
|
482
|
+
* 1. Current working directory
|
|
483
|
+
* 2. Home directory
|
|
484
|
+
*
|
|
485
|
+
* @returns Array of ignore patterns
|
|
486
|
+
*/ async function loadIgnorePatterns() {
|
|
487
|
+
const patterns = [
|
|
488
|
+
'**/node_modules/**',
|
|
489
|
+
'**/.git/**',
|
|
490
|
+
'**/.*'
|
|
491
|
+
];
|
|
492
|
+
const ignoreFiles = [
|
|
493
|
+
join(process.cwd(), '.onmignore'),
|
|
494
|
+
join(process.env.HOME || process.cwd(), '.onmignore')
|
|
495
|
+
];
|
|
496
|
+
for (const ignoreFile of ignoreFiles){
|
|
497
|
+
try {
|
|
498
|
+
if (await fileExists(ignoreFile)) {
|
|
499
|
+
const content = await promises.readFile(ignoreFile, 'utf-8');
|
|
500
|
+
const lines = content.split('\n').map((line)=>line.trim()).filter((line)=>line && !line.startsWith('#'));
|
|
501
|
+
patterns.push(...lines);
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
// Ignore errors reading ignore files
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return patterns;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Load favorites list from .onmfavorites file.
|
|
511
|
+
*
|
|
512
|
+
* Favorites are projects that should never be suggested for deletion.
|
|
513
|
+
*
|
|
514
|
+
* @returns Set of favorite project paths
|
|
515
|
+
*/ async function loadFavorites() {
|
|
516
|
+
const favorites = new Set();
|
|
517
|
+
const favoritesFile = join(process.env.HOME || process.cwd(), '.onmfavorites');
|
|
518
|
+
try {
|
|
519
|
+
if (await fileExists(favoritesFile)) {
|
|
520
|
+
const content = await promises.readFile(favoritesFile, 'utf-8');
|
|
521
|
+
const lines = content.split('\n').map((line)=>line.trim()).filter((line)=>line && !line.startsWith('#'));
|
|
522
|
+
for (const line of lines){
|
|
523
|
+
favorites.add(line);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// Ignore errors reading favorites
|
|
528
|
+
}
|
|
529
|
+
return favorites;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Check if a node_modules directory is currently in use.
|
|
533
|
+
*
|
|
534
|
+
* This is a safety check to prevent deleting node_modules that
|
|
535
|
+
* might be actively being used by a running process.
|
|
536
|
+
*
|
|
537
|
+
* Note: This is a best-effort check and may not catch all cases.
|
|
538
|
+
*
|
|
539
|
+
* @param path - Path to node_modules
|
|
540
|
+
* @returns True if potentially in use
|
|
541
|
+
*/ async function isNodeModulesInUse(path) {
|
|
542
|
+
// This is a simplified check - in production, you might want to:
|
|
543
|
+
// 1. Check for lock files
|
|
544
|
+
// 2. Check for running node processes using this path
|
|
545
|
+
// 3. Check for open file handles
|
|
546
|
+
try {
|
|
547
|
+
const lockFiles = [
|
|
548
|
+
'.package-lock.json',
|
|
549
|
+
'yarn.lock',
|
|
550
|
+
'pnpm-lock.yaml'
|
|
551
|
+
];
|
|
552
|
+
const projectPath = dirname(path);
|
|
553
|
+
for (const lockFile of lockFiles){
|
|
554
|
+
const lockPath = join(projectPath, lockFile);
|
|
555
|
+
try {
|
|
556
|
+
const stats = await promises.stat(lockPath);
|
|
557
|
+
// If lock file was modified in the last minute, might be in use
|
|
558
|
+
const oneMinuteAgo = Date.now() - 60 * 1000;
|
|
559
|
+
if (stats.mtime.getTime() > oneMinuteAgo) {
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// Lock file doesn't exist - that's fine
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// Error checking - assume not in use
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Quick scan mode - just report without full metadata.
|
|
573
|
+
*
|
|
574
|
+
* Faster than full scan when you just need a quick overview.
|
|
575
|
+
*
|
|
576
|
+
* @param rootPath - Root directory to scan
|
|
577
|
+
* @returns Basic info about found node_modules
|
|
578
|
+
*/ async function quickScan(rootPath) {
|
|
579
|
+
const results = [];
|
|
580
|
+
const visitedPaths = new Set();
|
|
581
|
+
const pathsToScan = [
|
|
582
|
+
rootPath
|
|
583
|
+
];
|
|
584
|
+
while(pathsToScan.length > 0){
|
|
585
|
+
const currentPath = pathsToScan.pop();
|
|
586
|
+
if (visitedPaths.has(currentPath)) continue;
|
|
587
|
+
visitedPaths.add(currentPath);
|
|
588
|
+
try {
|
|
589
|
+
const entries = await promises.readdir(currentPath, {
|
|
590
|
+
withFileTypes: true
|
|
591
|
+
});
|
|
592
|
+
const hasNodeModules = entries.some((entry)=>entry.isDirectory() && entry.name === 'node_modules');
|
|
593
|
+
if (hasNodeModules) {
|
|
594
|
+
const projectPath = currentPath;
|
|
595
|
+
const nodeModulesPath = join(currentPath, 'node_modules');
|
|
596
|
+
const packageJson = await readPackageJson(projectPath);
|
|
597
|
+
results.push({
|
|
598
|
+
path: nodeModulesPath,
|
|
599
|
+
projectPath,
|
|
600
|
+
projectName: packageJson?.name || basename(projectPath)
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// Add subdirectories
|
|
604
|
+
for (const entry of entries){
|
|
605
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
|
|
606
|
+
pathsToScan.push(join(currentPath, entry.name));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
// Skip directories we can't read
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return results;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Delete selected node_modules directories.
|
|
618
|
+
*
|
|
619
|
+
* This is the main entry point for deletion operations. It:
|
|
620
|
+
* 1. Filters to only selected items
|
|
621
|
+
* 2. Performs safety checks
|
|
622
|
+
* 3. Deletes each node_modules (or simulates in dry run)
|
|
623
|
+
* 4. Collects results and statistics
|
|
624
|
+
*
|
|
625
|
+
* @param nodeModules - List of all node_modules (selected ones will be deleted)
|
|
626
|
+
* @param options - Deletion options
|
|
627
|
+
* @param onProgress - Optional callback for progress updates
|
|
628
|
+
* @returns Deletion results with statistics
|
|
629
|
+
*/ async function deleteSelectedNodeModules(nodeModules, options, onProgress) {
|
|
630
|
+
const selected = nodeModules.filter((nm)=>nm.selected);
|
|
631
|
+
const result = {
|
|
632
|
+
totalAttempted: selected.length,
|
|
633
|
+
successful: 0,
|
|
634
|
+
failed: 0,
|
|
635
|
+
bytesFreed: 0,
|
|
636
|
+
formattedBytesFreed: '0 B',
|
|
637
|
+
details: []
|
|
638
|
+
};
|
|
639
|
+
for(let i = 0; i < selected.length; i++){
|
|
640
|
+
const item = selected[i];
|
|
641
|
+
if (onProgress) {
|
|
642
|
+
onProgress(i + 1, selected.length, item.projectName);
|
|
643
|
+
}
|
|
644
|
+
const detail = await deleteNodeModules(item, options);
|
|
645
|
+
result.details.push(detail);
|
|
646
|
+
if (detail.success) {
|
|
647
|
+
result.successful++;
|
|
648
|
+
result.bytesFreed += item.sizeBytes;
|
|
649
|
+
} else {
|
|
650
|
+
result.failed++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
result.formattedBytesFreed = formatBytes(result.bytesFreed);
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Delete a single node_modules directory.
|
|
658
|
+
*
|
|
659
|
+
* Performs safety checks before deletion:
|
|
660
|
+
* - Verifies it's actually a node_modules directory
|
|
661
|
+
* - Checks if it's in use (if enabled)
|
|
662
|
+
* - Verifies the path is valid
|
|
663
|
+
*
|
|
664
|
+
* @param nodeModules - NodeModulesInfo to delete
|
|
665
|
+
* @param options - Deletion options
|
|
666
|
+
* @returns Detailed result of the deletion
|
|
667
|
+
*/ async function deleteNodeModules(nodeModules, options) {
|
|
668
|
+
const startTime = Date.now();
|
|
669
|
+
const detail = {
|
|
670
|
+
nodeModules,
|
|
671
|
+
success: false,
|
|
672
|
+
durationMs: 0
|
|
673
|
+
};
|
|
674
|
+
try {
|
|
675
|
+
// Safety check 1: Verify path ends with node_modules
|
|
676
|
+
if (!nodeModules.path.endsWith('node_modules')) {
|
|
677
|
+
detail.error = 'Path does not appear to be a node_modules directory';
|
|
678
|
+
detail.durationMs = Date.now() - startTime;
|
|
679
|
+
return detail;
|
|
680
|
+
}
|
|
681
|
+
// Safety check 2: Verify directory exists
|
|
682
|
+
if (!await fileExists(nodeModules.path)) {
|
|
683
|
+
detail.error = 'Directory does not exist';
|
|
684
|
+
detail.durationMs = Date.now() - startTime;
|
|
685
|
+
return detail;
|
|
686
|
+
}
|
|
687
|
+
// Safety check 3: Check if in use
|
|
688
|
+
if (options.checkRunningProcesses) {
|
|
689
|
+
const inUse = await isNodeModulesInUse(nodeModules.path);
|
|
690
|
+
if (inUse) {
|
|
691
|
+
detail.error = 'Directory appears to be in use by a running process';
|
|
692
|
+
detail.durationMs = Date.now() - startTime;
|
|
693
|
+
return detail;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Safety check 4: Verify it looks like a real node_modules
|
|
697
|
+
const isValidNodeModules = await verifyNodeModules(nodeModules.path);
|
|
698
|
+
if (!isValidNodeModules) {
|
|
699
|
+
detail.error = 'Directory does not appear to be a valid node_modules';
|
|
700
|
+
detail.durationMs = Date.now() - startTime;
|
|
701
|
+
return detail;
|
|
702
|
+
}
|
|
703
|
+
// Perform deletion (or simulate)
|
|
704
|
+
if (options.dryRun) {
|
|
705
|
+
// In dry run, just simulate success
|
|
706
|
+
detail.success = true;
|
|
707
|
+
} else {
|
|
708
|
+
// Actually delete the directory
|
|
709
|
+
await promises.rm(nodeModules.path, {
|
|
710
|
+
recursive: true,
|
|
711
|
+
force: true
|
|
712
|
+
});
|
|
713
|
+
detail.success = true;
|
|
714
|
+
}
|
|
715
|
+
detail.durationMs = Date.now() - startTime;
|
|
716
|
+
} catch (error) {
|
|
717
|
+
detail.error = error instanceof Error ? error.message : String(error);
|
|
718
|
+
detail.durationMs = Date.now() - startTime;
|
|
719
|
+
}
|
|
720
|
+
return detail;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Verify that a directory looks like a real node_modules.
|
|
724
|
+
*
|
|
725
|
+
* We check for:
|
|
726
|
+
* - Directory name is exactly "node_modules"
|
|
727
|
+
* - Contains at least one subdirectory (package)
|
|
728
|
+
* - Parent directory contains package.json
|
|
729
|
+
*
|
|
730
|
+
* These checks prevent accidental deletion of similarly named directories.
|
|
731
|
+
*
|
|
732
|
+
* @param path - Path to verify
|
|
733
|
+
* @returns True if it looks like a valid node_modules
|
|
734
|
+
*/ async function verifyNodeModules(path) {
|
|
735
|
+
try {
|
|
736
|
+
// Check name
|
|
737
|
+
const parts = path.split('/');
|
|
738
|
+
if (parts[parts.length - 1] !== 'node_modules') {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
// Check it has contents (not empty)
|
|
742
|
+
const entries = await promises.readdir(path);
|
|
743
|
+
const hasSubdirs = entries.some(async (entry)=>{
|
|
744
|
+
const entryPath = join(path, entry);
|
|
745
|
+
const stats = await promises.stat(entryPath);
|
|
746
|
+
return stats.isDirectory();
|
|
747
|
+
});
|
|
748
|
+
// Parent should have package.json
|
|
749
|
+
const parentPath = path.replace(/\/node_modules$/, '').replace(/\\node_modules$/, '');
|
|
750
|
+
const hasPackageJson = await fileExists(join(parentPath, 'package.json'));
|
|
751
|
+
return hasSubdirs || hasPackageJson;
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Generate a preview report of what would be deleted.
|
|
758
|
+
*
|
|
759
|
+
* Used for dry run mode and confirmation prompts.
|
|
760
|
+
*
|
|
761
|
+
* @param nodeModules - All node_modules items
|
|
762
|
+
* @returns Formatted report string
|
|
763
|
+
*/ function generateDeletionPreview(nodeModules) {
|
|
764
|
+
const selected = nodeModules.filter((nm)=>nm.selected);
|
|
765
|
+
if (selected.length === 0) {
|
|
766
|
+
return 'No node_modules selected for deletion.';
|
|
767
|
+
}
|
|
768
|
+
const totalBytes = selected.reduce((sum, nm)=>sum + nm.sizeBytes, 0);
|
|
769
|
+
let report = `\n⚠️ You are about to delete ${selected.length} node_modules director${selected.length === 1 ? 'y' : 'ies'}:\n\n`;
|
|
770
|
+
for (const nm of selected){
|
|
771
|
+
const shortPath = nm.path.replace(process.cwd(), '.');
|
|
772
|
+
report += ` • ${shortPath} (${nm.sizeFormatted})\n`;
|
|
773
|
+
}
|
|
774
|
+
report += `\n Total space to reclaim: ${formatBytes(totalBytes)}\n`;
|
|
775
|
+
return report;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Generate a JSON report of deletion results.
|
|
779
|
+
*
|
|
780
|
+
* @param result - Deletion result
|
|
781
|
+
* @returns JSON string
|
|
782
|
+
*/ function generateJSONReport(result) {
|
|
783
|
+
return JSON.stringify({
|
|
784
|
+
summary: {
|
|
785
|
+
totalAttempted: result.totalAttempted,
|
|
786
|
+
successful: result.successful,
|
|
787
|
+
failed: result.failed,
|
|
788
|
+
bytesFreed: result.bytesFreed,
|
|
789
|
+
formattedBytesFreed: result.formattedBytesFreed
|
|
790
|
+
},
|
|
791
|
+
details: result.details.map((d)=>({
|
|
792
|
+
path: d.nodeModules.path,
|
|
793
|
+
projectName: d.nodeModules.projectName,
|
|
794
|
+
sizeBytes: d.nodeModules.sizeBytes,
|
|
795
|
+
sizeFormatted: d.nodeModules.sizeFormatted,
|
|
796
|
+
success: d.success,
|
|
797
|
+
error: d.error,
|
|
798
|
+
durationMs: d.durationMs
|
|
799
|
+
}))
|
|
800
|
+
}, null, 2);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Select node_modules by size criteria.
|
|
804
|
+
*
|
|
805
|
+
* Helper for "select all >500MB" functionality.
|
|
806
|
+
*
|
|
807
|
+
* @param nodeModules - All node_modules
|
|
808
|
+
* @param minSizeBytes - Minimum size in bytes
|
|
809
|
+
* @returns Updated array with selections
|
|
810
|
+
*/ function selectBySize(nodeModules, minSizeBytes) {
|
|
811
|
+
return nodeModules.map((nm)=>nm.sizeBytes >= minSizeBytes ? {
|
|
812
|
+
...nm,
|
|
813
|
+
selected: true
|
|
814
|
+
} : nm);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Select node_modules by age criteria.
|
|
818
|
+
*
|
|
819
|
+
* Helper for "select all older than X days" functionality.
|
|
820
|
+
*
|
|
821
|
+
* @param nodeModules - All node_modules
|
|
822
|
+
* @param minAgeDays - Minimum age in days
|
|
823
|
+
* @returns Updated array with selections
|
|
824
|
+
*/ function selectByAge(nodeModules, minAgeDays) {
|
|
825
|
+
const now = new Date();
|
|
826
|
+
return nodeModules.map((nm)=>{
|
|
827
|
+
const ageDays = Math.floor((now.getTime() - nm.lastModified.getTime()) / (1000 * 60 * 60 * 24));
|
|
828
|
+
return ageDays >= minAgeDays ? {
|
|
829
|
+
...nm,
|
|
830
|
+
selected: true
|
|
831
|
+
} : nm;
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Select all node_modules.
|
|
836
|
+
*
|
|
837
|
+
* @param nodeModules - All node_modules
|
|
838
|
+
* @param selected - Whether to select (true) or deselect (false)
|
|
839
|
+
* @returns Updated array
|
|
840
|
+
*/ function selectAll(nodeModules, selected) {
|
|
841
|
+
return nodeModules.map((nm)=>({
|
|
842
|
+
...nm,
|
|
843
|
+
selected
|
|
844
|
+
}));
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Invert selection.
|
|
848
|
+
*
|
|
849
|
+
* @param nodeModules - All node_modules
|
|
850
|
+
* @returns Updated array with inverted selections
|
|
851
|
+
*/ function invertSelection(nodeModules) {
|
|
852
|
+
return nodeModules.map((nm)=>({
|
|
853
|
+
...nm,
|
|
854
|
+
selected: !nm.selected
|
|
855
|
+
}));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* oh-my-node-modules - Public API
|
|
860
|
+
*
|
|
861
|
+
* This module exports the public API for programmatic use.
|
|
862
|
+
* Most users will use the CLI, but the API is available for
|
|
863
|
+
* integration with other tools.
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* ```typescript
|
|
867
|
+
* import { scanForNodeModules, deleteSelectedNodeModules } from 'oh-my-node-modules';
|
|
868
|
+
*
|
|
869
|
+
* const result = await scanForNodeModules({
|
|
870
|
+
* rootPath: '/path/to/projects',
|
|
871
|
+
* excludePatterns: [],
|
|
872
|
+
* followSymlinks: false,
|
|
873
|
+
* });
|
|
874
|
+
*
|
|
875
|
+
* // ... process results ...
|
|
876
|
+
* ```
|
|
877
|
+
*/ // Core types
|
|
878
|
+
// Core functions
|
|
879
|
+
// Version
|
|
880
|
+
const VERSION = '1.0.0';
|
|
881
|
+
|
|
882
|
+
export { AGE_THRESHOLDS, DEFAULT_COLORS, SIZE_THRESHOLDS, VERSION, analyzeNodeModules, calculateStatistics, deleteSelectedNodeModules, filterNodeModules, formatBytes, formatRelativeTime, generateDeletionPreview, generateJSONReport, getAgeCategory, getSizeCategory, invertSelection, isNodeModulesInUse, loadFavorites, loadIgnorePatterns, parseSize, quickScan, scanForNodeModules, selectAll, selectByAge, selectByPredicate, selectBySize, sortNodeModules, toggleSelection };
|