next-prune 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/app.js +474 -0
- package/dist/cli.js +84 -0
- package/dist/scanner.js +125 -0
- package/package.json +99 -0
- package/readme.md +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 The next-prune Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { findNextCaches, getDirSize, FRAMES, human } from './scanner.js';
|
|
6
|
+
|
|
7
|
+
// Scanner utilities are imported from ./scanner.js
|
|
8
|
+
|
|
9
|
+
function useSpinner(active) {
|
|
10
|
+
const [i, setI] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!active) return;
|
|
13
|
+
const t = setInterval(() => setI(x => (x + 1) % FRAMES.length), 80);
|
|
14
|
+
return () => clearInterval(t);
|
|
15
|
+
}, [active]);
|
|
16
|
+
return FRAMES[i];
|
|
17
|
+
}
|
|
18
|
+
function useTerminalCols() {
|
|
19
|
+
const [cols, setCols] = useState(process.stdout?.columns || 80);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const onResize = () => setCols(process.stdout?.columns || 80);
|
|
22
|
+
process.stdout?.on?.('resize', onResize);
|
|
23
|
+
return () => process.stdout?.off?.('resize', onResize);
|
|
24
|
+
}, []);
|
|
25
|
+
return cols;
|
|
26
|
+
}
|
|
27
|
+
function useTerminalRows() {
|
|
28
|
+
const [rows, setRows] = useState(process.stdout?.rows || 24);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const onResize = () => setRows(process.stdout?.rows || 24);
|
|
31
|
+
process.stdout?.on?.('resize', onResize);
|
|
32
|
+
return () => process.stdout?.off?.('resize', onResize);
|
|
33
|
+
}, []);
|
|
34
|
+
return rows;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Greedy pack segments into up to maxLines based on terminal width
|
|
38
|
+
function packSegments(segments, cols, maxLines = 3) {
|
|
39
|
+
const sep = ' • ';
|
|
40
|
+
const prefixLen = 3; // approx width of '🎮 '
|
|
41
|
+
const max = Math.max(24, (cols || 80) - 6); // leave room for borders/padding
|
|
42
|
+
const lines = [];
|
|
43
|
+
let cur = [];
|
|
44
|
+
let curLen = prefixLen; // first line has a small prefix
|
|
45
|
+
const pushLine = () => {
|
|
46
|
+
if (cur.length > 0) lines.push(cur);
|
|
47
|
+
cur = [];
|
|
48
|
+
curLen = 0;
|
|
49
|
+
};
|
|
50
|
+
for (const seg of segments) {
|
|
51
|
+
const segText = `${seg.key} ${seg.label}`;
|
|
52
|
+
const addLen = (cur.length === 0 ? 0 : sep.length) + segText.length;
|
|
53
|
+
const isLastLine = lines.length >= maxLines - 1;
|
|
54
|
+
if (!isLastLine && cur.length > 0 && curLen + addLen > max) {
|
|
55
|
+
pushLine();
|
|
56
|
+
}
|
|
57
|
+
cur.push(seg);
|
|
58
|
+
curLen += cur.length === 1 ? segText.length : addLen;
|
|
59
|
+
}
|
|
60
|
+
if (cur.length > 0) lines.push(cur);
|
|
61
|
+
return lines.slice(0, maxLines);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Similar to packSegments but for {label, value} pairs
|
|
65
|
+
function packLabeledSegments(segments, cols, prefixLen = 0, maxLines = 3) {
|
|
66
|
+
const sepLen = 3; // ' • '
|
|
67
|
+
const max = Math.max(24, (cols || 80) - 6);
|
|
68
|
+
const lines = [];
|
|
69
|
+
let cur = [];
|
|
70
|
+
let curLen = prefixLen;
|
|
71
|
+
const pushLine = () => {
|
|
72
|
+
if (cur.length > 0) lines.push(cur);
|
|
73
|
+
cur = [];
|
|
74
|
+
curLen = 0;
|
|
75
|
+
};
|
|
76
|
+
for (const seg of segments) {
|
|
77
|
+
const segLen = seg.label.length + 1 + String(seg.value).length; // space before value
|
|
78
|
+
const addLen = (cur.length === 0 ? 0 : sepLen) + segLen;
|
|
79
|
+
if (cur.length > 0 && curLen + addLen > max) {
|
|
80
|
+
pushLine();
|
|
81
|
+
}
|
|
82
|
+
cur.push(seg);
|
|
83
|
+
curLen += cur.length === 1 ? segLen : addLen;
|
|
84
|
+
}
|
|
85
|
+
if (cur.length > 0) lines.push(cur);
|
|
86
|
+
return lines.slice(0, maxLines);
|
|
87
|
+
}
|
|
88
|
+
function truncateMiddle(text, max) {
|
|
89
|
+
if (!text) return '';
|
|
90
|
+
if (max <= 0) return '';
|
|
91
|
+
if (text.length <= max) return text;
|
|
92
|
+
if (max <= 1) return '…';
|
|
93
|
+
const head = Math.ceil((max - 1) / 2);
|
|
94
|
+
const tail = Math.floor((max - 1) / 2);
|
|
95
|
+
return text.slice(0, head) + '…' + text.slice(text.length - tail);
|
|
96
|
+
}
|
|
97
|
+
export default function App({
|
|
98
|
+
cwd = process.cwd(),
|
|
99
|
+
dryRun = false,
|
|
100
|
+
confirmImmediately = false,
|
|
101
|
+
testMode = false
|
|
102
|
+
}) {
|
|
103
|
+
const [items, setItems] = useState([]); // {path, size, status}
|
|
104
|
+
const [loading, setLoading] = useState(!testMode);
|
|
105
|
+
const [selected, setSelected] = useState(new Set());
|
|
106
|
+
const [index, setIndex] = useState(0);
|
|
107
|
+
const [confirm, setConfirm] = useState(false);
|
|
108
|
+
const [error, setError] = useState('');
|
|
109
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
110
|
+
const spinner = useSpinner(loading);
|
|
111
|
+
const cols = useTerminalCols();
|
|
112
|
+
const rows = useTerminalRows();
|
|
113
|
+
const hasSelection = selected.size > 0;
|
|
114
|
+
const footerSegments = useMemo(() => {
|
|
115
|
+
if (hasSelection) {
|
|
116
|
+
return [{
|
|
117
|
+
key: 'Space',
|
|
118
|
+
label: 'toggle'
|
|
119
|
+
}, {
|
|
120
|
+
key: 'D/Enter',
|
|
121
|
+
label: 'delete'
|
|
122
|
+
}, {
|
|
123
|
+
key: 'C',
|
|
124
|
+
label: 'clear'
|
|
125
|
+
}, {
|
|
126
|
+
key: 'H',
|
|
127
|
+
label: 'help'
|
|
128
|
+
}, {
|
|
129
|
+
key: 'Q',
|
|
130
|
+
label: 'quit'
|
|
131
|
+
}];
|
|
132
|
+
}
|
|
133
|
+
return [{
|
|
134
|
+
key: '↑↓',
|
|
135
|
+
label: 'move'
|
|
136
|
+
}, {
|
|
137
|
+
key: 'Space',
|
|
138
|
+
label: 'select'
|
|
139
|
+
}, {
|
|
140
|
+
key: 'A',
|
|
141
|
+
label: 'all'
|
|
142
|
+
}, {
|
|
143
|
+
key: 'R',
|
|
144
|
+
label: 'rescan'
|
|
145
|
+
}, {
|
|
146
|
+
key: 'H',
|
|
147
|
+
label: 'help'
|
|
148
|
+
}, {
|
|
149
|
+
key: 'Q',
|
|
150
|
+
label: 'quit'
|
|
151
|
+
}];
|
|
152
|
+
}, [hasSelection]);
|
|
153
|
+
const packedLines = useMemo(() => packSegments(footerSegments, cols, 3), [footerSegments, cols]);
|
|
154
|
+
const totalSize = useMemo(() => {
|
|
155
|
+
let sum = 0;
|
|
156
|
+
for (const it of items) {
|
|
157
|
+
if (it.status !== 'deleted') {
|
|
158
|
+
sum += typeof it.size === 'number' ? it.size : 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return sum;
|
|
162
|
+
}, [items]);
|
|
163
|
+
const selectedSize = useMemo(() => {
|
|
164
|
+
let sum = 0;
|
|
165
|
+
let idx = 0;
|
|
166
|
+
for (const it of items) {
|
|
167
|
+
if (selected.has(idx)) sum += typeof it.size === 'number' ? it.size : 0;
|
|
168
|
+
idx += 1;
|
|
169
|
+
}
|
|
170
|
+
return sum;
|
|
171
|
+
}, [items, selected]);
|
|
172
|
+
const foundCount = useMemo(() => items.filter(it => it.status !== 'deleted').length, [items]);
|
|
173
|
+
const truncatedCwd = useMemo(() => {
|
|
174
|
+
const labelLen = 'Scan Directory:'.length;
|
|
175
|
+
const max = Math.max(10, (cols || 80) - labelLen - 6);
|
|
176
|
+
return truncateMiddle(cwd, max);
|
|
177
|
+
}, [cwd, cols]);
|
|
178
|
+
const scanSegments = useMemo(() => {
|
|
179
|
+
const segs = [{
|
|
180
|
+
label: 'Found:',
|
|
181
|
+
value: `${foundCount} directories`
|
|
182
|
+
}, {
|
|
183
|
+
label: 'Total Size:',
|
|
184
|
+
value: human(totalSize)
|
|
185
|
+
}, {
|
|
186
|
+
label: 'Selected:',
|
|
187
|
+
value: `${selected.size} (${human(selectedSize)})`
|
|
188
|
+
}];
|
|
189
|
+
if (loading) segs.push({
|
|
190
|
+
label: 'Status:',
|
|
191
|
+
value: `${spinner} scanning...`
|
|
192
|
+
});
|
|
193
|
+
return segs;
|
|
194
|
+
}, [foundCount, totalSize, selected.size, selectedSize, loading, spinner]);
|
|
195
|
+
const packedScan = useMemo(() => packLabeledSegments(scanSegments, cols, 0, 3), [scanSegments, cols]);
|
|
196
|
+
|
|
197
|
+
// Viewport sizing to avoid terminal jumping on navigation
|
|
198
|
+
const {
|
|
199
|
+
listBoxHeight,
|
|
200
|
+
viewStart,
|
|
201
|
+
viewEnd
|
|
202
|
+
} = useMemo(() => {
|
|
203
|
+
const rootPad = 2; // root <Box padding={1}> adds 2 rows total
|
|
204
|
+
const titleHeight = 5; // round border + padding + 1 line
|
|
205
|
+
const scanHeight = 5 + packedScan.length; // single border + padding + header + lines
|
|
206
|
+
const errorHeight = error ? 5 : 0; // approx single-line error box
|
|
207
|
+
const footerHeight = showHelp ? 10 : 4 + packedLines.length; // help ~6 lines + borders/padding => 10; else compact footer
|
|
208
|
+
const confirmHeight = confirm ? 7 : 0; // confirm box ~3 lines + borders/padding
|
|
209
|
+
const used = rootPad + titleHeight + scanHeight + errorHeight + footerHeight + confirmHeight;
|
|
210
|
+
const totalRows = rows || 24;
|
|
211
|
+
const boxHeight = Math.max(7, totalRows - used); // include list borders+padding
|
|
212
|
+
const inner = Math.max(3, boxHeight - 4); // subtract list border+padding
|
|
213
|
+
|
|
214
|
+
// Center the focused item within the visible window when possible
|
|
215
|
+
const half = Math.floor(inner / 2);
|
|
216
|
+
let start = Math.max(0, index - half);
|
|
217
|
+
let end = start + inner;
|
|
218
|
+
if (end > items.length) {
|
|
219
|
+
end = items.length;
|
|
220
|
+
start = Math.max(0, end - inner);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
listBoxHeight: boxHeight,
|
|
224
|
+
viewStart: start,
|
|
225
|
+
viewEnd: end
|
|
226
|
+
};
|
|
227
|
+
}, [rows, packedScan.length, error, showHelp, packedLines.length, confirm, index, items.length]);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (testMode) return;
|
|
230
|
+
let cancelled = false;
|
|
231
|
+
(async () => {
|
|
232
|
+
try {
|
|
233
|
+
setLoading(true);
|
|
234
|
+
setError('');
|
|
235
|
+
const paths = await findNextCaches(cwd);
|
|
236
|
+
const sizes = await Promise.all(paths.map(p => getDirSize(p)));
|
|
237
|
+
if (cancelled) return;
|
|
238
|
+
const nextItems = [];
|
|
239
|
+
for (let i = 0; i < paths.length; i += 1) nextItems.push({
|
|
240
|
+
path: paths[i],
|
|
241
|
+
size: sizes[i]
|
|
242
|
+
});
|
|
243
|
+
setItems(nextItems);
|
|
244
|
+
// Reset UI focus/help after a fresh scan
|
|
245
|
+
setIndex(0);
|
|
246
|
+
setShowHelp(false);
|
|
247
|
+
} catch (error_) {
|
|
248
|
+
if (!cancelled) setError(String(error_?.message ?? error_));
|
|
249
|
+
} finally {
|
|
250
|
+
if (!cancelled) setLoading(false);
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
253
|
+
return () => {
|
|
254
|
+
cancelled = true;
|
|
255
|
+
};
|
|
256
|
+
}, [cwd, testMode]);
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!confirmImmediately) return;
|
|
259
|
+
if (testMode) return;
|
|
260
|
+
if (items.length === 0) return;
|
|
261
|
+
setSelected(new Set(items.map((_, i) => i)));
|
|
262
|
+
setConfirm(true);
|
|
263
|
+
}, [confirmImmediately, items, testMode]);
|
|
264
|
+
|
|
265
|
+
// Auto-deselect deleted items
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
setSelected(cur => {
|
|
268
|
+
const next = new Set();
|
|
269
|
+
for (const i of cur) {
|
|
270
|
+
if (items[i]?.status === 'deleted') {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
next.add(i);
|
|
274
|
+
}
|
|
275
|
+
if (next.size === cur.size) {
|
|
276
|
+
return cur;
|
|
277
|
+
}
|
|
278
|
+
return next;
|
|
279
|
+
});
|
|
280
|
+
}, [items]);
|
|
281
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
282
|
+
flexDirection: "column",
|
|
283
|
+
padding: 1
|
|
284
|
+
}, /*#__PURE__*/React.createElement(Box, {
|
|
285
|
+
borderStyle: "round",
|
|
286
|
+
borderColor: "green",
|
|
287
|
+
padding: 1,
|
|
288
|
+
marginBottom: 1
|
|
289
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
290
|
+
color: "green",
|
|
291
|
+
bold: true
|
|
292
|
+
}, "\uD83C\uDF3F Next Prune")), /*#__PURE__*/React.createElement(Box, {
|
|
293
|
+
borderStyle: "single",
|
|
294
|
+
borderColor: "gray",
|
|
295
|
+
padding: 1,
|
|
296
|
+
marginBottom: 1
|
|
297
|
+
}, /*#__PURE__*/React.createElement(Box, {
|
|
298
|
+
flexDirection: "column"
|
|
299
|
+
}, /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, {
|
|
300
|
+
color: "cyan"
|
|
301
|
+
}, "Scan Directory:"), /*#__PURE__*/React.createElement(Text, null, " ", truncatedCwd)), /*#__PURE__*/React.createElement(Box, {
|
|
302
|
+
marginTop: 1,
|
|
303
|
+
flexDirection: "column"
|
|
304
|
+
}, packedScan.map((line, li) => /*#__PURE__*/React.createElement(Box, {
|
|
305
|
+
key: li
|
|
306
|
+
}, line.map((seg, si) => /*#__PURE__*/React.createElement(React.Fragment, {
|
|
307
|
+
key: si
|
|
308
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
309
|
+
color: seg.label === 'Status:' ? 'blue' : 'yellow'
|
|
310
|
+
}, seg.label), /*#__PURE__*/React.createElement(Text, null, " ", seg.value), si < line.length - 1 && /*#__PURE__*/React.createElement(Text, null, " \u2022 ")))))))), error && /*#__PURE__*/React.createElement(Box, {
|
|
311
|
+
borderStyle: "single",
|
|
312
|
+
borderColor: error.startsWith('✅') ? 'green' : 'red',
|
|
313
|
+
padding: 1,
|
|
314
|
+
marginBottom: 1
|
|
315
|
+
}, error.startsWith('✅') ? /*#__PURE__*/React.createElement(Text, {
|
|
316
|
+
color: "green",
|
|
317
|
+
bold: true
|
|
318
|
+
}, error) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Text, {
|
|
319
|
+
color: "red",
|
|
320
|
+
bold: true
|
|
321
|
+
}, "\u274C Error:", ' '), /*#__PURE__*/React.createElement(Text, {
|
|
322
|
+
color: "red"
|
|
323
|
+
}, error))), /*#__PURE__*/React.createElement(Box, {
|
|
324
|
+
flexDirection: "column",
|
|
325
|
+
borderStyle: "single",
|
|
326
|
+
padding: 1,
|
|
327
|
+
height: listBoxHeight
|
|
328
|
+
}, items.length === 0 && !loading ? /*#__PURE__*/React.createElement(Box, {
|
|
329
|
+
justifyContent: "center",
|
|
330
|
+
alignItems: "center",
|
|
331
|
+
minHeight: 5
|
|
332
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
333
|
+
color: "green"
|
|
334
|
+
}, "\u2705 No build artifacts found - your project is clean!")) : items.slice(viewStart, viewEnd).map((it, offset) => {
|
|
335
|
+
const i = viewStart + offset;
|
|
336
|
+
const rel = path.relative(cwd, it.path) || '.';
|
|
337
|
+
const isSel = selected.has(i);
|
|
338
|
+
const isFocus = i === index;
|
|
339
|
+
const prefix = isFocus ? '>' : ' ';
|
|
340
|
+
const mark = isSel ? '[x]' : '[ ]';
|
|
341
|
+
const sizeText = it.size === undefined || it.size === null ? '…' : human(it.size);
|
|
342
|
+
let statusColor = undefined;
|
|
343
|
+
let statusText = '';
|
|
344
|
+
if (it.status === 'deleted') {
|
|
345
|
+
// No inline indicator for deleted items; styling conveys state
|
|
346
|
+
} else if (it.status === 'error') {
|
|
347
|
+
statusColor = 'red';
|
|
348
|
+
statusText = ' ❌ error';
|
|
349
|
+
} else if (it.status === 'dry-run') {
|
|
350
|
+
statusColor = 'yellow';
|
|
351
|
+
statusText = ' 🔍 dry-run';
|
|
352
|
+
} else if (it.status === 'deleting') {
|
|
353
|
+
statusColor = 'blue';
|
|
354
|
+
statusText = ' 🗑️ deleting...';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Ensure single-line rendering to avoid terminal scroll jumps
|
|
358
|
+
const containerWidth = Math.max(24, (cols || 80) - 6);
|
|
359
|
+
const leftPart = `${prefix} ${mark} ${sizeText.padStart(7)} `;
|
|
360
|
+
const reserved = leftPart.length + (statusText ? statusText.length : 0);
|
|
361
|
+
const maxRel = Math.max(3, containerWidth - reserved);
|
|
362
|
+
const displayRel = truncateMiddle(rel, maxRel);
|
|
363
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
364
|
+
key: it.path
|
|
365
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
366
|
+
color: it.status === 'deleted' ? 'gray' : isFocus ? 'cyan' : undefined,
|
|
367
|
+
backgroundColor: isFocus && it.status !== 'deleted' ? 'blue' : undefined,
|
|
368
|
+
dimColor: it.status === 'deleted',
|
|
369
|
+
strikethrough: it.status === 'deleted'
|
|
370
|
+
}, leftPart, displayRel), statusText && /*#__PURE__*/React.createElement(Text, {
|
|
371
|
+
color: statusColor
|
|
372
|
+
}, statusText));
|
|
373
|
+
})), /*#__PURE__*/React.createElement(Box, {
|
|
374
|
+
borderStyle: "single",
|
|
375
|
+
borderColor: "gray",
|
|
376
|
+
padding: 1,
|
|
377
|
+
marginTop: 1
|
|
378
|
+
}, showHelp ? /*#__PURE__*/React.createElement(Box, {
|
|
379
|
+
flexDirection: "column"
|
|
380
|
+
}, /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, {
|
|
381
|
+
color: "cyan",
|
|
382
|
+
bold: true
|
|
383
|
+
}, "\uD83D\uDCD6 Help"), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, {
|
|
384
|
+
dimColor: true
|
|
385
|
+
}, "(Esc to close)")), /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Box, {
|
|
386
|
+
flexDirection: "column",
|
|
387
|
+
marginRight: 2
|
|
388
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
389
|
+
dimColor: true
|
|
390
|
+
}, "Navigation"), /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
|
|
391
|
+
color: "cyan"
|
|
392
|
+
}, "\u2191\u2193"), /*#__PURE__*/React.createElement(Text, {
|
|
393
|
+
dimColor: true
|
|
394
|
+
}, " move \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
395
|
+
color: "cyan"
|
|
396
|
+
}, "Home/End"), /*#__PURE__*/React.createElement(Text, {
|
|
397
|
+
dimColor: true
|
|
398
|
+
}, " jump \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
399
|
+
color: "cyan"
|
|
400
|
+
}, "PgUp/PgDn"), /*#__PURE__*/React.createElement(Text, {
|
|
401
|
+
dimColor: true
|
|
402
|
+
}, " page")), /*#__PURE__*/React.createElement(Text, null), /*#__PURE__*/React.createElement(Text, {
|
|
403
|
+
dimColor: true
|
|
404
|
+
}, "Selection"), /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
|
|
405
|
+
color: "cyan"
|
|
406
|
+
}, "Space"), /*#__PURE__*/React.createElement(Text, {
|
|
407
|
+
dimColor: true
|
|
408
|
+
}, " select \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
409
|
+
color: "cyan"
|
|
410
|
+
}, "A"), /*#__PURE__*/React.createElement(Text, {
|
|
411
|
+
dimColor: true
|
|
412
|
+
}, " all \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
413
|
+
color: "cyan"
|
|
414
|
+
}, "C"), /*#__PURE__*/React.createElement(Text, {
|
|
415
|
+
dimColor: true
|
|
416
|
+
}, " clear"))), /*#__PURE__*/React.createElement(Box, {
|
|
417
|
+
flexDirection: "column"
|
|
418
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
419
|
+
dimColor: true
|
|
420
|
+
}, "Actions"), /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
|
|
421
|
+
color: "cyan"
|
|
422
|
+
}, "D/Enter"), /*#__PURE__*/React.createElement(Text, {
|
|
423
|
+
dimColor: true
|
|
424
|
+
}, " delete \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
425
|
+
color: "cyan"
|
|
426
|
+
}, "R"), /*#__PURE__*/React.createElement(Text, {
|
|
427
|
+
dimColor: true
|
|
428
|
+
}, " rescan")), /*#__PURE__*/React.createElement(Text, null), /*#__PURE__*/React.createElement(Text, {
|
|
429
|
+
dimColor: true
|
|
430
|
+
}, "App"), /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
|
|
431
|
+
color: "cyan"
|
|
432
|
+
}, "H/?"), /*#__PURE__*/React.createElement(Text, {
|
|
433
|
+
dimColor: true
|
|
434
|
+
}, " help \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
435
|
+
color: "cyan"
|
|
436
|
+
}, "Q"), /*#__PURE__*/React.createElement(Text, {
|
|
437
|
+
dimColor: true
|
|
438
|
+
}, " quit"))))) : /*#__PURE__*/React.createElement(Box, {
|
|
439
|
+
flexDirection: "column"
|
|
440
|
+
}, packedLines.map((line, li) => /*#__PURE__*/React.createElement(Box, {
|
|
441
|
+
key: li
|
|
442
|
+
}, li === 0 && /*#__PURE__*/React.createElement(Text, {
|
|
443
|
+
dimColor: true
|
|
444
|
+
}, "\uD83C\uDFAE "), line.map((seg, si) => /*#__PURE__*/React.createElement(React.Fragment, {
|
|
445
|
+
key: si
|
|
446
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
447
|
+
color: "cyan"
|
|
448
|
+
}, seg.key), /*#__PURE__*/React.createElement(Text, {
|
|
449
|
+
dimColor: true
|
|
450
|
+
}, " ", seg.label), si < line.length - 1 && /*#__PURE__*/React.createElement(Text, {
|
|
451
|
+
dimColor: true
|
|
452
|
+
}, " \u2022 "))))))), confirm && /*#__PURE__*/React.createElement(Box, {
|
|
453
|
+
borderStyle: "double",
|
|
454
|
+
borderColor: "yellow",
|
|
455
|
+
padding: 1,
|
|
456
|
+
marginTop: 1
|
|
457
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
458
|
+
color: "yellow",
|
|
459
|
+
bold: true
|
|
460
|
+
}, "\u26A0\uFE0F Confirm Deletion"), /*#__PURE__*/React.createElement(Text, null, "Delete ", selected.size, " directories (", human(selectedSize), ")", dryRun ? ' [dry-run mode]' : '', "?"), /*#__PURE__*/React.createElement(Box, {
|
|
461
|
+
marginTop: 1
|
|
462
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
463
|
+
color: "green",
|
|
464
|
+
bold: true
|
|
465
|
+
}, "[Y]es"), /*#__PURE__*/React.createElement(Text, null, " \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
466
|
+
color: "red",
|
|
467
|
+
bold: true
|
|
468
|
+
}, "[N]o"), /*#__PURE__*/React.createElement(Text, null, " \u2022 "), /*#__PURE__*/React.createElement(Text, {
|
|
469
|
+
color: "gray",
|
|
470
|
+
bold: true
|
|
471
|
+
}, "Esc"), /*#__PURE__*/React.createElement(Text, {
|
|
472
|
+
color: "gray"
|
|
473
|
+
}, " cancel"))));
|
|
474
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import meow from 'meow';
|
|
5
|
+
import { scanWithSizes, human } from './scanner.js';
|
|
6
|
+
const cli = meow(`
|
|
7
|
+
Usage
|
|
8
|
+
$ next-prune [options]
|
|
9
|
+
|
|
10
|
+
Description
|
|
11
|
+
Scan for Next.js build artifacts (.next, out), Vercel outputs (.vercel/output),
|
|
12
|
+
Turborepo caches (.turbo), and other safe-to-delete directories.
|
|
13
|
+
|
|
14
|
+
Options
|
|
15
|
+
--yes, -y Skip confirmation and delete selected immediately
|
|
16
|
+
--dry-run Don't delete anything; just show results
|
|
17
|
+
--cwd=<path> Directory to scan (default: current working dir)
|
|
18
|
+
--list Non-interactive list of artifacts and sizes, then exit
|
|
19
|
+
--json Output JSON (implies --list)
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
$ next-prune
|
|
23
|
+
$ next-prune --dry-run
|
|
24
|
+
$ next-prune -y --cwd=./packages
|
|
25
|
+
`, {
|
|
26
|
+
importMeta: import.meta,
|
|
27
|
+
flags: {
|
|
28
|
+
yes: {
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
shortFlag: 'y',
|
|
31
|
+
default: false
|
|
32
|
+
},
|
|
33
|
+
dryRun: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
default: false
|
|
36
|
+
},
|
|
37
|
+
cwd: {
|
|
38
|
+
type: 'string'
|
|
39
|
+
},
|
|
40
|
+
list: {
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
default: false
|
|
43
|
+
},
|
|
44
|
+
json: {
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
default: false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const props = {
|
|
51
|
+
confirmImmediately: Boolean(cli.flags.yes),
|
|
52
|
+
dryRun: Boolean(cli.flags.dryRun),
|
|
53
|
+
cwd: cli.flags.cwd ?? process.cwd()
|
|
54
|
+
};
|
|
55
|
+
async function main() {
|
|
56
|
+
if (cli.flags.list || cli.flags.json) {
|
|
57
|
+
const items = await scanWithSizes(props.cwd);
|
|
58
|
+
if (cli.flags.json) {
|
|
59
|
+
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let total = 0;
|
|
63
|
+
for (const it of items) total += typeof it.size === 'number' ? it.size : 0;
|
|
64
|
+
for (const it of items) {
|
|
65
|
+
const rel = path.relative(props.cwd, it.path) || '.';
|
|
66
|
+
process.stdout.write(`${human(it.size).padStart(6)} ${rel}\n`);
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(`\nTotal: ${human(total)} in ${items.length} directorie${items.length === 1 ? '' : 's'}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const reactModule = await import('react');
|
|
72
|
+
const React = reactModule.default;
|
|
73
|
+
const ink = await import('ink');
|
|
74
|
+
const {
|
|
75
|
+
render
|
|
76
|
+
} = ink;
|
|
77
|
+
const {
|
|
78
|
+
default: App
|
|
79
|
+
} = await import('./app.js');
|
|
80
|
+
render(React.createElement(App, props));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// eslint-disable-next-line unicorn/prefer-top-level-await
|
|
84
|
+
main();
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
export const FRAMES = Object.freeze(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']);
|
|
4
|
+
|
|
5
|
+
// Patterns for Next.js and related build artifacts/caches
|
|
6
|
+
const CACHE_PATTERNS = ['.next',
|
|
7
|
+
// Next.js build output and cache
|
|
8
|
+
'out',
|
|
9
|
+
// Next.js static export output
|
|
10
|
+
'.vercel/output',
|
|
11
|
+
// Vercel Build Output API bundle
|
|
12
|
+
'.turbo',
|
|
13
|
+
// Turborepo cache
|
|
14
|
+
'.vercel_build_output',
|
|
15
|
+
// Legacy Vercel build output
|
|
16
|
+
'node_modules/.cache/next' // Next.js cache in node_modules
|
|
17
|
+
];
|
|
18
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', ...CACHE_PATTERNS]);
|
|
19
|
+
export const human = bytes => {
|
|
20
|
+
if (!bytes) return '0 B';
|
|
21
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
22
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
23
|
+
const value = bytes / 1024 ** i;
|
|
24
|
+
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
25
|
+
};
|
|
26
|
+
export const dirExists = async p => {
|
|
27
|
+
try {
|
|
28
|
+
const s = await fs.stat(p);
|
|
29
|
+
return s.isDirectory();
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export async function* walk(root) {
|
|
35
|
+
let entries = [];
|
|
36
|
+
try {
|
|
37
|
+
entries = await fs.readdir(root, {
|
|
38
|
+
withFileTypes: true
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (!entry.isDirectory()) continue;
|
|
45
|
+
const full = path.join(root, entry.name);
|
|
46
|
+
yield full;
|
|
47
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
48
|
+
yield* walk(full);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const findNextCaches = async cwd => {
|
|
52
|
+
const results = [];
|
|
53
|
+
|
|
54
|
+
// Check for build artifacts in the root directory
|
|
55
|
+
for (const pattern of CACHE_PATTERNS) {
|
|
56
|
+
const fullPath = path.join(cwd, pattern);
|
|
57
|
+
if (await dirExists(fullPath)) {
|
|
58
|
+
results.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Walk subdirectories to find nested artifacts
|
|
63
|
+
for await (const dir of walk(cwd)) {
|
|
64
|
+
const base = path.basename(dir);
|
|
65
|
+
const relativePath = path.relative(cwd, dir);
|
|
66
|
+
|
|
67
|
+
// Check if this directory matches any of our patterns
|
|
68
|
+
if (CACHE_PATTERNS.includes(base)) {
|
|
69
|
+
results.push(dir);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Special handling for nested patterns like .vercel/output
|
|
74
|
+
for (const pattern of CACHE_PATTERNS) {
|
|
75
|
+
if (pattern.includes('/') && relativePath === pattern) {
|
|
76
|
+
results.push(dir);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Special case for node_modules/.cache/next
|
|
82
|
+
if (base === 'node_modules') {
|
|
83
|
+
const maybe = path.join(dir, '.cache', 'next');
|
|
84
|
+
if (await dirExists(maybe)) results.push(maybe);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...new Set(results)];
|
|
88
|
+
};
|
|
89
|
+
export const getDirSize = async dir => {
|
|
90
|
+
let total = 0;
|
|
91
|
+
const processDir = async d => {
|
|
92
|
+
let entries = [];
|
|
93
|
+
try {
|
|
94
|
+
entries = await fs.readdir(d, {
|
|
95
|
+
withFileTypes: true
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
const paths = entries.map(entry => path.join(d, entry.name));
|
|
101
|
+
const stats = await Promise.all(paths.map(p => fs.lstat(p).catch(() => undefined)));
|
|
102
|
+
let sizeHere = 0;
|
|
103
|
+
const subdirs = [];
|
|
104
|
+
for (const [i, st] of stats.entries()) {
|
|
105
|
+
if (!st) continue;
|
|
106
|
+
if (st.isSymbolicLink()) continue;
|
|
107
|
+
if (st.isDirectory()) subdirs.push(paths[i]);else sizeHere += st.size;
|
|
108
|
+
}
|
|
109
|
+
const subSizes = await Promise.all(subdirs.map(d2 => processDir(d2)));
|
|
110
|
+
for (const s of subSizes) sizeHere += s;
|
|
111
|
+
return sizeHere;
|
|
112
|
+
};
|
|
113
|
+
total = await processDir(dir);
|
|
114
|
+
return total;
|
|
115
|
+
};
|
|
116
|
+
export const scanWithSizes = async cwd => {
|
|
117
|
+
const paths = await findNextCaches(cwd);
|
|
118
|
+
const sizes = await Promise.all(paths.map(p => getDirSize(p)));
|
|
119
|
+
const items = [];
|
|
120
|
+
for (let i = 0; i < paths.length; i += 1) items.push({
|
|
121
|
+
path: paths[i],
|
|
122
|
+
size: sizes[i]
|
|
123
|
+
});
|
|
124
|
+
return items;
|
|
125
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-prune",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive terminal UI to prune Next.js build artifacts and caches to free disk space",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "next-prune contributors",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/khoa-lucents/next-prune.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/khoa-lucents/next-prune/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/khoa-lucents/next-prune#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"nextjs",
|
|
17
|
+
"next",
|
|
18
|
+
"prune",
|
|
19
|
+
"build",
|
|
20
|
+
"artifacts",
|
|
21
|
+
"cache",
|
|
22
|
+
"vercel",
|
|
23
|
+
"turbo",
|
|
24
|
+
"clean",
|
|
25
|
+
"cli",
|
|
26
|
+
"terminal",
|
|
27
|
+
"ui",
|
|
28
|
+
"ink",
|
|
29
|
+
"disk-space",
|
|
30
|
+
"webpack-cache"
|
|
31
|
+
],
|
|
32
|
+
"bin": {
|
|
33
|
+
"next-prune": "dist/cli.js"
|
|
34
|
+
},
|
|
35
|
+
"type": "module",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=16"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "babel --out-dir=dist source",
|
|
41
|
+
"dev": "babel --out-dir=dist --watch source",
|
|
42
|
+
"test": "prettier --check . && xo && ava",
|
|
43
|
+
"prepublishOnly": "npm run build && npm test"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"ink": "^4.1.0",
|
|
50
|
+
"meow": "^11.0.0",
|
|
51
|
+
"react": "^18.2.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@babel/cli": "^7.21.0",
|
|
55
|
+
"@babel/preset-react": "^7.18.6",
|
|
56
|
+
"@vdemedes/prettier-config": "^2.0.1",
|
|
57
|
+
"ava": "^5.2.0",
|
|
58
|
+
"chalk": "^5.2.0",
|
|
59
|
+
"eslint-config-xo-react": "^0.27.0",
|
|
60
|
+
"eslint-plugin-react": "^7.32.2",
|
|
61
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
62
|
+
"import-jsx": "^5.0.0",
|
|
63
|
+
"ink-testing-library": "^3.0.0",
|
|
64
|
+
"prettier": "^2.8.7",
|
|
65
|
+
"xo": "^0.53.1"
|
|
66
|
+
},
|
|
67
|
+
"ava": {
|
|
68
|
+
"environmentVariables": {
|
|
69
|
+
"NODE_NO_WARNINGS": "1"
|
|
70
|
+
},
|
|
71
|
+
"nodeArguments": [
|
|
72
|
+
"--loader=import-jsx"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"xo": {
|
|
76
|
+
"extends": "xo-react",
|
|
77
|
+
"prettier": true,
|
|
78
|
+
"rules": {
|
|
79
|
+
"react/prop-types": "off",
|
|
80
|
+
"unicorn/expiring-todo-comments": "off",
|
|
81
|
+
"complexity": "off",
|
|
82
|
+
"unicorn/prevent-abbreviations": "off",
|
|
83
|
+
"capitalized-comments": "off",
|
|
84
|
+
"padding-line-between-statements": "off",
|
|
85
|
+
"no-await-in-loop": "off",
|
|
86
|
+
"react/jsx-sort-props": "off",
|
|
87
|
+
"react/no-array-index-key": "off",
|
|
88
|
+
"no-undef-init": "off",
|
|
89
|
+
"unicorn/prefer-switch": "off",
|
|
90
|
+
"react/self-closing-comp": "off"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"prettier": "@vdemedes/prettier-config",
|
|
94
|
+
"babel": {
|
|
95
|
+
"presets": [
|
|
96
|
+
"@babel/preset-react"
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Next Prune 🌿
|
|
2
|
+
|
|
3
|
+
> Prune Next.js build artifacts and caches from your terminal. Interactive TUI to scan and delete `.next`, `out`, `.vercel/output`, `.turbo`, and other safe-to-delete directories to free disk space.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/next-prune)
|
|
6
|
+
[](https://github.com/OWNER/next-prune/actions/workflows/ci.yml)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
|
|
10
|
+
## What Gets Pruned
|
|
11
|
+
|
|
12
|
+
**Safe to delete (recreated by tools):**
|
|
13
|
+
|
|
14
|
+
- `.next/` - Next.js build output and cache
|
|
15
|
+
- `out/` - Next.js static export output
|
|
16
|
+
- `.vercel/output/` - Vercel Build Output API bundle
|
|
17
|
+
- `.turbo/` - Turborepo cache (default at `.turbo/cache`)
|
|
18
|
+
- `.vercel_build_output/` - Legacy Vercel build output
|
|
19
|
+
- `node_modules/.cache/next` - Next.js cache in node_modules
|
|
20
|
+
|
|
21
|
+
**Always preserved:**
|
|
22
|
+
|
|
23
|
+
- `.vercel/project.json` - Keeps local folder linked to Vercel project
|
|
24
|
+
- `vercel.json` - Vercel project configuration
|
|
25
|
+
- `next.config.*` - Next.js configuration
|
|
26
|
+
- All source code and project files
|
|
27
|
+
|
|
28
|
+
**Features:**
|
|
29
|
+
|
|
30
|
+
- 🎯 Interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink)
|
|
31
|
+
- 🔍 Scans recursively for Next.js, Vercel, and Turborepo build artifacts
|
|
32
|
+
- 📊 Shows disk usage for each directory found
|
|
33
|
+
- ✅ Select multiple directories for batch deletion
|
|
34
|
+
- 🚀 Non-interactive modes for scripting (`--list`, `--json`)
|
|
35
|
+
- 🛡️ Safe deletion with confirmation prompts
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
$ npm install --global next-prune
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
````
|
|
46
|
+
|
|
47
|
+
$ next-prune --help
|
|
48
|
+
|
|
49
|
+
Usage
|
|
50
|
+
$ next-prune
|
|
51
|
+
|
|
52
|
+
Options
|
|
53
|
+
--yes, -y Skip confirmation and delete selected immediately
|
|
54
|
+
--dry-run Don't delete anything; just show results
|
|
55
|
+
--cwd=<path> Directory to scan (default: current working dir)
|
|
56
|
+
--list Non-interactive list of artifacts and sizes, then exit
|
|
57
|
+
--json Output JSON (implies --list)
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
$ next-prune # interactive TUI
|
|
61
|
+
$ next-prune --dry-run # scan only
|
|
62
|
+
$ next-prune --list # list found artifacts
|
|
63
|
+
$ next-prune --json # machine-readable output
|
|
64
|
+
$ next-prune --yes # one-shot cleanup
|
|
65
|
+
|
|
66
|
+
## One-Shot Cleanup
|
|
67
|
+
|
|
68
|
+
For quick cleanup without interaction:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Equivalent to: rm -rf .next out .vercel/output .turbo
|
|
72
|
+
$ next-prune --yes
|
|
73
|
+
````
|
|
74
|
+
|
|
75
|
+
## Contributing
|
|
76
|
+
|
|
77
|
+
See `CONTRIBUTING.md` and `AGENTS.md`. By participating, you agree to our `CODE_OF_CONDUCT.md`.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT © next-prune contributors
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|