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/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 };