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