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/cli.js ADDED
@@ -0,0 +1,1674 @@
1
+ import { Box, Text, useInput, useStdout, useApp, render } from 'ink';
2
+ import React, { useState, useMemo, useEffect, useCallback } from 'react';
3
+ import { promises } from 'fs';
4
+ import { join, basename, dirname } from 'path';
5
+
6
+ /**
7
+ * Header displays the app title and overall statistics.
8
+ * Shows scanning progress when active.
9
+ */ const Header = ({ statistics, isScanning, scanProgress })=>{
10
+ return /*#__PURE__*/ React.createElement(Box, {
11
+ flexDirection: "column",
12
+ borderStyle: "single",
13
+ padding: 1
14
+ }, /*#__PURE__*/ React.createElement(Box, {
15
+ justifyContent: "space-between"
16
+ }, /*#__PURE__*/ React.createElement(Text, {
17
+ bold: true,
18
+ color: "cyan"
19
+ }, "oh-my-node-modules"), isScanning && /*#__PURE__*/ React.createElement(Text, {
20
+ color: "yellow"
21
+ }, "Scanning... ", scanProgress, "%")), /*#__PURE__*/ React.createElement(Box, {
22
+ justifyContent: "space-between",
23
+ marginTop: 1
24
+ }, /*#__PURE__*/ React.createElement(Text, null, /*#__PURE__*/ React.createElement(Text, {
25
+ color: "white"
26
+ }, "Projects: "), /*#__PURE__*/ React.createElement(Text, {
27
+ bold: true,
28
+ color: "green"
29
+ }, statistics.totalProjects)), /*#__PURE__*/ React.createElement(Text, null, /*#__PURE__*/ React.createElement(Text, {
30
+ color: "white"
31
+ }, "Total Size: "), /*#__PURE__*/ React.createElement(Text, {
32
+ bold: true,
33
+ color: "yellow"
34
+ }, statistics.totalSizeFormatted)), /*#__PURE__*/ React.createElement(Text, null, /*#__PURE__*/ React.createElement(Text, {
35
+ color: "white"
36
+ }, "Selected: "), /*#__PURE__*/ React.createElement(Text, {
37
+ bold: true,
38
+ color: "cyan"
39
+ }, statistics.selectedCount), /*#__PURE__*/ React.createElement(Text, null, " (", statistics.selectedSizeFormatted, ")"))));
40
+ };
41
+ /**
42
+ * ListItem displays a single node_modules entry.
43
+ * Shows selection state, focus state, and visual indicators.
44
+ */ const ListItem = ({ item, isFocused })=>{
45
+ // Determine colors based on categories
46
+ const sizeColor = getSizeColor(item.sizeCategory);
47
+ const ageColor = item.ageCategory === 'stale' ? 'gray' : 'white';
48
+ const selectionIndicator = item.selected ? '[āœ“]' : '[ ]';
49
+ const focusIndicator = isFocused ? '>' : ' ';
50
+ // Warning indicator for large or old items
51
+ const warningIndicator = item.sizeCategory === 'huge' || item.ageCategory === 'stale' ? 'āš ļø ' : ' ';
52
+ return /*#__PURE__*/ React.createElement(Box, null, /*#__PURE__*/ React.createElement(Text, null, /*#__PURE__*/ React.createElement(Text, {
53
+ color: isFocused ? 'cyan' : 'white'
54
+ }, focusIndicator), /*#__PURE__*/ React.createElement(Text, {
55
+ color: item.selected ? 'green' : 'white'
56
+ }, selectionIndicator), /*#__PURE__*/ React.createElement(Text, null, " "), /*#__PURE__*/ React.createElement(Text, null, warningIndicator), /*#__PURE__*/ React.createElement(Text, null, "šŸ“ "), /*#__PURE__*/ React.createElement(Text, {
57
+ bold: true
58
+ }, item.projectName), item.projectVersion && /*#__PURE__*/ React.createElement(Text, {
59
+ color: "gray"
60
+ }, " (v", item.projectVersion, ")"), /*#__PURE__*/ React.createElement(Text, null, " "), /*#__PURE__*/ React.createElement(Text, {
61
+ color: sizeColor,
62
+ bold: true
63
+ }, item.sizeFormatted), /*#__PURE__*/ React.createElement(Text, null, " "), /*#__PURE__*/ React.createElement(Text, {
64
+ color: ageColor
65
+ }, "[", item.lastModifiedFormatted, "]")));
66
+ };
67
+ /**
68
+ * List displays a scrollable list of node_modules.
69
+ * Handles virtual scrolling for performance with large lists.
70
+ */ const List = ({ items, selectedIndex, visibleCount })=>{
71
+ // Calculate visible range for virtual scrolling
72
+ const halfVisible = Math.floor(visibleCount / 2);
73
+ let startIndex = Math.max(0, selectedIndex - halfVisible);
74
+ let endIndex = Math.min(items.length, startIndex + visibleCount);
75
+ // Adjust start if we're near the end
76
+ if (endIndex - startIndex < visibleCount) {
77
+ startIndex = Math.max(0, endIndex - visibleCount);
78
+ }
79
+ const visibleItems = items.slice(startIndex, endIndex);
80
+ return /*#__PURE__*/ React.createElement(Box, {
81
+ flexDirection: "column",
82
+ flexGrow: 1,
83
+ overflow: "hidden"
84
+ }, items.length === 0 ? /*#__PURE__*/ React.createElement(Box, {
85
+ padding: 2
86
+ }, /*#__PURE__*/ React.createElement(Text, {
87
+ color: "gray"
88
+ }, "No node_modules found. Press 'q' to quit.")) : /*#__PURE__*/ React.createElement(React.Fragment, null, startIndex > 0 && /*#__PURE__*/ React.createElement(Text, {
89
+ color: "gray"
90
+ }, "↑ ", startIndex, " more..."), visibleItems.map((item, index)=>{
91
+ const actualIndex = startIndex + index;
92
+ return /*#__PURE__*/ React.createElement(ListItem, {
93
+ key: item.path,
94
+ item: item,
95
+ isFocused: actualIndex === selectedIndex
96
+ });
97
+ }), endIndex < items.length && /*#__PURE__*/ React.createElement(Text, {
98
+ color: "gray"
99
+ }, "↓ ", items.length - endIndex, " more...")));
100
+ };
101
+ /**
102
+ * Footer displays keyboard shortcuts and current state.
103
+ */ const Footer = ({ sortBy, filterQuery })=>{
104
+ const sortLabel = getSortLabel(sortBy);
105
+ return /*#__PURE__*/ React.createElement(Box, {
106
+ flexDirection: "column",
107
+ borderStyle: "single",
108
+ padding: 1
109
+ }, /*#__PURE__*/ React.createElement(Box, {
110
+ justifyContent: "space-between"
111
+ }, /*#__PURE__*/ React.createElement(Text, {
112
+ color: "gray"
113
+ }, "[↑/↓] Navigate [Space] Toggle [d] Delete [a] Select all [s] Sort (", sortLabel, ") [f] Filter")), /*#__PURE__*/ React.createElement(Box, {
114
+ justifyContent: "space-between"
115
+ }, /*#__PURE__*/ React.createElement(Text, {
116
+ color: "gray"
117
+ }, "[i] Invert [>] Select larger [q] Quit [?] Help"), filterQuery && /*#__PURE__*/ React.createElement(Text, {
118
+ color: "cyan"
119
+ }, "Filter: ", filterQuery)));
120
+ };
121
+ /**
122
+ * Help displays keyboard shortcuts and usage instructions.
123
+ */ const Help = ({ onClose })=>{
124
+ useInput((input, key)=>{
125
+ if (input === 'q' || key.escape) {
126
+ onClose();
127
+ }
128
+ });
129
+ return /*#__PURE__*/ React.createElement(Box, {
130
+ flexDirection: "column",
131
+ borderStyle: "double",
132
+ padding: 2,
133
+ width: "80%"
134
+ }, /*#__PURE__*/ React.createElement(Text, {
135
+ bold: true,
136
+ color: "cyan"
137
+ }, "oh-my-node-modules - Keyboard Shortcuts"), /*#__PURE__*/ React.createElement(Box, {
138
+ marginY: 1
139
+ }, /*#__PURE__*/ React.createElement(Text, {
140
+ color: "gray"
141
+ }, "Navigation")), /*#__PURE__*/ React.createElement(Text, null, " ↑/↓ or j/k Navigate up/down"), /*#__PURE__*/ React.createElement(Text, null, " Space Toggle selection"), /*#__PURE__*/ React.createElement(Text, null, " Enter Toggle selection"), /*#__PURE__*/ React.createElement(Box, {
142
+ marginY: 1
143
+ }, /*#__PURE__*/ React.createElement(Text, {
144
+ color: "gray"
145
+ }, "Selection")), /*#__PURE__*/ React.createElement(Text, null, " a Select all visible"), /*#__PURE__*/ React.createElement(Text, null, " n Deselect all"), /*#__PURE__*/ React.createElement(Text, null, " i Invert selection"), /*#__PURE__*/ React.createElement(Text, null, " ", '>', " Select larger than previous"), /*#__PURE__*/ React.createElement(Box, {
146
+ marginY: 1
147
+ }, /*#__PURE__*/ React.createElement(Text, {
148
+ color: "gray"
149
+ }, "Actions")), /*#__PURE__*/ React.createElement(Text, null, " d Delete selected"), /*#__PURE__*/ React.createElement(Text, null, " s Change sort order"), /*#__PURE__*/ React.createElement(Text, null, " f Filter/search"), /*#__PURE__*/ React.createElement(Box, {
150
+ marginY: 1
151
+ }, /*#__PURE__*/ React.createElement(Text, {
152
+ color: "gray"
153
+ }, "Other")), /*#__PURE__*/ React.createElement(Text, null, " q Quit"), /*#__PURE__*/ React.createElement(Text, null, " ? Toggle this help"), /*#__PURE__*/ React.createElement(Text, null, " Esc Close help/exit filter"), /*#__PURE__*/ React.createElement(Box, {
154
+ marginTop: 2
155
+ }, /*#__PURE__*/ React.createElement(Text, {
156
+ color: "gray"
157
+ }, "Press any key to close...")));
158
+ };
159
+ /**
160
+ * ConfirmDialog asks for user confirmation before destructive actions.
161
+ */ const ConfirmDialog = ({ message, onConfirm, onCancel })=>{
162
+ useInput((input, key)=>{
163
+ if (input === 'y' || input === 'Y') {
164
+ onConfirm();
165
+ } else if (input === 'n' || input === 'N' || key.escape) {
166
+ onCancel();
167
+ }
168
+ });
169
+ return /*#__PURE__*/ React.createElement(Box, {
170
+ flexDirection: "column",
171
+ borderStyle: "double",
172
+ borderColor: "yellow",
173
+ padding: 2,
174
+ width: "80%"
175
+ }, /*#__PURE__*/ React.createElement(Text, {
176
+ color: "yellow",
177
+ bold: true
178
+ }, "āš ļø Confirmation Required"), /*#__PURE__*/ React.createElement(Box, {
179
+ marginY: 1
180
+ }, /*#__PURE__*/ React.createElement(Text, null, message)), /*#__PURE__*/ React.createElement(Text, {
181
+ color: "gray"
182
+ }, "Proceed? (y/N): "));
183
+ };
184
+ /**
185
+ * ProgressBar shows progress during long operations.
186
+ */ const ProgressBar = ({ current, total, label, operation })=>{
187
+ const percentage = total > 0 ? Math.round(current / total * 100) : 0;
188
+ const barWidth = 40;
189
+ const filledWidth = Math.round(percentage / 100 * barWidth);
190
+ const emptyWidth = barWidth - filledWidth;
191
+ const filledBar = 'ā–ˆ'.repeat(filledWidth);
192
+ const emptyBar = 'ā–‘'.repeat(emptyWidth);
193
+ return /*#__PURE__*/ React.createElement(Box, {
194
+ flexDirection: "column",
195
+ borderStyle: "single",
196
+ padding: 1,
197
+ width: "80%"
198
+ }, /*#__PURE__*/ React.createElement(Text, {
199
+ bold: true
200
+ }, operation, "..."), /*#__PURE__*/ React.createElement(Box, {
201
+ marginY: 1
202
+ }, /*#__PURE__*/ React.createElement(Text, {
203
+ color: "cyan"
204
+ }, filledBar), /*#__PURE__*/ React.createElement(Text, {
205
+ color: "gray"
206
+ }, emptyBar), /*#__PURE__*/ React.createElement(Text, null, " ", percentage, "%")), /*#__PURE__*/ React.createElement(Text, {
207
+ color: "gray"
208
+ }, current, "/", total, ": ", label));
209
+ };
210
+ // ============================================
211
+ // Helper Functions
212
+ // ============================================
213
+ function getSizeColor(category) {
214
+ switch(category){
215
+ case 'huge':
216
+ return 'red';
217
+ case 'large':
218
+ return 'yellow';
219
+ case 'medium':
220
+ return 'cyan';
221
+ case 'small':
222
+ return 'green';
223
+ default:
224
+ return 'white';
225
+ }
226
+ }
227
+ function getSortLabel(sortBy) {
228
+ const labels = {
229
+ 'size-desc': 'size ↓',
230
+ 'size-asc': 'size ↑',
231
+ 'date-desc': 'date ↓',
232
+ 'date-asc': 'date ↑',
233
+ 'name-asc': 'name A-Z',
234
+ 'name-desc': 'name Z-A',
235
+ 'packages-desc': 'pkgs ↓',
236
+ 'packages-asc': 'pkgs ↑'
237
+ };
238
+ return labels[sortBy] || sortBy;
239
+ }
240
+
241
+ /**
242
+ * Core type definitions for oh-my-node-modules
243
+ *
244
+ * These types represent the domain model for node_modules analysis.
245
+ * Each interface answers a specific question about the data structure.
246
+ */ /**
247
+ * Represents a discovered node_modules directory with all relevant metadata.
248
+ * This is the core data structure that flows through the entire application.
249
+ */ /**
250
+ * Initial state factory function.
251
+ * Creates a fresh application state with sensible defaults.
252
+ */ function createInitialState() {
253
+ return {
254
+ nodeModules: [],
255
+ selectedIndex: 0,
256
+ sortBy: 'size-desc',
257
+ filterQuery: '',
258
+ isScanning: false,
259
+ isDeleting: false,
260
+ scanProgress: 0,
261
+ sessionBytesReclaimed: 0,
262
+ showHelp: false
263
+ };
264
+ }
265
+ /**
266
+ * Thresholds for size categorization in bytes.
267
+ * Used to determine visual styling and smart selection rules.
268
+ */ const SIZE_THRESHOLDS = {
269
+ /** 100 MB - upper bound for "small" category */ SMALL: 100 * 1024 * 1024,
270
+ /** 500 MB - upper bound for "medium" category */ MEDIUM: 500 * 1024 * 1024,
271
+ /** 1 GB - upper bound for "large" category */ LARGE: 1024 * 1024 * 1024
272
+ };
273
+ /**
274
+ * Thresholds for age categorization in days.
275
+ * Used to identify stale node_modules that might be safe to delete.
276
+ */ const AGE_THRESHOLDS = {
277
+ /** 7 days - still considered fresh */ FRESH: 7,
278
+ /** 30 days - warning threshold */ RECENT: 30,
279
+ /** 90 days - stale threshold */ OLD: 90
280
+ };
281
+
282
+ /**
283
+ * Format bytes into human-readable string.
284
+ * Uses binary units (MiB, GiB) for accuracy.
285
+ *
286
+ * @param bytes - Number of bytes to format
287
+ * @returns Formatted string like "1.2 GB" or "456 MB"
288
+ */ function formatBytes(bytes) {
289
+ if (bytes === 0) return '0 B';
290
+ const units = [
291
+ 'B',
292
+ 'KB',
293
+ 'MB',
294
+ 'GB',
295
+ 'TB'
296
+ ];
297
+ const base = 1024;
298
+ const exponent = Math.floor(Math.log(bytes) / Math.log(base));
299
+ const unit = units[Math.min(exponent, units.length - 1)];
300
+ const value = bytes / Math.pow(base, exponent);
301
+ // Show 1 decimal place for MB and above, 0 for smaller
302
+ const decimals = exponent >= 2 ? 1 : 0;
303
+ return `${value.toFixed(decimals)} ${unit}`;
304
+ }
305
+ /**
306
+ * Parse human-readable size string into bytes.
307
+ * Supports formats like "1gb", "500MB", "10mb"
308
+ *
309
+ * @param sizeStr - Size string to parse
310
+ * @returns Size in bytes, or undefined if invalid
311
+ */ function parseSize(sizeStr) {
312
+ const match = sizeStr.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
313
+ if (!match) return undefined;
314
+ const value = parseFloat(match[1]);
315
+ const unit = match[2] || 'b';
316
+ const multipliers = {
317
+ b: 1,
318
+ kb: 1024,
319
+ mb: 1024 * 1024,
320
+ gb: 1024 * 1024 * 1024,
321
+ tb: 1024 * 1024 * 1024 * 1024
322
+ };
323
+ return Math.floor(value * (multipliers[unit] || 1));
324
+ }
325
+ /**
326
+ * Format a date into "X days ago" string.
327
+ * Provides more readable relative time than raw dates.
328
+ *
329
+ * @param date - Date to format
330
+ * @returns Formatted string like "30d ago" or "2d ago"
331
+ */ function formatRelativeTime(date) {
332
+ const now = new Date();
333
+ const diffMs = now.getTime() - date.getTime();
334
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
335
+ if (diffDays === 0) {
336
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
337
+ if (diffHours === 0) {
338
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
339
+ return diffMinutes <= 1 ? 'just now' : `${diffMinutes}m ago`;
340
+ }
341
+ return `${diffHours}h ago`;
342
+ }
343
+ if (diffDays < 30) return `${diffDays}d ago`;
344
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
345
+ return `${Math.floor(diffDays / 365)}y ago`;
346
+ }
347
+ /**
348
+ * Determine size category based on bytes.
349
+ * Used for color coding and smart selection.
350
+ *
351
+ * @param bytes - Size in bytes
352
+ * @returns Size category
353
+ */ function getSizeCategory(bytes) {
354
+ if (bytes > SIZE_THRESHOLDS.LARGE) return 'huge';
355
+ if (bytes > SIZE_THRESHOLDS.MEDIUM) return 'large';
356
+ if (bytes > SIZE_THRESHOLDS.SMALL) return 'medium';
357
+ return 'small';
358
+ }
359
+ /**
360
+ * Determine age category based on days since modification.
361
+ * Used to identify potentially stale node_modules.
362
+ *
363
+ * @param lastModified - Last modification date
364
+ * @returns Age category
365
+ */ function getAgeCategory(lastModified) {
366
+ const now = new Date();
367
+ const diffDays = Math.floor((now.getTime() - lastModified.getTime()) / (1000 * 60 * 60 * 24));
368
+ if (diffDays > AGE_THRESHOLDS.OLD) return 'stale';
369
+ if (diffDays > AGE_THRESHOLDS.RECENT) return 'old';
370
+ if (diffDays > AGE_THRESHOLDS.FRESH) return 'recent';
371
+ return 'fresh';
372
+ }
373
+ /**
374
+ * Calculate age in days from a date.
375
+ *
376
+ * @param date - Date to calculate age from
377
+ * @returns Number of days
378
+ */ function getAgeInDays$1(date) {
379
+ const now = new Date();
380
+ return Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
381
+ }
382
+ /**
383
+ * Sort node_modules by the specified option.
384
+ * Pure function that returns a new sorted array.
385
+ *
386
+ * @param items - Array to sort
387
+ * @param sortBy - Sort option
388
+ * @returns New sorted array
389
+ */ function sortNodeModules(items, sortBy) {
390
+ const sorted = [
391
+ ...items
392
+ ]; // Create copy to avoid mutation
393
+ switch(sortBy){
394
+ case 'size-desc':
395
+ return sorted.sort((a, b)=>b.sizeBytes - a.sizeBytes);
396
+ case 'size-asc':
397
+ return sorted.sort((a, b)=>a.sizeBytes - b.sizeBytes);
398
+ case 'date-desc':
399
+ return sorted.sort((a, b)=>b.lastModified.getTime() - a.lastModified.getTime());
400
+ case 'date-asc':
401
+ return sorted.sort((a, b)=>a.lastModified.getTime() - b.lastModified.getTime());
402
+ case 'name-asc':
403
+ return sorted.sort((a, b)=>a.projectName.localeCompare(b.projectName));
404
+ case 'name-desc':
405
+ return sorted.sort((a, b)=>b.projectName.localeCompare(a.projectName));
406
+ case 'packages-desc':
407
+ return sorted.sort((a, b)=>b.totalPackageCount - a.totalPackageCount);
408
+ case 'packages-asc':
409
+ return sorted.sort((a, b)=>a.totalPackageCount - b.totalPackageCount);
410
+ default:
411
+ return sorted;
412
+ }
413
+ }
414
+ /**
415
+ * Filter node_modules by search query.
416
+ * Matches against project name and path.
417
+ *
418
+ * @param items - Array to filter
419
+ * @param query - Search query (case-insensitive)
420
+ * @returns Filtered array
421
+ */ function filterNodeModules(items, query) {
422
+ if (!query.trim()) return items;
423
+ const lowerQuery = query.toLowerCase();
424
+ return items.filter((item)=>item.projectName.toLowerCase().includes(lowerQuery) || item.path.toLowerCase().includes(lowerQuery));
425
+ }
426
+ /**
427
+ * Calculate statistics from node_modules list.
428
+ * Used for overview displays and summaries.
429
+ *
430
+ * @param items - Node modules to analyze
431
+ * @returns Calculated statistics
432
+ */ function calculateStatistics(items) {
433
+ const selectedItems = items.filter((item)=>item.selected);
434
+ const totalSize = items.reduce((sum, item)=>sum + item.sizeBytes, 0);
435
+ const selectedSize = selectedItems.reduce((sum, item)=>sum + item.sizeBytes, 0);
436
+ const totalAge = items.reduce((sum, item)=>{
437
+ return sum + getAgeInDays$1(item.lastModified);
438
+ }, 0);
439
+ const staleCount = items.filter((item)=>item.ageCategory === 'stale').length;
440
+ return {
441
+ totalProjects: new Set(items.map((item)=>item.projectPath)).size,
442
+ totalNodeModules: items.length,
443
+ totalSizeBytes: totalSize,
444
+ totalSizeFormatted: formatBytes(totalSize),
445
+ selectedCount: selectedItems.length,
446
+ selectedSizeBytes: selectedSize,
447
+ selectedSizeFormatted: formatBytes(selectedSize),
448
+ averageAgeDays: items.length > 0 ? Math.round(totalAge / items.length) : 0,
449
+ staleCount
450
+ };
451
+ }
452
+ /**
453
+ * Check if a path should be excluded based on patterns.
454
+ * Supports glob-like patterns with * and ? wildcards.
455
+ *
456
+ * @param path - Path to check
457
+ * @param patterns - Exclusion patterns
458
+ * @returns True if path should be excluded
459
+ */ function shouldExcludePath(path, patterns) {
460
+ return patterns.some((pattern)=>{
461
+ // Convert glob pattern to regex
462
+ const regexPattern = pattern.replace(/\*\*/g, '{{GLOBSTAR}}').replace(/\*/g, '[^/]*').replace(/\?/g, '.').replace(/\{\{GLOBSTAR\}\}/g, '.*');
463
+ const regex = new RegExp(regexPattern, 'i');
464
+ return regex.test(path);
465
+ });
466
+ }
467
+ /**
468
+ * Toggle selection state for a node_modules item.
469
+ * Returns new array with toggled item (immutable update).
470
+ *
471
+ * @param items - Current items array
472
+ * @param index - Index of item to toggle
473
+ * @returns New array with toggled selection
474
+ */ function toggleSelection(items, index) {
475
+ if (index < 0 || index >= items.length) return items;
476
+ return items.map((item, i)=>i === index ? {
477
+ ...item,
478
+ selected: !item.selected
479
+ } : item);
480
+ }
481
+ /**
482
+ * Safe file existence check that doesn't throw.
483
+ * Useful for checking if package.json exists before parsing.
484
+ *
485
+ * @param path - Path to check
486
+ * @returns True if file exists
487
+ */ async function fileExists(path) {
488
+ try {
489
+ await promises.access(path);
490
+ return true;
491
+ } catch {
492
+ return false;
493
+ }
494
+ }
495
+ /**
496
+ * Read and parse package.json safely.
497
+ * Returns undefined if file doesn't exist or is invalid.
498
+ *
499
+ * @param projectPath - Path to project directory
500
+ * @returns Parsed package.json or undefined
501
+ */ async function readPackageJson(projectPath) {
502
+ const packagePath = join(projectPath, 'package.json');
503
+ try {
504
+ const content = await promises.readFile(packagePath, 'utf-8');
505
+ const parsed = JSON.parse(content);
506
+ return parsed;
507
+ } catch {
508
+ return undefined;
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Recursively scan for node_modules directories starting from root path.
514
+ *
515
+ * This is the main entry point for discovery. It walks the directory tree,
516
+ * identifies node_modules folders, and collects metadata about each one.
517
+ *
518
+ * @param options - Scan configuration options
519
+ * @param onProgress - Optional callback for progress updates (0-100)
520
+ * @returns Scan results with all discovered node_modules
521
+ */ async function scanForNodeModules(options, onProgress) {
522
+ const result = {
523
+ nodeModules: [],
524
+ directoriesScanned: 0,
525
+ errors: []
526
+ };
527
+ const visitedPaths = new Set();
528
+ const pathsToScan = [
529
+ {
530
+ path: options.rootPath,
531
+ depth: 0
532
+ }
533
+ ];
534
+ let processedCount = 0;
535
+ let totalEstimate = 1; // Start with 1, will adjust as we discover
536
+ while(pathsToScan.length > 0){
537
+ const { path: currentPath, depth } = pathsToScan.shift();
538
+ // Skip if already visited or exceeds max depth
539
+ if (visitedPaths.has(currentPath)) continue;
540
+ if (options.maxDepth !== undefined && depth > options.maxDepth) continue;
541
+ if (shouldExcludePath(currentPath, options.excludePatterns)) continue;
542
+ visitedPaths.add(currentPath);
543
+ result.directoriesScanned++;
544
+ try {
545
+ const entries = await promises.readdir(currentPath, {
546
+ withFileTypes: true
547
+ });
548
+ // Check if current directory has node_modules
549
+ const hasNodeModules = entries.some((entry)=>entry.isDirectory() && entry.name === 'node_modules');
550
+ if (hasNodeModules) {
551
+ const nodeModulesPath = join(currentPath, 'node_modules');
552
+ const info = await analyzeNodeModules(nodeModulesPath, currentPath);
553
+ // Apply filters
554
+ if (options.minSizeBytes && info.sizeBytes < options.minSizeBytes) {
555
+ // Skip - too small
556
+ } else if (options.olderThanDays && getAgeInDays(info.lastModified) < options.olderThanDays) {
557
+ // Skip - too recent
558
+ } else {
559
+ result.nodeModules.push(info);
560
+ }
561
+ }
562
+ // Add subdirectories to scan queue (excluding node_modules itself)
563
+ for (const entry of entries){
564
+ if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
565
+ const subPath = join(currentPath, entry.name);
566
+ if (!shouldExcludePath(subPath, options.excludePatterns)) {
567
+ pathsToScan.push({
568
+ path: subPath,
569
+ depth: depth + 1
570
+ });
571
+ totalEstimate++;
572
+ }
573
+ }
574
+ }
575
+ } catch (error) {
576
+ const errorMessage = error instanceof Error ? error.message : String(error);
577
+ result.errors.push(`Error scanning ${currentPath}: ${errorMessage}`);
578
+ }
579
+ // Report progress
580
+ processedCount++;
581
+ if (onProgress) {
582
+ const progress = Math.min(100, Math.round(processedCount / totalEstimate * 100));
583
+ onProgress(progress);
584
+ }
585
+ }
586
+ // Ensure we report 100% at the end
587
+ if (onProgress) {
588
+ onProgress(100);
589
+ }
590
+ return result;
591
+ }
592
+ /**
593
+ * Analyze a specific node_modules directory and extract all metadata.
594
+ *
595
+ * This function performs the heavy lifting of:
596
+ * - Calculating total size (recursive)
597
+ * - Counting packages
598
+ * - Reading parent project info
599
+ * - Determining age and size categories
600
+ *
601
+ * @param nodeModulesPath - Path to node_modules directory
602
+ * @param projectPath - Path to parent project (containing package.json)
603
+ * @returns Complete metadata for the node_modules
604
+ */ async function analyzeNodeModules(nodeModulesPath, projectPath) {
605
+ // Get basic stats
606
+ const stats = await promises.stat(nodeModulesPath);
607
+ // Calculate size and count packages
608
+ const { totalSize, packageCount, totalPackageCount } = await calculateDirectorySize(nodeModulesPath);
609
+ // Read project info from package.json
610
+ const packageJson = await readPackageJson(projectPath);
611
+ const projectName = packageJson?.name || basename(projectPath);
612
+ const projectVersion = packageJson?.version;
613
+ // Determine categories
614
+ const sizeCategory = getSizeCategory(totalSize);
615
+ const ageCategory = getAgeCategory(stats.mtime);
616
+ return {
617
+ path: nodeModulesPath,
618
+ projectPath,
619
+ projectName,
620
+ projectVersion,
621
+ sizeBytes: totalSize,
622
+ sizeFormatted: formatBytes(totalSize),
623
+ packageCount,
624
+ totalPackageCount,
625
+ lastModified: stats.mtime,
626
+ lastModifiedFormatted: formatRelativeTime(stats.mtime),
627
+ selected: false,
628
+ isFavorite: false,
629
+ ageCategory,
630
+ sizeCategory
631
+ };
632
+ }
633
+ /**
634
+ * Recursively calculate directory size and package counts.
635
+ *
636
+ * This is an expensive operation for large node_modules directories.
637
+ * We optimize by:
638
+ * - Using iterative approach (avoid stack overflow)
639
+ * - Counting only top-level packages for packageCount
640
+ * - Counting all packages for totalPackageCount
641
+ *
642
+ * @param dirPath - Directory to analyze
643
+ * @returns Size in bytes and package counts
644
+ */ async function calculateDirectorySize(dirPath) {
645
+ let totalSize = 0;
646
+ let packageCount = 0;
647
+ let totalPackageCount = 0;
648
+ let isTopLevel = true;
649
+ const pathsToProcess = [
650
+ dirPath
651
+ ];
652
+ const processedPaths = new Set();
653
+ while(pathsToProcess.length > 0){
654
+ const currentPath = pathsToProcess.pop();
655
+ if (processedPaths.has(currentPath)) continue;
656
+ processedPaths.add(currentPath);
657
+ try {
658
+ const stats = await promises.stat(currentPath);
659
+ if (stats.isFile()) {
660
+ totalSize += stats.size;
661
+ } else if (stats.isDirectory()) {
662
+ // Add directory entry size (approximate)
663
+ totalSize += 4096; // Typical directory entry size
664
+ // Count packages at top level only
665
+ if (isTopLevel && currentPath !== dirPath) {
666
+ const entryName = basename(currentPath);
667
+ // Skip hidden directories and special directories
668
+ if (!entryName.startsWith('.') && entryName !== '.bin') {
669
+ packageCount++;
670
+ }
671
+ }
672
+ // Count all packages for total
673
+ if (currentPath !== dirPath) {
674
+ const entryName = basename(currentPath);
675
+ if (!entryName.startsWith('.') && entryName !== '.bin') {
676
+ totalPackageCount++;
677
+ }
678
+ }
679
+ // Read directory contents
680
+ try {
681
+ const entries = await promises.readdir(currentPath, {
682
+ withFileTypes: true
683
+ });
684
+ for (const entry of entries){
685
+ const entryPath = join(currentPath, entry.name);
686
+ pathsToProcess.push(entryPath);
687
+ }
688
+ } catch {
689
+ // Permission denied or other error - skip this directory
690
+ }
691
+ } else if (stats.isSymbolicLink()) {
692
+ // Skip symbolic links to avoid cycles
693
+ }
694
+ } catch {
695
+ // File not accessible - skip
696
+ }
697
+ if (currentPath === dirPath) {
698
+ isTopLevel = false;
699
+ }
700
+ }
701
+ return {
702
+ totalSize,
703
+ packageCount,
704
+ totalPackageCount
705
+ };
706
+ }
707
+ /**
708
+ * Helper to get age in days from a date.
709
+ */ function getAgeInDays(date) {
710
+ const now = new Date();
711
+ return Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
712
+ }
713
+ /**
714
+ * Load ignore patterns from .onmignore file.
715
+ *
716
+ * Looks for .onmignore in:
717
+ * 1. Current working directory
718
+ * 2. Home directory
719
+ *
720
+ * @returns Array of ignore patterns
721
+ */ async function loadIgnorePatterns() {
722
+ const patterns = [
723
+ '**/node_modules/**',
724
+ '**/.git/**',
725
+ '**/.*'
726
+ ];
727
+ const ignoreFiles = [
728
+ join(process.cwd(), '.onmignore'),
729
+ join(process.env.HOME || process.cwd(), '.onmignore')
730
+ ];
731
+ for (const ignoreFile of ignoreFiles){
732
+ try {
733
+ if (await fileExists(ignoreFile)) {
734
+ const content = await promises.readFile(ignoreFile, 'utf-8');
735
+ const lines = content.split('\n').map((line)=>line.trim()).filter((line)=>line && !line.startsWith('#'));
736
+ patterns.push(...lines);
737
+ }
738
+ } catch {
739
+ // Ignore errors reading ignore files
740
+ }
741
+ }
742
+ return patterns;
743
+ }
744
+ /**
745
+ * Load favorites list from .onmfavorites file.
746
+ *
747
+ * Favorites are projects that should never be suggested for deletion.
748
+ *
749
+ * @returns Set of favorite project paths
750
+ */ async function loadFavorites() {
751
+ const favorites = new Set();
752
+ const favoritesFile = join(process.env.HOME || process.cwd(), '.onmfavorites');
753
+ try {
754
+ if (await fileExists(favoritesFile)) {
755
+ const content = await promises.readFile(favoritesFile, 'utf-8');
756
+ const lines = content.split('\n').map((line)=>line.trim()).filter((line)=>line && !line.startsWith('#'));
757
+ for (const line of lines){
758
+ favorites.add(line);
759
+ }
760
+ }
761
+ } catch {
762
+ // Ignore errors reading favorites
763
+ }
764
+ return favorites;
765
+ }
766
+ /**
767
+ * Check if a node_modules directory is currently in use.
768
+ *
769
+ * This is a safety check to prevent deleting node_modules that
770
+ * might be actively being used by a running process.
771
+ *
772
+ * Note: This is a best-effort check and may not catch all cases.
773
+ *
774
+ * @param path - Path to node_modules
775
+ * @returns True if potentially in use
776
+ */ async function isNodeModulesInUse(path) {
777
+ // This is a simplified check - in production, you might want to:
778
+ // 1. Check for lock files
779
+ // 2. Check for running node processes using this path
780
+ // 3. Check for open file handles
781
+ try {
782
+ const lockFiles = [
783
+ '.package-lock.json',
784
+ 'yarn.lock',
785
+ 'pnpm-lock.yaml'
786
+ ];
787
+ const projectPath = dirname(path);
788
+ for (const lockFile of lockFiles){
789
+ const lockPath = join(projectPath, lockFile);
790
+ try {
791
+ const stats = await promises.stat(lockPath);
792
+ // If lock file was modified in the last minute, might be in use
793
+ const oneMinuteAgo = Date.now() - 60 * 1000;
794
+ if (stats.mtime.getTime() > oneMinuteAgo) {
795
+ return true;
796
+ }
797
+ } catch {
798
+ // Lock file doesn't exist - that's fine
799
+ }
800
+ }
801
+ } catch {
802
+ // Error checking - assume not in use
803
+ }
804
+ return false;
805
+ }
806
+
807
+ /**
808
+ * Delete selected node_modules directories.
809
+ *
810
+ * This is the main entry point for deletion operations. It:
811
+ * 1. Filters to only selected items
812
+ * 2. Performs safety checks
813
+ * 3. Deletes each node_modules (or simulates in dry run)
814
+ * 4. Collects results and statistics
815
+ *
816
+ * @param nodeModules - List of all node_modules (selected ones will be deleted)
817
+ * @param options - Deletion options
818
+ * @param onProgress - Optional callback for progress updates
819
+ * @returns Deletion results with statistics
820
+ */ async function deleteSelectedNodeModules(nodeModules, options, onProgress) {
821
+ const selected = nodeModules.filter((nm)=>nm.selected);
822
+ const result = {
823
+ totalAttempted: selected.length,
824
+ successful: 0,
825
+ failed: 0,
826
+ bytesFreed: 0,
827
+ formattedBytesFreed: '0 B',
828
+ details: []
829
+ };
830
+ for(let i = 0; i < selected.length; i++){
831
+ const item = selected[i];
832
+ if (onProgress) {
833
+ onProgress(i + 1, selected.length, item.projectName);
834
+ }
835
+ const detail = await deleteNodeModules(item, options);
836
+ result.details.push(detail);
837
+ if (detail.success) {
838
+ result.successful++;
839
+ result.bytesFreed += item.sizeBytes;
840
+ } else {
841
+ result.failed++;
842
+ }
843
+ }
844
+ result.formattedBytesFreed = formatBytes(result.bytesFreed);
845
+ return result;
846
+ }
847
+ /**
848
+ * Delete a single node_modules directory.
849
+ *
850
+ * Performs safety checks before deletion:
851
+ * - Verifies it's actually a node_modules directory
852
+ * - Checks if it's in use (if enabled)
853
+ * - Verifies the path is valid
854
+ *
855
+ * @param nodeModules - NodeModulesInfo to delete
856
+ * @param options - Deletion options
857
+ * @returns Detailed result of the deletion
858
+ */ async function deleteNodeModules(nodeModules, options) {
859
+ const startTime = Date.now();
860
+ const detail = {
861
+ nodeModules,
862
+ success: false,
863
+ durationMs: 0
864
+ };
865
+ try {
866
+ // Safety check 1: Verify path ends with node_modules
867
+ if (!nodeModules.path.endsWith('node_modules')) {
868
+ detail.error = 'Path does not appear to be a node_modules directory';
869
+ detail.durationMs = Date.now() - startTime;
870
+ return detail;
871
+ }
872
+ // Safety check 2: Verify directory exists
873
+ if (!await fileExists(nodeModules.path)) {
874
+ detail.error = 'Directory does not exist';
875
+ detail.durationMs = Date.now() - startTime;
876
+ return detail;
877
+ }
878
+ // Safety check 3: Check if in use
879
+ if (options.checkRunningProcesses) {
880
+ const inUse = await isNodeModulesInUse(nodeModules.path);
881
+ if (inUse) {
882
+ detail.error = 'Directory appears to be in use by a running process';
883
+ detail.durationMs = Date.now() - startTime;
884
+ return detail;
885
+ }
886
+ }
887
+ // Safety check 4: Verify it looks like a real node_modules
888
+ const isValidNodeModules = await verifyNodeModules(nodeModules.path);
889
+ if (!isValidNodeModules) {
890
+ detail.error = 'Directory does not appear to be a valid node_modules';
891
+ detail.durationMs = Date.now() - startTime;
892
+ return detail;
893
+ }
894
+ // Perform deletion (or simulate)
895
+ if (options.dryRun) {
896
+ // In dry run, just simulate success
897
+ detail.success = true;
898
+ } else {
899
+ // Actually delete the directory
900
+ await promises.rm(nodeModules.path, {
901
+ recursive: true,
902
+ force: true
903
+ });
904
+ detail.success = true;
905
+ }
906
+ detail.durationMs = Date.now() - startTime;
907
+ } catch (error) {
908
+ detail.error = error instanceof Error ? error.message : String(error);
909
+ detail.durationMs = Date.now() - startTime;
910
+ }
911
+ return detail;
912
+ }
913
+ /**
914
+ * Verify that a directory looks like a real node_modules.
915
+ *
916
+ * We check for:
917
+ * - Directory name is exactly "node_modules"
918
+ * - Contains at least one subdirectory (package)
919
+ * - Parent directory contains package.json
920
+ *
921
+ * These checks prevent accidental deletion of similarly named directories.
922
+ *
923
+ * @param path - Path to verify
924
+ * @returns True if it looks like a valid node_modules
925
+ */ async function verifyNodeModules(path) {
926
+ try {
927
+ // Check name
928
+ const parts = path.split('/');
929
+ if (parts[parts.length - 1] !== 'node_modules') {
930
+ return false;
931
+ }
932
+ // Check it has contents (not empty)
933
+ const entries = await promises.readdir(path);
934
+ const hasSubdirs = entries.some(async (entry)=>{
935
+ const entryPath = join(path, entry);
936
+ const stats = await promises.stat(entryPath);
937
+ return stats.isDirectory();
938
+ });
939
+ // Parent should have package.json
940
+ const parentPath = path.replace(/\/node_modules$/, '').replace(/\\node_modules$/, '');
941
+ const hasPackageJson = await fileExists(join(parentPath, 'package.json'));
942
+ return hasSubdirs || hasPackageJson;
943
+ } catch {
944
+ return false;
945
+ }
946
+ }
947
+ /**
948
+ * Generate a preview report of what would be deleted.
949
+ *
950
+ * Used for dry run mode and confirmation prompts.
951
+ *
952
+ * @param nodeModules - All node_modules items
953
+ * @returns Formatted report string
954
+ */ function generateDeletionPreview(nodeModules) {
955
+ const selected = nodeModules.filter((nm)=>nm.selected);
956
+ if (selected.length === 0) {
957
+ return 'No node_modules selected for deletion.';
958
+ }
959
+ const totalBytes = selected.reduce((sum, nm)=>sum + nm.sizeBytes, 0);
960
+ let report = `\nāš ļø You are about to delete ${selected.length} node_modules director${selected.length === 1 ? 'y' : 'ies'}:\n\n`;
961
+ for (const nm of selected){
962
+ const shortPath = nm.path.replace(process.cwd(), '.');
963
+ report += ` • ${shortPath} (${nm.sizeFormatted})\n`;
964
+ }
965
+ report += `\n Total space to reclaim: ${formatBytes(totalBytes)}\n`;
966
+ return report;
967
+ }
968
+ /**
969
+ * Select node_modules by size criteria.
970
+ *
971
+ * Helper for "select all >500MB" functionality.
972
+ *
973
+ * @param nodeModules - All node_modules
974
+ * @param minSizeBytes - Minimum size in bytes
975
+ * @returns Updated array with selections
976
+ */ function selectBySize(nodeModules, minSizeBytes) {
977
+ return nodeModules.map((nm)=>nm.sizeBytes >= minSizeBytes ? {
978
+ ...nm,
979
+ selected: true
980
+ } : nm);
981
+ }
982
+ /**
983
+ * Select all node_modules.
984
+ *
985
+ * @param nodeModules - All node_modules
986
+ * @param selected - Whether to select (true) or deselect (false)
987
+ * @returns Updated array
988
+ */ function selectAll(nodeModules, selected) {
989
+ return nodeModules.map((nm)=>({
990
+ ...nm,
991
+ selected
992
+ }));
993
+ }
994
+ /**
995
+ * Invert selection.
996
+ *
997
+ * @param nodeModules - All node_modules
998
+ * @returns Updated array with inverted selections
999
+ */ function invertSelection(nodeModules) {
1000
+ return nodeModules.map((nm)=>({
1001
+ ...nm,
1002
+ selected: !nm.selected
1003
+ }));
1004
+ }
1005
+
1006
+ /**
1007
+ * Main application component.
1008
+ *
1009
+ * Manages all application state and coordinates between:
1010
+ * - Scanner (discovery)
1011
+ * - UI components (rendering)
1012
+ * - Deletion operations
1013
+ * - User input
1014
+ */ const App = ({ rootPath, initialSort = 'size-desc' })=>{
1015
+ // Get terminal dimensions for responsive layout
1016
+ const { stdout } = useStdout();
1017
+ const { exit } = useApp();
1018
+ // Application state
1019
+ const [state, setState] = useState(()=>({
1020
+ ...createInitialState(),
1021
+ sortBy: initialSort
1022
+ }));
1023
+ // UI state (not part of core app state)
1024
+ const [showHelp, setShowHelp] = useState(false);
1025
+ const [showConfirm, setShowConfirm] = useState(false);
1026
+ const [showFilter, setShowFilter] = useState(false);
1027
+ const [showProgress, setShowProgress] = useState(false);
1028
+ const [progressState, setProgressState] = useState({
1029
+ current: 0,
1030
+ total: 0,
1031
+ label: ''
1032
+ });
1033
+ const [filterInput, setFilterInput] = useState('');
1034
+ const [errorMessage, setErrorMessage] = useState();
1035
+ // Derived state: sorted and filtered items
1036
+ const sortedItems = useMemo(()=>{
1037
+ const sorted = sortNodeModules(state.nodeModules, state.sortBy);
1038
+ return sorted;
1039
+ }, [
1040
+ state.nodeModules,
1041
+ state.sortBy
1042
+ ]);
1043
+ const filteredItems = useMemo(()=>{
1044
+ return filterNodeModules(sortedItems, state.filterQuery);
1045
+ }, [
1046
+ sortedItems,
1047
+ state.filterQuery
1048
+ ]);
1049
+ // Calculate statistics from current items
1050
+ const statistics = useMemo(()=>{
1051
+ return calculateStatistics(filteredItems);
1052
+ }, [
1053
+ filteredItems
1054
+ ]);
1055
+ // Calculate visible list size based on terminal height
1056
+ const visibleListCount = useMemo(()=>{
1057
+ // Reserve space for header, footer, and padding
1058
+ const reservedLines = 12;
1059
+ return Math.max(5, stdout.rows - reservedLines);
1060
+ }, [
1061
+ stdout.rows
1062
+ ]);
1063
+ // ============================================
1064
+ // Effects
1065
+ // ============================================
1066
+ // Scan on mount
1067
+ useEffect(()=>{
1068
+ const performScan = async ()=>{
1069
+ setState((prev)=>({
1070
+ ...prev,
1071
+ isScanning: true
1072
+ }));
1073
+ try {
1074
+ const excludePatterns = await loadIgnorePatterns();
1075
+ const favorites = await loadFavorites();
1076
+ const result = await scanForNodeModules({
1077
+ rootPath,
1078
+ excludePatterns,
1079
+ followSymlinks: false
1080
+ }, (progress)=>{
1081
+ setState((prev)=>({
1082
+ ...prev,
1083
+ scanProgress: progress
1084
+ }));
1085
+ });
1086
+ // Mark favorites
1087
+ const itemsWithFavorites = result.nodeModules.map((item)=>({
1088
+ ...item,
1089
+ isFavorite: favorites.has(item.projectPath)
1090
+ }));
1091
+ setState((prev)=>({
1092
+ ...prev,
1093
+ nodeModules: itemsWithFavorites,
1094
+ isScanning: false,
1095
+ scanProgress: 100
1096
+ }));
1097
+ if (result.errors.length > 0) {
1098
+ setErrorMessage(`Scan completed with ${result.errors.length} errors`);
1099
+ }
1100
+ } catch (error) {
1101
+ setState((prev)=>({
1102
+ ...prev,
1103
+ isScanning: false
1104
+ }));
1105
+ setErrorMessage(error instanceof Error ? error.message : 'Scan failed');
1106
+ }
1107
+ };
1108
+ performScan();
1109
+ }, [
1110
+ rootPath
1111
+ ]);
1112
+ // ============================================
1113
+ // Keyboard Handlers
1114
+ // ============================================
1115
+ useInput((input, key)=>{
1116
+ // Don't handle input during certain states
1117
+ if (state.isDeleting || showConfirm || showProgress) return;
1118
+ // Help overlay takes precedence
1119
+ if (showHelp) {
1120
+ if (input === 'q' || key.escape) {
1121
+ setShowHelp(false);
1122
+ }
1123
+ return;
1124
+ }
1125
+ // Filter mode
1126
+ if (showFilter) {
1127
+ if (key.escape) {
1128
+ setShowFilter(false);
1129
+ setFilterInput('');
1130
+ setState((prev)=>({
1131
+ ...prev,
1132
+ filterQuery: ''
1133
+ }));
1134
+ } else if (key.return) {
1135
+ setShowFilter(false);
1136
+ setState((prev)=>({
1137
+ ...prev,
1138
+ filterQuery: filterInput
1139
+ }));
1140
+ } else if (key.backspace || key.delete) {
1141
+ setFilterInput((prev)=>prev.slice(0, -1));
1142
+ } else if (input && !key.ctrl && !key.meta) {
1143
+ setFilterInput((prev)=>prev + input);
1144
+ }
1145
+ return;
1146
+ }
1147
+ // Navigation
1148
+ if (key.upArrow || input === 'k') {
1149
+ setState((prev)=>({
1150
+ ...prev,
1151
+ selectedIndex: Math.max(0, prev.selectedIndex - 1)
1152
+ }));
1153
+ } else if (key.downArrow || input === 'j') {
1154
+ setState((prev)=>({
1155
+ ...prev,
1156
+ selectedIndex: Math.min(filteredItems.length - 1, prev.selectedIndex + 1)
1157
+ }));
1158
+ } else if (input === ' ' || key.return) {
1159
+ setState((prev)=>{
1160
+ const newItems = toggleSelection(filteredItems, prev.selectedIndex);
1161
+ return {
1162
+ ...prev,
1163
+ nodeModules: prev.nodeModules.map((item)=>{
1164
+ const updated = newItems.find((n)=>n.path === item.path);
1165
+ return updated || item;
1166
+ })
1167
+ };
1168
+ });
1169
+ } else if (input === 'a') {
1170
+ setState((prev)=>{
1171
+ const allSelected = filteredItems.every((item)=>item.selected);
1172
+ const newItems = selectAll(filteredItems, !allSelected);
1173
+ return {
1174
+ ...prev,
1175
+ nodeModules: prev.nodeModules.map((item)=>{
1176
+ const updated = newItems.find((n)=>n.path === item.path);
1177
+ return updated || item;
1178
+ })
1179
+ };
1180
+ });
1181
+ } else if (input === 'n') {
1182
+ setState((prev)=>{
1183
+ const newItems = selectAll(filteredItems, false);
1184
+ return {
1185
+ ...prev,
1186
+ nodeModules: prev.nodeModules.map((item)=>{
1187
+ const updated = newItems.find((n)=>n.path === item.path);
1188
+ return updated || item;
1189
+ })
1190
+ };
1191
+ });
1192
+ } else if (input === 'i') {
1193
+ setState((prev)=>{
1194
+ const newItems = invertSelection(filteredItems);
1195
+ return {
1196
+ ...prev,
1197
+ nodeModules: prev.nodeModules.map((item)=>{
1198
+ const updated = newItems.find((n)=>n.path === item.path);
1199
+ return updated || item;
1200
+ })
1201
+ };
1202
+ });
1203
+ } else if (input === '>') {
1204
+ const currentItem = filteredItems[state.selectedIndex];
1205
+ if (currentItem) {
1206
+ setState((prev)=>{
1207
+ const newItems = selectBySize(filteredItems, currentItem.sizeBytes);
1208
+ return {
1209
+ ...prev,
1210
+ nodeModules: prev.nodeModules.map((item)=>{
1211
+ const updated = newItems.find((n)=>n.path === item.path);
1212
+ return updated || item;
1213
+ })
1214
+ };
1215
+ });
1216
+ }
1217
+ } else if (input === 'd') {
1218
+ const selected = filteredItems.filter((item)=>item.selected);
1219
+ if (selected.length > 0) {
1220
+ setShowConfirm(true);
1221
+ }
1222
+ } else if (input === 's') {
1223
+ setState((prev)=>{
1224
+ const sorts = [
1225
+ 'size-desc',
1226
+ 'size-asc',
1227
+ 'date-desc',
1228
+ 'date-asc',
1229
+ 'name-asc',
1230
+ 'name-desc',
1231
+ 'packages-desc',
1232
+ 'packages-asc'
1233
+ ];
1234
+ const currentIndex = sorts.indexOf(prev.sortBy);
1235
+ const nextIndex = (currentIndex + 1) % sorts.length;
1236
+ return {
1237
+ ...prev,
1238
+ sortBy: sorts[nextIndex]
1239
+ };
1240
+ });
1241
+ } else if (input === 'f') {
1242
+ setShowFilter(true);
1243
+ setFilterInput(state.filterQuery);
1244
+ } else if (input === '?') {
1245
+ setShowHelp(true);
1246
+ } else if (input === 'q') {
1247
+ exit();
1248
+ }
1249
+ });
1250
+ // ============================================
1251
+ // Action Handlers
1252
+ // ============================================
1253
+ const handleConfirmDelete = useCallback(async ()=>{
1254
+ setShowConfirm(false);
1255
+ setShowProgress(true);
1256
+ setState((prev)=>({
1257
+ ...prev,
1258
+ isDeleting: true
1259
+ }));
1260
+ const options = {
1261
+ dryRun: false,
1262
+ yes: true,
1263
+ checkRunningProcesses: true,
1264
+ showProgress: true
1265
+ };
1266
+ try {
1267
+ const result = await deleteSelectedNodeModules(state.nodeModules, options, (current, total, label)=>{
1268
+ setProgressState({
1269
+ current,
1270
+ total,
1271
+ label
1272
+ });
1273
+ });
1274
+ // Update state to reflect deletions
1275
+ setState((prev)=>({
1276
+ ...prev,
1277
+ nodeModules: prev.nodeModules.filter((nm)=>!result.details.find((d)=>d.nodeModules.path === nm.path && d.success)),
1278
+ isDeleting: false,
1279
+ sessionBytesReclaimed: prev.sessionBytesReclaimed + result.bytesFreed
1280
+ }));
1281
+ setShowProgress(false);
1282
+ } catch (error) {
1283
+ setState((prev)=>({
1284
+ ...prev,
1285
+ isDeleting: false
1286
+ }));
1287
+ setShowProgress(false);
1288
+ setErrorMessage(error instanceof Error ? error.message : 'Deletion failed');
1289
+ }
1290
+ }, [
1291
+ state.nodeModules,
1292
+ exit
1293
+ ]);
1294
+ const handleCancelDelete = useCallback(()=>{
1295
+ setShowConfirm(false);
1296
+ }, []);
1297
+ // ============================================
1298
+ // Render
1299
+ // ============================================
1300
+ // If showing help overlay
1301
+ if (showHelp) {
1302
+ return /*#__PURE__*/ React.createElement(Box, {
1303
+ flexDirection: "column",
1304
+ height: stdout.rows
1305
+ }, /*#__PURE__*/ React.createElement(Header, {
1306
+ statistics: statistics,
1307
+ isScanning: state.isScanning,
1308
+ scanProgress: state.scanProgress
1309
+ }), /*#__PURE__*/ React.createElement(Box, {
1310
+ flexGrow: 1,
1311
+ alignItems: "center",
1312
+ justifyContent: "center"
1313
+ }, /*#__PURE__*/ React.createElement(Help, {
1314
+ onClose: ()=>setShowHelp(false)
1315
+ })));
1316
+ }
1317
+ // If showing confirmation dialog
1318
+ if (showConfirm) {
1319
+ const preview = generateDeletionPreview(filteredItems);
1320
+ return /*#__PURE__*/ React.createElement(Box, {
1321
+ flexDirection: "column",
1322
+ height: stdout.rows
1323
+ }, /*#__PURE__*/ React.createElement(Header, {
1324
+ statistics: statistics,
1325
+ isScanning: state.isScanning,
1326
+ scanProgress: state.scanProgress
1327
+ }), /*#__PURE__*/ React.createElement(Box, {
1328
+ flexGrow: 1,
1329
+ alignItems: "center",
1330
+ justifyContent: "center"
1331
+ }, /*#__PURE__*/ React.createElement(ConfirmDialog, {
1332
+ message: preview,
1333
+ onConfirm: handleConfirmDelete,
1334
+ onCancel: handleCancelDelete
1335
+ })));
1336
+ }
1337
+ // If showing progress
1338
+ if (showProgress) {
1339
+ return /*#__PURE__*/ React.createElement(Box, {
1340
+ flexDirection: "column",
1341
+ height: stdout.rows
1342
+ }, /*#__PURE__*/ React.createElement(Header, {
1343
+ statistics: statistics,
1344
+ isScanning: state.isScanning,
1345
+ scanProgress: state.scanProgress
1346
+ }), /*#__PURE__*/ React.createElement(Box, {
1347
+ flexGrow: 1,
1348
+ alignItems: "center",
1349
+ justifyContent: "center"
1350
+ }, /*#__PURE__*/ React.createElement(ProgressBar, {
1351
+ current: progressState.current,
1352
+ total: progressState.total,
1353
+ label: progressState.label,
1354
+ operation: "Deleting"
1355
+ })));
1356
+ }
1357
+ // If showing filter input
1358
+ if (showFilter) {
1359
+ return /*#__PURE__*/ React.createElement(Box, {
1360
+ flexDirection: "column",
1361
+ height: stdout.rows
1362
+ }, /*#__PURE__*/ React.createElement(Header, {
1363
+ statistics: statistics,
1364
+ isScanning: state.isScanning,
1365
+ scanProgress: state.scanProgress
1366
+ }), /*#__PURE__*/ React.createElement(Box, {
1367
+ flexGrow: 1,
1368
+ alignItems: "center",
1369
+ justifyContent: "center",
1370
+ borderStyle: "single",
1371
+ padding: 2
1372
+ }, /*#__PURE__*/ React.createElement(Text, null, "Filter: "), /*#__PURE__*/ React.createElement(Text, {
1373
+ color: "cyan"
1374
+ }, filterInput), /*#__PURE__*/ React.createElement(Text, {
1375
+ color: "gray"
1376
+ }, "_")), /*#__PURE__*/ React.createElement(Footer, {
1377
+ sortBy: state.sortBy,
1378
+ filterQuery: state.filterQuery
1379
+ }));
1380
+ }
1381
+ // Main TUI view
1382
+ return /*#__PURE__*/ React.createElement(Box, {
1383
+ flexDirection: "column",
1384
+ height: stdout.rows
1385
+ }, /*#__PURE__*/ React.createElement(Header, {
1386
+ statistics: statistics,
1387
+ isScanning: state.isScanning,
1388
+ scanProgress: state.scanProgress
1389
+ }), errorMessage && /*#__PURE__*/ React.createElement(Box, {
1390
+ padding: 1
1391
+ }, /*#__PURE__*/ React.createElement(Text, {
1392
+ color: "red"
1393
+ }, "āš ļø ", errorMessage)), /*#__PURE__*/ React.createElement(Box, {
1394
+ flexGrow: 1,
1395
+ overflow: "hidden"
1396
+ }, /*#__PURE__*/ React.createElement(List, {
1397
+ items: filteredItems,
1398
+ selectedIndex: state.selectedIndex,
1399
+ visibleCount: visibleListCount
1400
+ })), /*#__PURE__*/ React.createElement(Footer, {
1401
+ sortBy: state.sortBy,
1402
+ filterQuery: state.filterQuery
1403
+ }));
1404
+ };
1405
+
1406
+ /**
1407
+ * Parse command-line arguments into structured config.
1408
+ *
1409
+ * Supports:
1410
+ * - Positional path argument
1411
+ * - --scan (quick scan mode)
1412
+ * - --auto (auto-delete mode)
1413
+ * - --dry-run (simulate deletions)
1414
+ * - --min-size (size threshold)
1415
+ * - --yes (skip confirmations)
1416
+ * - --json (JSON output)
1417
+ * - --help (show help)
1418
+ * - --version (show version)
1419
+ *
1420
+ * @param args - Raw command-line arguments
1421
+ * @returns Parsed configuration
1422
+ */ function parseArgs(args) {
1423
+ const result = {
1424
+ path: process.cwd(),
1425
+ scan: false,
1426
+ auto: false,
1427
+ dryRun: false,
1428
+ yes: false,
1429
+ json: false,
1430
+ help: false,
1431
+ version: false
1432
+ };
1433
+ for(let i = 0; i < args.length; i++){
1434
+ const arg = args[i];
1435
+ switch(arg){
1436
+ case '--scan':
1437
+ result.scan = true;
1438
+ break;
1439
+ case '--auto':
1440
+ result.auto = true;
1441
+ break;
1442
+ case '--dry-run':
1443
+ result.dryRun = true;
1444
+ break;
1445
+ case '--yes':
1446
+ case '-y':
1447
+ result.yes = true;
1448
+ break;
1449
+ case '--json':
1450
+ result.json = true;
1451
+ break;
1452
+ case '--help':
1453
+ case '-h':
1454
+ result.help = true;
1455
+ break;
1456
+ case '--version':
1457
+ case '-v':
1458
+ result.version = true;
1459
+ break;
1460
+ case '--min-size':
1461
+ result.minSize = args[++i];
1462
+ break;
1463
+ default:
1464
+ // If it doesn't start with -, treat as path
1465
+ if (!arg.startsWith('-')) {
1466
+ result.path = arg;
1467
+ }
1468
+ break;
1469
+ }
1470
+ }
1471
+ return result;
1472
+ }
1473
+ /**
1474
+ * Show help message and exit.
1475
+ */ function showHelp() {
1476
+ console.log(`
1477
+ oh-my-node-modules - Visualize and clean up node_modules directories
1478
+
1479
+ Usage:
1480
+ onm [path] [options]
1481
+
1482
+ Arguments:
1483
+ path Directory to scan (default: current directory)
1484
+
1485
+ Options:
1486
+ --scan Quick scan mode (no TUI, just report)
1487
+ --auto Auto-delete mode (no TUI, delete matching criteria)
1488
+ --dry-run Simulate deletions without actually deleting
1489
+ --min-size <size> Minimum size threshold (e.g., 1gb, 500mb)
1490
+ --yes, -y Skip confirmations
1491
+ --json Output as JSON
1492
+ --help, -h Show this help message
1493
+ --version, -v Show version
1494
+
1495
+ Interactive Mode (default):
1496
+ onm Start interactive TUI
1497
+ onm /path/to/projects Scan specific directory
1498
+
1499
+ Quick Scan Mode:
1500
+ onm --scan Quick scan and summary
1501
+ onm --scan --json Output JSON report
1502
+
1503
+ Auto-Delete Mode:
1504
+ onm --auto --min-size 1gb --yes Delete all >1GB without prompting
1505
+ onm --auto --dry-run Preview what would be deleted
1506
+
1507
+ Keyboard Shortcuts (Interactive Mode):
1508
+ ↑/↓ or j/k Navigate
1509
+ Space or Enter Toggle selection
1510
+ a Select all
1511
+ n Deselect all
1512
+ i Invert selection
1513
+ > Select larger than current
1514
+ d Delete selected
1515
+ s Change sort order
1516
+ f Filter/search
1517
+ q Quit
1518
+ ? Show help
1519
+
1520
+ Examples:
1521
+ # Start interactive TUI in current directory
1522
+ onm
1523
+
1524
+ # Scan specific directory
1525
+ onm ~/projects
1526
+
1527
+ # Quick scan and report
1528
+ onm --scan
1529
+
1530
+ # Preview deletion of large node_modules
1531
+ onm --auto --min-size 500mb --dry-run
1532
+
1533
+ # Delete all node_modules >1GB without confirmation
1534
+ onm --auto --min-size 1gb --yes
1535
+ `);
1536
+ }
1537
+ /**
1538
+ * Show version and exit.
1539
+ */ function showVersion() {
1540
+ console.log('oh-my-node-modules v1.0.0');
1541
+ }
1542
+ /**
1543
+ * Quick scan mode - scan and report without TUI.
1544
+ */ async function runQuickScan(args) {
1545
+ console.log('Scanning for node_modules...\n');
1546
+ try {
1547
+ const excludePatterns = await loadIgnorePatterns();
1548
+ const result = await scanForNodeModules({
1549
+ rootPath: args.path,
1550
+ excludePatterns,
1551
+ followSymlinks: false
1552
+ });
1553
+ const sorted = sortNodeModules(result.nodeModules, 'size-desc');
1554
+ const stats = calculateStatistics(sorted);
1555
+ if (args.json) {
1556
+ console.log(JSON.stringify({
1557
+ summary: stats,
1558
+ nodeModules: sorted.map((nm)=>({
1559
+ path: nm.path,
1560
+ projectName: nm.projectName,
1561
+ projectVersion: nm.projectVersion,
1562
+ sizeBytes: nm.sizeBytes,
1563
+ sizeFormatted: nm.sizeFormatted,
1564
+ packageCount: nm.packageCount,
1565
+ lastModified: nm.lastModified,
1566
+ lastModifiedFormatted: nm.lastModifiedFormatted
1567
+ }))
1568
+ }, null, 2));
1569
+ } else {
1570
+ console.log(`Found ${stats.totalProjects} projects with node_modules`);
1571
+ console.log(`Total size: ${stats.totalSizeFormatted}\n`);
1572
+ if (sorted.length > 0) {
1573
+ console.log('Projects (sorted by size):');
1574
+ console.log('─'.repeat(80));
1575
+ for (const nm of sorted){
1576
+ const warning = nm.sizeCategory === 'huge' ? ' āš ļø' : '';
1577
+ console.log(`${nm.sizeFormatted.padStart(10)} ${nm.projectName}${warning} [${nm.lastModifiedFormatted}]`);
1578
+ }
1579
+ }
1580
+ if (result.errors.length > 0) {
1581
+ console.log(`\nāš ļø ${result.errors.length} errors during scan`);
1582
+ }
1583
+ }
1584
+ } catch (error) {
1585
+ console.error('Scan failed:', error instanceof Error ? error.message : error);
1586
+ process.exit(1);
1587
+ }
1588
+ }
1589
+ /**
1590
+ * Auto-delete mode - delete without TUI.
1591
+ */ async function runAutoDelete(args) {
1592
+ console.log('Scanning for node_modules...\n');
1593
+ try {
1594
+ const excludePatterns = await loadIgnorePatterns();
1595
+ const result = await scanForNodeModules({
1596
+ rootPath: args.path,
1597
+ excludePatterns,
1598
+ followSymlinks: false
1599
+ });
1600
+ let items = result.nodeModules;
1601
+ // Apply size filter if specified
1602
+ if (args.minSize) {
1603
+ const minBytes = parseSize(args.minSize);
1604
+ if (minBytes) {
1605
+ items = selectBySize(items, minBytes);
1606
+ }
1607
+ }
1608
+ const selected = items.filter((nm)=>nm.selected);
1609
+ if (selected.length === 0) {
1610
+ console.log('No node_modules match the criteria.');
1611
+ return;
1612
+ }
1613
+ // Show preview
1614
+ const preview = generateDeletionPreview(items);
1615
+ console.log(preview);
1616
+ // Confirm unless --yes
1617
+ if (!args.yes) {
1618
+ // In real implementation, would use clack prompts here
1619
+ console.log('Use --yes to proceed without confirmation.');
1620
+ return;
1621
+ }
1622
+ // Delete
1623
+ const options = {
1624
+ dryRun: args.dryRun,
1625
+ yes: true,
1626
+ checkRunningProcesses: true,
1627
+ showProgress: true
1628
+ };
1629
+ console.log(args.dryRun ? '\n[DRY RUN] No files will be deleted\n' : '\n');
1630
+ const deleteResult = await deleteSelectedNodeModules(items, options);
1631
+ console.log(`\n${'─'.repeat(80)}`);
1632
+ console.log(`Deleted: ${deleteResult.successful}/${deleteResult.totalAttempted}`);
1633
+ console.log(`Space ${args.dryRun ? 'that would be' : ''} freed: ${deleteResult.formattedBytesFreed}`);
1634
+ if (deleteResult.failed > 0) {
1635
+ console.log(`Failed: ${deleteResult.failed}`);
1636
+ process.exit(1);
1637
+ }
1638
+ } catch (error) {
1639
+ console.error('Operation failed:', error instanceof Error ? error.message : error);
1640
+ process.exit(1);
1641
+ }
1642
+ }
1643
+ /**
1644
+ * Main entry point.
1645
+ * Parses arguments and routes to appropriate mode.
1646
+ */ async function main() {
1647
+ const args = parseArgs(process.argv.slice(2));
1648
+ if (args.help) {
1649
+ showHelp();
1650
+ process.exit(0);
1651
+ }
1652
+ if (args.version) {
1653
+ showVersion();
1654
+ process.exit(0);
1655
+ }
1656
+ if (args.auto) {
1657
+ await runAutoDelete(args);
1658
+ process.exit(0);
1659
+ }
1660
+ if (args.scan) {
1661
+ await runQuickScan(args);
1662
+ process.exit(0);
1663
+ }
1664
+ // Interactive TUI mode (default)
1665
+ const { waitUntilExit } = render(/*#__PURE__*/ React.createElement(App, {
1666
+ rootPath: args.path
1667
+ }));
1668
+ await waitUntilExit();
1669
+ }
1670
+ // Run main and handle errors
1671
+ main().catch((error)=>{
1672
+ console.error('Fatal error:', error);
1673
+ process.exit(1);
1674
+ });