next-prune 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
4
  import React, { useState, useEffect, useMemo } from 'react';
5
5
  import { Box, Text, useInput, useApp } from 'ink';
6
- import { findNextCaches, getDirSize, FRAMES, human } from './scanner.js';
6
+ import { scanArtifacts, getArtifactStats, FRAMES, human, timeAgo } from './scanner.js';
7
+ import { findUnusedAssets } from './asset-scanner.js';
7
8
 
8
9
  // Scanner utilities are imported from ./scanner.js
9
10
 
@@ -38,11 +39,11 @@ function useTerminalRows() {
38
39
  // Greedy pack segments into up to maxLines based on terminal width
39
40
  function packSegments(segments, cols, maxLines = 3) {
40
41
  const sep = ' • ';
41
- const prefixLen = 3; // approx width of '🎮 '
42
- const max = Math.max(24, (cols || 80) - 6); // leave room for borders/padding
42
+ const prefixLen = 3; // Approx width of '🎮 '
43
+ const max = Math.max(24, (cols || 80) - 6); // Leave room for borders/padding
43
44
  const lines = [];
44
45
  let cur = [];
45
- let curLen = prefixLen; // first line has a small prefix
46
+ let curLen = prefixLen; // First line has a small prefix
46
47
  const pushLine = () => {
47
48
  if (cur.length > 0) lines.push(cur);
48
49
  cur = [];
@@ -75,7 +76,7 @@ function packLabeledSegments(segments, cols, prefixLen = 0, maxLines = 3) {
75
76
  curLen = 0;
76
77
  };
77
78
  for (const seg of segments) {
78
- const segLen = seg.label.length + 1 + String(seg.value).length; // space before value
79
+ const segLen = seg.label.length + 1 + String(seg.value).length; // Space before value
79
80
  const addLen = (cur.length === 0 ? 0 : sepLen) + segLen;
80
81
  if (cur.length > 0 && curLen + addLen > max) {
81
82
  pushLine();
@@ -95,16 +96,22 @@ function truncateMiddle(text, max) {
95
96
  const tail = Math.floor((max - 1) / 2);
96
97
  return text.slice(0, head) + '…' + text.slice(text.length - tail);
97
98
  }
99
+ const DEFAULT_CONFIG = {
100
+ alwaysDelete: [],
101
+ neverDelete: [],
102
+ checkUnusedAssets: false
103
+ };
98
104
  export default function App({
99
105
  cwd = process.cwd(),
100
106
  dryRun = false,
101
107
  confirmImmediately = false,
102
- testMode = false
108
+ testMode = false,
109
+ config = DEFAULT_CONFIG
103
110
  }) {
104
111
  const {
105
112
  exit
106
113
  } = useApp();
107
- const [items, setItems] = useState([]); // {path, size, status}
114
+ const [items, setItems] = useState([]); // {path, size, mtime, fileCount, status}
108
115
  const [loading, setLoading] = useState(!testMode);
109
116
  const [selected, setSelected] = useState(new Set());
110
117
  const [index, setIndex] = useState(0);
@@ -182,7 +189,7 @@ export default function App({
182
189
  const scanSegments = useMemo(() => {
183
190
  const segs = [{
184
191
  label: 'Found:',
185
- value: `${foundCount} directories`
192
+ value: `${foundCount} items`
186
193
  }, {
187
194
  label: 'Total Size:',
188
195
  value: human(totalSize)
@@ -203,13 +210,28 @@ export default function App({
203
210
  setLoading(true);
204
211
  setError('');
205
212
  try {
206
- const paths = await findNextCaches(cwd);
207
- const sizes = await Promise.all(paths.map(p => getDirSize(p)));
208
- const nextItems = [];
209
- for (let i = 0; i < paths.length; i += 1) nextItems.push({
210
- path: paths[i],
211
- size: sizes[i]
212
- });
213
+ // Updated to use scanArtifacts which does stats internally
214
+ let nextItems = await scanArtifacts(cwd);
215
+ if (config.checkUnusedAssets) {
216
+ const assetPaths = await findUnusedAssets(cwd);
217
+ const assetStats = await Promise.all(assetPaths.map(p => getArtifactStats(p)));
218
+ const assetItems = assetPaths.map((p, i) => ({
219
+ path: p,
220
+ ...assetStats[i],
221
+ type: 'asset' // Marker for UI
222
+ }));
223
+ nextItems = [...nextItems, ...assetItems];
224
+ // Re-sort by size
225
+ nextItems.sort((a, b) => b.size - a.size);
226
+ }
227
+
228
+ // Filter out neverDelete items
229
+ if (config.neverDelete?.length > 0) {
230
+ nextItems = nextItems.filter(it => {
231
+ const rel = path.relative(cwd, it.path);
232
+ return !config.neverDelete.some(pattern => rel === pattern || rel.startsWith(pattern + path.sep));
233
+ });
234
+ }
213
235
  setItems(nextItems);
214
236
  // Reset UI focus/help after a fresh scan
215
237
  setIndex(0);
@@ -234,7 +256,7 @@ export default function App({
234
256
  ...it,
235
257
  status: 'dry-run'
236
258
  } : it));
237
- setError(`✅ Dry-run: would delete ${selectedIndices.length} directories (${human(selectedSize)})`);
259
+ setError(`✅ Dry-run: would delete ${selectedIndices.length} items (${human(selectedSize)})`);
238
260
  setSelected(new Set());
239
261
  setConfirm(false);
240
262
  return;
@@ -269,6 +291,7 @@ export default function App({
269
291
  setError(`Failed to delete: ${p} (${error_?.message ?? error_})`);
270
292
  }
271
293
  }
294
+
272
295
  // Mark successful deletions
273
296
  if (successes.size > 0) {
274
297
  setItems(prev => prev.map((it, i) => successes.has(i) ? {
@@ -276,7 +299,7 @@ export default function App({
276
299
  status: 'deleted',
277
300
  size: 0
278
301
  } : it));
279
- setError(`✅ Deleted ${successes.size} directories (freed ${human(freed)})`);
302
+ setError(`✅ Deleted ${successes.size} items (freed ${human(freed)})`);
280
303
  }
281
304
  } finally {
282
305
  setSelected(new Set());
@@ -298,6 +321,7 @@ export default function App({
298
321
  }
299
322
  return;
300
323
  }
324
+
301
325
  // Global Quit
302
326
  if (key.escape || input?.toLowerCase() === 'q' || input === 'c' && key.ctrl) {
303
327
  exit();
@@ -310,30 +334,44 @@ export default function App({
310
334
  } else if (key.downArrow) {
311
335
  // Navigate Down
312
336
  setIndex(i => Math.min(Math.max(0, items.length - 1), i + 1));
313
- } else if (input === ' ') {
314
- // Selection Toggle
315
- setSelected(cur => {
316
- const next = new Set(cur);
317
- if (next.has(index)) next.delete(index);else if (items[index]?.status !== 'deleted') next.add(index);
318
- return next;
319
- });
320
- } else if (input === 'a') {
321
- // Select All
322
- setSelected(new Set(items.map((_, i) => i).filter(i => items[i]?.status !== 'deleted')));
323
- } else if (input === 'c') {
324
- // Clear Selection
325
- setSelected(new Set());
326
- } else if (input?.toLowerCase() === 'd' || key.return) {
327
- // Open confirm; if none selected, select the focused item if allowed
328
- if (selected.size > 0) {
329
- setConfirm(true);
330
- } else if (items[index]?.status !== 'deleted') {
331
- setSelected(new Set([index]));
332
- setConfirm(true);
333
- }
334
- } else if (input?.toLowerCase() === 'r' && !loading) {
335
- // Rescan
336
- doScan();
337
+ } else switch (input) {
338
+ case ' ':
339
+ {
340
+ // Selection Toggle
341
+ setSelected(cur => {
342
+ const next = new Set(cur);
343
+ if (next.has(index)) next.delete(index);else if (items[index]?.status !== 'deleted') next.add(index);
344
+ return next;
345
+ });
346
+ break;
347
+ }
348
+ case 'a':
349
+ {
350
+ // Select All
351
+ setSelected(new Set(items.map((_, i) => i).filter(i => items[i]?.status !== 'deleted')));
352
+ break;
353
+ }
354
+ case 'c':
355
+ {
356
+ // Clear Selection
357
+ setSelected(new Set());
358
+ break;
359
+ }
360
+ default:
361
+ {
362
+ if (input?.toLowerCase() === 'd' || key.return) {
363
+ // Open confirm; if none selected, select the focused item if allowed
364
+ if (selected.size > 0) {
365
+ setConfirm(true);
366
+ } else if (items[index]?.status !== 'deleted') {
367
+ setSelected(new Set([index]));
368
+ setConfirm(true);
369
+ }
370
+ } else if (input?.toLowerCase() === 'r' && !loading) {
371
+ // Rescan
372
+ doScan();
373
+ }
374
+ }
337
375
  }
338
376
  });
339
377
 
@@ -343,16 +381,16 @@ export default function App({
343
381
  viewStart,
344
382
  viewEnd
345
383
  } = useMemo(() => {
346
- const rootPad = 2; // root <Box padding={1}> adds 2 rows total
347
- const titleHeight = 5; // round border + padding + 1 line
348
- const scanHeight = 5 + packedScan.length; // single border + padding + header + lines
349
- const errorHeight = error ? 5 : 0; // approx single-line error box
350
- const footerHeight = showHelp ? 10 : 4 + packedLines.length; // help ~6 lines + borders/padding => 10; else compact footer
351
- const confirmHeight = confirm ? 7 : 0; // confirm box ~3 lines + borders/padding
384
+ const rootPad = 2; // Root <Box padding={1}> adds 2 rows total
385
+ const titleHeight = 5; // Round border + padding + 1 line
386
+ const scanHeight = 5 + packedScan.length; // Single border + padding + header + lines
387
+ const errorHeight = error ? 5 : 0; // Approx single-line error box
388
+ const footerHeight = showHelp ? 10 : 4 + packedLines.length; // Help ~6 lines + borders/padding => 10; else compact footer
389
+ const confirmHeight = confirm ? 7 : 0; // Confirm box ~3 lines + borders/padding
352
390
  const used = rootPad + titleHeight + scanHeight + errorHeight + footerHeight + confirmHeight;
353
391
  const totalRows = rows || 24;
354
- const boxHeight = Math.max(7, totalRows - used); // include list borders+padding
355
- const inner = Math.max(3, boxHeight - 4); // subtract list border+padding
392
+ const boxHeight = Math.max(7, totalRows - used); // Include list borders+padding
393
+ const inner = Math.max(3, boxHeight - 4); // Subtract list border+padding
356
394
 
357
395
  // Center the focused item within the visible window when possible
358
396
  const half = Math.floor(inner / 2);
@@ -374,12 +412,31 @@ export default function App({
374
412
  // eslint-disable-next-line react-hooks/exhaustive-deps
375
413
  }, [cwd, testMode]);
376
414
  useEffect(() => {
377
- if (!confirmImmediately) return;
378
- if (testMode) return;
379
415
  if (items.length === 0) return;
380
- setSelected(new Set(items.map((_, i) => i)));
381
- setConfirm(true);
382
- }, [confirmImmediately, items, testMode]);
416
+ if (testMode) return;
417
+
418
+ // 1. If confirmImmediately is set, select all and confirm
419
+ if (confirmImmediately) {
420
+ setSelected(new Set(items.map((_, i) => i)));
421
+ setConfirm(true);
422
+ return;
423
+ }
424
+
425
+ // 2. Otherwise, check config.alwaysDelete
426
+ if (config.alwaysDelete?.length > 0) {
427
+ const preSelected = new Set();
428
+ for (const [i, it] of items.entries()) {
429
+ const rel = path.relative(cwd, it.path);
430
+ const shouldSelect = config.alwaysDelete.some(pattern => rel === pattern || rel.startsWith(pattern + path.sep));
431
+ if (shouldSelect && it.status !== 'deleted') {
432
+ preSelected.add(i);
433
+ }
434
+ }
435
+ if (preSelected.size > 0) {
436
+ setSelected(preSelected);
437
+ }
438
+ }
439
+ }, [confirmImmediately, items, testMode, config.alwaysDelete, cwd]);
383
440
 
384
441
  // Auto-deselect deleted items
385
442
  useEffect(() => {
@@ -426,7 +483,7 @@ export default function App({
426
483
  key: si
427
484
  }, /*#__PURE__*/React.createElement(Text, {
428
485
  color: seg.label === 'Status:' ? 'blue' : 'yellow'
429
- }, seg.label), /*#__PURE__*/React.createElement(Text, null, " ", seg.value), si < line.length - 1 && /*#__PURE__*/React.createElement(Text, null, " \u2022 ")))))))), error && /*#__PURE__*/React.createElement(Box, {
486
+ }, seg.label), /*#__PURE__*/React.createElement(Text, null, " ", seg.value), si < line.length - 1 && /*#__PURE__*/React.createElement(Text, null, " \u2022 ")))))))), error ? /*#__PURE__*/React.createElement(Box, {
430
487
  borderStyle: "single",
431
488
  borderColor: error.startsWith('✅') ? 'green' : 'red',
432
489
  padding: 1,
@@ -439,7 +496,7 @@ export default function App({
439
496
  bold: true
440
497
  }, "\u274C Error:", ' '), /*#__PURE__*/React.createElement(Text, {
441
498
  color: "red"
442
- }, error))), /*#__PURE__*/React.createElement(Box, {
499
+ }, error))) : null, /*#__PURE__*/React.createElement(Box, {
443
500
  flexDirection: "column",
444
501
  borderStyle: "single",
445
502
  padding: 1,
@@ -458,27 +515,48 @@ export default function App({
458
515
  const prefix = isFocus ? '>' : ' ';
459
516
  const mark = isSel ? '[x]' : '[ ]';
460
517
  const sizeText = it.size === undefined || it.size === null ? '…' : human(it.size);
518
+ const timeText = timeAgo(it.mtime);
461
519
  let statusColor = undefined;
462
520
  let statusText = '';
463
- if (it.status === 'deleted') {
464
- // No inline indicator for deleted items; styling conveys state
465
- } else if (it.status === 'error') {
466
- statusColor = 'red';
467
- statusText = ' ❌ error';
468
- } else if (it.status === 'dry-run') {
469
- statusColor = 'yellow';
470
- statusText = ' 🔍 dry-run';
471
- } else if (it.status === 'deleting') {
472
- statusColor = 'blue';
473
- statusText = ' 🗑️ deleting...';
521
+ switch (it.status) {
522
+ case 'deleted':
523
+ {
524
+ // No inline indicator for deleted items; styling conveys state
525
+
526
+ break;
527
+ }
528
+ case 'error':
529
+ {
530
+ statusColor = 'red';
531
+ statusText = ' error';
532
+ break;
533
+ }
534
+ case 'dry-run':
535
+ {
536
+ statusColor = 'yellow';
537
+ statusText = ' 🔍 dry-run';
538
+ break;
539
+ }
540
+ case 'deleting':
541
+ {
542
+ statusColor = 'blue';
543
+ statusText = ' 🗑️ deleting...';
544
+ break;
545
+ }
546
+ // No default
474
547
  }
475
548
 
476
549
  // Ensure single-line rendering to avoid terminal scroll jumps
477
550
  const containerWidth = Math.max(24, (cols || 80) - 6);
478
551
  const leftPart = `${prefix} ${mark} ${sizeText.padStart(7)} `;
479
- const reserved = leftPart.length + (statusText ? statusText.length : 0);
480
- const maxRel = Math.max(3, containerWidth - reserved);
481
- const displayRel = truncateMiddle(rel, maxRel);
552
+ const metaPart = ` ${timeText.padEnd(8)} `;
553
+
554
+ // Add icon based on type/directory
555
+ // const icon = it.isDirectory === false ? '📄' : '📁';
556
+
557
+ const reserved = leftPart.length + metaPart.length + (statusText ? statusText.length : 0);
558
+ // Adjusted maxRel calculation to account for icon and warning
559
+ const maxRel = Math.max(3, containerWidth - reserved - 4);
482
560
  return /*#__PURE__*/React.createElement(Box, {
483
561
  key: it.path
484
562
  }, /*#__PURE__*/React.createElement(Text, {
@@ -486,9 +564,14 @@ export default function App({
486
564
  backgroundColor: isFocus && it.status !== 'deleted' ? 'blue' : undefined,
487
565
  dimColor: it.status === 'deleted',
488
566
  strikethrough: it.status === 'deleted'
489
- }, leftPart, displayRel), statusText && /*#__PURE__*/React.createElement(Text, {
567
+ }, leftPart, /*#__PURE__*/React.createElement(Text, {
568
+ dimColor: it.status === 'deleted',
569
+ color: it.status === 'deleted' ? 'gray' : 'yellow'
570
+ }, metaPart), it.type === 'asset' ? /*#__PURE__*/React.createElement(Text, {
571
+ color: "yellow"
572
+ }, "\u26A0\uFE0F ") : null, it.isDirectory === false ? /*#__PURE__*/React.createElement(Text, null, "\uD83D\uDCC4 ") : /*#__PURE__*/React.createElement(Text, null, "\uD83D\uDCC1 "), truncateMiddle(rel, maxRel)), statusText ? /*#__PURE__*/React.createElement(Text, {
490
573
  color: statusColor
491
- }, statusText));
574
+ }, statusText) : null);
492
575
  })), /*#__PURE__*/React.createElement(Box, {
493
576
  borderStyle: "single",
494
577
  borderColor: "gray",
@@ -568,7 +651,7 @@ export default function App({
568
651
  dimColor: true
569
652
  }, " ", seg.label), si < line.length - 1 && /*#__PURE__*/React.createElement(Text, {
570
653
  dimColor: true
571
- }, " \u2022 "))))))), confirm && /*#__PURE__*/React.createElement(Box, {
654
+ }, " \u2022 "))))))), confirm ? /*#__PURE__*/React.createElement(Box, {
572
655
  borderStyle: "double",
573
656
  borderColor: "yellow",
574
657
  padding: 1,
@@ -589,5 +672,5 @@ export default function App({
589
672
  bold: true
590
673
  }, "Esc"), /*#__PURE__*/React.createElement(Text, {
591
674
  color: "gray"
592
- }, " cancel"))));
675
+ }, " cancel"))) : null);
593
676
  }
@@ -0,0 +1,92 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.ico', '.bmp']);
4
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.less', '.html', '.md', '.mdx']);
5
+ async function* walkFiles(dir, extensions) {
6
+ let entries = [];
7
+ try {
8
+ entries = await fs.readdir(dir, {
9
+ withFileTypes: true
10
+ });
11
+ } catch {
12
+ return;
13
+ }
14
+ for (const entry of entries) {
15
+ const full = path.join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ yield* walkFiles(full, extensions);
18
+ } else if (entry.isFile()) {
19
+ const ext = path.extname(entry.name).toLowerCase();
20
+ if (!extensions || extensions.has(ext)) {
21
+ yield full;
22
+ }
23
+ }
24
+ }
25
+ }
26
+ export const findUnusedAssets = async cwd => {
27
+ const publicDir = path.join(cwd, 'public');
28
+ try {
29
+ await fs.access(publicDir);
30
+ } catch {
31
+ return []; // No public dir
32
+ }
33
+
34
+ // 1. Gather all assets in public/
35
+ const assets = [];
36
+ for await (const p of walkFiles(publicDir, IMAGE_EXTENSIONS)) {
37
+ assets.push(p);
38
+ }
39
+ if (assets.length === 0) return [];
40
+
41
+ // 2. Gather all source files
42
+ const sourceFiles = [];
43
+ const srcDirs = ['src', 'app', 'pages', 'components', 'lib', 'utils', 'hooks'];
44
+ // Also check root files
45
+ const rootFiles = await fs.readdir(cwd);
46
+ for (const f of rootFiles) {
47
+ if (SOURCE_EXTENSIONS.has(path.extname(f))) {
48
+ sourceFiles.push(path.join(cwd, f));
49
+ }
50
+ }
51
+ for (const dir of srcDirs) {
52
+ const fullDir = path.join(cwd, dir);
53
+ try {
54
+ await fs.access(fullDir);
55
+ for await (const p of walkFiles(fullDir, SOURCE_EXTENSIONS)) {
56
+ sourceFiles.push(p);
57
+ }
58
+ } catch {
59
+ // ignore missing dirs
60
+ }
61
+ }
62
+
63
+ // 3. Read source files content
64
+ const contents = await Promise.all(sourceFiles.map(async f => {
65
+ try {
66
+ return await fs.readFile(f, 'utf8');
67
+ } catch {
68
+ return '';
69
+ }
70
+ }));
71
+ const fullSource = contents.join('\n');
72
+
73
+ // 4. Check for usage
74
+ const unused = [];
75
+ for (const asset of assets) {
76
+ const filename = path.basename(asset);
77
+ const relPath = path.relative(publicDir, asset); // e.g. "images/logo.png"
78
+
79
+ // Naive check: does the filename appear?
80
+ // Or the relative path?
81
+ // We check both "logo.png" and "images/logo.png"
82
+
83
+ const nameMatch = fullSource.includes(filename);
84
+ // For relative path, we need to be careful about slashes.
85
+ // In code it might be "/images/logo.png"
86
+ const relMatch = fullSource.includes(relPath) || fullSource.includes('/' + relPath);
87
+ if (!nameMatch && !relMatch) {
88
+ unused.push(asset);
89
+ }
90
+ }
91
+ return unused;
92
+ };
package/dist/cli.js CHANGED
@@ -2,7 +2,9 @@
2
2
  import process from 'node:process';
3
3
  import path from 'node:path';
4
4
  import meow from 'meow';
5
- import { scanWithSizes, human } from './scanner.js';
5
+ import { scanArtifacts, getArtifactStats, human, timeAgo } from './scanner.js';
6
+ import { findUnusedAssets } from './asset-scanner.js';
7
+ import { loadConfig } from './config.js';
6
8
  const cli = meow(`
7
9
  Usage
8
10
  $ next-prune [options]
@@ -53,8 +55,28 @@ const props = {
53
55
  cwd: cli.flags.cwd ?? process.cwd()
54
56
  };
55
57
  async function main() {
58
+ const config = await loadConfig(props.cwd);
56
59
  if (cli.flags.list || cli.flags.json) {
57
- const items = await scanWithSizes(props.cwd);
60
+ let items = await scanArtifacts(props.cwd);
61
+ if (config.checkUnusedAssets) {
62
+ const assetPaths = await findUnusedAssets(props.cwd);
63
+ const assetStats = await Promise.all(assetPaths.map(p => getArtifactStats(p)));
64
+ const assetItems = assetPaths.map((p, i) => ({
65
+ path: p,
66
+ ...assetStats[i],
67
+ type: 'asset'
68
+ }));
69
+ items = [...items, ...assetItems];
70
+ items.sort((a, b) => b.size - a.size);
71
+ }
72
+
73
+ // Filter out neverDelete items
74
+ if (config.neverDelete.length > 0) {
75
+ items = items.filter(it => {
76
+ const rel = path.relative(props.cwd, it.path);
77
+ return !config.neverDelete.some(pattern => rel === pattern || rel.startsWith(pattern + path.sep));
78
+ });
79
+ }
58
80
  if (cli.flags.json) {
59
81
  process.stdout.write(JSON.stringify(items, null, 2) + '\n');
60
82
  return;
@@ -63,9 +85,12 @@ async function main() {
63
85
  for (const it of items) total += typeof it.size === 'number' ? it.size : 0;
64
86
  for (const it of items) {
65
87
  const rel = path.relative(props.cwd, it.path) || '.';
66
- process.stdout.write(`${human(it.size).padStart(6)} ${rel}\n`);
88
+ const time = it.mtime ? `(${timeAgo(it.mtime)})` : '';
89
+ const type = it.type === 'asset' ? '⚠️ ' : '';
90
+ const icon = it.isDirectory === false ? '📄' : '📁';
91
+ process.stdout.write(`${human(it.size).padStart(6)} ${time.padEnd(10)} ${type}${icon} ${rel}\n`);
67
92
  }
68
- process.stdout.write(`\nTotal: ${human(total)} in ${items.length} directorie${items.length === 1 ? '' : 's'}\n`);
93
+ process.stdout.write(`\nTotal: ${human(total)} in ${items.length} items\n`);
69
94
  return;
70
95
  }
71
96
  const reactModule = await import('react');
@@ -77,7 +102,10 @@ async function main() {
77
102
  const {
78
103
  default: App
79
104
  } = await import('./app.js');
80
- render(React.createElement(App, props));
105
+ render(React.createElement(App, {
106
+ ...props,
107
+ config
108
+ }));
81
109
  }
82
110
 
83
111
  // eslint-disable-next-line unicorn/prefer-top-level-await
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ export const DEFAULT_CONFIG = {
4
+ alwaysDelete: [],
5
+ neverDelete: [],
6
+ checkUnusedAssets: false
7
+ };
8
+ export const loadConfig = async cwd => {
9
+ let config = {
10
+ ...DEFAULT_CONFIG
11
+ };
12
+
13
+ // 1. Check package.json
14
+ try {
15
+ const pkgPath = path.join(cwd, 'package.json');
16
+ const pkgStr = await fs.readFile(pkgPath, 'utf8');
17
+ const pkg = JSON.parse(pkgStr);
18
+ if (pkg['next-prune']) {
19
+ config = {
20
+ ...config,
21
+ ...pkg['next-prune']
22
+ };
23
+ }
24
+ } catch {
25
+ // Ignore if package.json not found or invalid
26
+ }
27
+
28
+ // 2. Check .next-prunerc.json (overrides package.json)
29
+ try {
30
+ const rcPath = path.join(cwd, '.next-prunerc.json');
31
+ const rcStr = await fs.readFile(rcPath, 'utf8');
32
+ const rc = JSON.parse(rcStr);
33
+ config = {
34
+ ...config,
35
+ ...rc
36
+ };
37
+ } catch {
38
+ // Ignore
39
+ }
40
+
41
+ // Normalize arrays
42
+ if (!Array.isArray(config.alwaysDelete)) config.alwaysDelete = [];
43
+ if (!Array.isArray(config.neverDelete)) config.neverDelete = [];
44
+ return config;
45
+ };
package/dist/scanner.js CHANGED
@@ -3,34 +3,68 @@ import fs from 'node:fs/promises';
3
3
  export const FRAMES = Object.freeze(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']);
4
4
 
5
5
  // Patterns for Next.js and related build artifacts/caches
6
- const CACHE_PATTERNS = ['.next',
7
- // Next.js build output and cache
6
+ // Includes files (logs) and directories
7
+ const ARTIFACT_PATTERNS = ['.next',
8
+ // Next.js build output
8
9
  'out',
9
- // Next.js static export output
10
+ // Next.js static export
10
11
  '.vercel/output',
11
- // Vercel Build Output API bundle
12
+ // Vercel output
12
13
  '.turbo',
13
14
  // Turborepo cache
14
15
  '.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]);
16
+ // Legacy Vercel
17
+ 'node_modules/.cache/next',
18
+ // Next.js cache
19
+ 'node_modules/.cache/turbopack',
20
+ // Turbopack cache
21
+ 'coverage',
22
+ // Test coverage
23
+ '.swc',
24
+ // SWC cache
25
+ '.docusaurus',
26
+ // Docusaurus cache
27
+ 'storybook-static',
28
+ // Storybook build
29
+ 'npm-debug.log', 'yarn-error.log', 'pnpm-debug.log'];
30
+
31
+ // Directories to skip during recursive scan to avoid performance hits
32
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build',
33
+ // careful: some projects use build as output, but often it's source
34
+ '.next', '.turbo', '.vercel', 'coverage', '.swc', '.docusaurus', 'storybook-static']);
19
35
  export const human = bytes => {
20
- if (!bytes) return '0 B';
36
+ if (bytes === 0) return '0 B';
37
+ if (!bytes) return '-';
21
38
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
22
39
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
23
40
  const value = bytes / 1024 ** i;
24
41
  return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
25
42
  };
26
- export const dirExists = async p => {
43
+ export const timeAgo = date => {
44
+ if (!date) return '';
45
+ const seconds = Math.floor((Date.now() - date) / 1000);
46
+ let interval = seconds / 31_536_000;
47
+ if (interval > 1) return Math.floor(interval) + 'y ago';
48
+ interval = seconds / 2_592_000;
49
+ if (interval > 1) return Math.floor(interval) + 'mo ago';
50
+ interval = seconds / 86_400;
51
+ if (interval > 1) return Math.floor(interval) + 'd ago';
52
+ interval = seconds / 3600;
53
+ if (interval > 1) return Math.floor(interval) + 'h ago';
54
+ interval = seconds / 60;
55
+ if (interval > 1) return Math.floor(interval) + 'm ago';
56
+ return Math.floor(seconds) + 's ago';
57
+ };
58
+ export const pathExists = async p => {
27
59
  try {
28
- const s = await fs.stat(p);
29
- return s.isDirectory();
60
+ await fs.stat(p);
61
+ return true;
30
62
  } catch {
31
63
  return false;
32
64
  }
33
65
  };
66
+
67
+ // Recursively walk directories, yielding subdirectories
34
68
  export async function* walk(root) {
35
69
  let entries = [];
36
70
  try {
@@ -42,84 +76,123 @@ export async function* walk(root) {
42
76
  }
43
77
  for (const entry of entries) {
44
78
  if (!entry.isDirectory()) continue;
79
+ if (SKIP_DIRS.has(entry.name)) continue;
45
80
  const full = path.join(root, entry.name);
46
81
  yield full;
47
- if (SKIP_DIRS.has(entry.name)) continue;
48
82
  yield* walk(full);
49
83
  }
50
84
  }
51
- export const findNextCaches = async cwd => {
52
- const results = [];
85
+ export const findArtifacts = async cwd => {
86
+ const results = new Set();
53
87
 
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);
88
+ // Helper to check a specific directory for patterns
89
+ const checkDir = async dir => {
90
+ for (const pattern of ARTIFACT_PATTERNS) {
91
+ // Handle deep patterns like node_modules/.cache/next separately
92
+ if (pattern.includes(path.sep) || pattern.includes('/')) continue;
93
+ const fullPath = path.join(dir, pattern);
94
+ if (await pathExists(fullPath)) {
95
+ results.add(fullPath);
96
+ }
59
97
  }
98
+ };
99
+
100
+ // Check root
101
+ await checkDir(cwd);
102
+
103
+ // Check specific nested known locations in root
104
+ const nestedCandidates = [path.join(cwd, '.vercel/output'), path.join(cwd, 'node_modules/.cache/next'), path.join(cwd, 'node_modules/.cache/turbopack')];
105
+ for (const cand of nestedCandidates) {
106
+ if (await pathExists(cand)) results.add(cand);
60
107
  }
61
108
 
62
- // Walk subdirectories to find nested artifacts
109
+ // Walk subdirectories (monorepo support / nested projects)
63
110
  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;
111
+ await checkDir(dir);
112
+ // Check nested in this subdir
113
+ const nestedSub = [path.join(dir, '.vercel/output'), path.join(dir, 'node_modules/.cache/next'), path.join(dir, 'node_modules/.cache/turbopack')];
114
+ for (const cand of nestedSub) {
115
+ if (await pathExists(cand)) results.add(cand);
116
+ }
117
+ }
118
+ return [...results];
119
+ };
120
+ export const getArtifactStats = async p => {
121
+ try {
122
+ const stat = await fs.lstat(p);
123
+ if (!stat.isDirectory()) {
124
+ return {
125
+ size: stat.size,
126
+ mtime: stat.mtime,
127
+ fileCount: 1,
128
+ isDirectory: false
129
+ };
71
130
  }
131
+ let totalSize = 0;
132
+ let fileCount = 0;
133
+ let latestMtime = stat.mtime;
134
+ const processDir = async d => {
135
+ let entries = [];
136
+ try {
137
+ entries = await fs.readdir(d, {
138
+ withFileTypes: true
139
+ });
140
+ } catch {
141
+ return;
142
+ }
143
+ const files = [];
144
+ const dirs = [];
145
+ for (const entry of entries) {
146
+ const full = path.join(d, entry.name);
147
+ if (entry.isDirectory()) {
148
+ dirs.push(full);
149
+ } else {
150
+ files.push(full);
151
+ }
152
+ }
72
153
 
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;
154
+ // Process files in this dir
155
+ const fileStats = await Promise.all(files.map(async f => {
156
+ try {
157
+ return await fs.lstat(f);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }));
162
+ for (const s of fileStats) {
163
+ if (!s) continue;
164
+ totalSize += s.size;
165
+ fileCount++;
166
+ if (s.mtime > latestMtime) latestMtime = s.mtime;
78
167
  }
79
- }
80
168
 
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
- }
169
+ // Recurse
170
+ await Promise.all(dirs.map(sub => processDir(sub)));
171
+ };
172
+ await processDir(p);
173
+ return {
174
+ size: totalSize,
175
+ mtime: latestMtime,
176
+ fileCount,
177
+ isDirectory: true
178
+ };
179
+ } catch {
180
+ return {
181
+ size: 0,
182
+ mtime: new Date(),
183
+ fileCount: 0,
184
+ isDirectory: false
185
+ };
86
186
  }
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
187
  };
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;
188
+ export const scanArtifacts = async cwd => {
189
+ const paths = await findArtifacts(cwd);
190
+ const stats = await Promise.all(paths.map(p => getArtifactStats(p)));
191
+ const items = paths.map((path_, index) => ({
192
+ path: path_,
193
+ ...stats[index]
194
+ }));
195
+
196
+ // Sort by size (descending)
197
+ return items.sort((a, b) => b.size - a.size);
125
198
  };
package/package.json CHANGED
@@ -1,99 +1,96 @@
1
1
  {
2
- "name": "next-prune",
3
- "version": "1.0.2",
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
- "unicorn/prevent-abbreviations": "off",
82
- "react/jsx-sort-props": "off",
83
- "react/no-array-index-key": "off",
84
- "no-undef-init": "off",
85
- "complexity": "warn",
86
- "capitalized-comments": "warn",
87
- "padding-line-between-statements": "warn",
88
- "no-await-in-loop": "warn",
89
- "unicorn/prefer-switch": "warn",
90
- "react/self-closing-comp": "warn"
91
- }
92
- },
93
- "prettier": "@vdemedes/prettier-config",
94
- "babel": {
95
- "presets": [
96
- "@babel/preset-react"
97
- ]
98
- }
99
- }
2
+ "name": "next-prune",
3
+ "version": "1.1.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
+ "files": [
40
+ "dist"
41
+ ],
42
+ "dependencies": {
43
+ "ink": "^6.6.0",
44
+ "meow": "^14.0.0",
45
+ "react": "^19.2.3"
46
+ },
47
+ "devDependencies": {
48
+ "@babel/cli": "^7.28.3",
49
+ "@babel/preset-react": "^7.28.5",
50
+ "@vdemedes/prettier-config": "^2.0.1",
51
+ "ava": "^6.4.1",
52
+ "chalk": "^5.6.2",
53
+ "eslint-config-xo-react": "^0.29.0",
54
+ "eslint-plugin-react": "^7.37.5",
55
+ "eslint-plugin-react-hooks": "^7.0.1",
56
+ "import-jsx": "^5.0.0",
57
+ "ink-testing-library": "^4.0.0",
58
+ "prettier": "^3.7.4",
59
+ "xo": "^1.2.3"
60
+ },
61
+ "ava": {
62
+ "environmentVariables": {
63
+ "NODE_NO_WARNINGS": "1"
64
+ }
65
+ },
66
+ "xo": {
67
+ "react": true,
68
+ "prettier": true,
69
+ "semicolon": true,
70
+ "rules": {
71
+ "react/prop-types": "off",
72
+ "unicorn/expiring-todo-comments": "off",
73
+ "unicorn/prevent-abbreviations": "off",
74
+ "react/jsx-sort-props": "off",
75
+ "react/no-array-index-key": "off",
76
+ "no-undef-init": "off",
77
+ "complexity": "warn",
78
+ "capitalized-comments": "warn",
79
+ "padding-line-between-statements": "warn",
80
+ "no-await-in-loop": "warn",
81
+ "unicorn/prefer-switch": "warn",
82
+ "react/self-closing-comp": "warn"
83
+ }
84
+ },
85
+ "prettier": "@vdemedes/prettier-config",
86
+ "babel": {
87
+ "presets": [
88
+ "@babel/preset-react"
89
+ ]
90
+ },
91
+ "scripts": {
92
+ "build": "babel --out-dir=dist source",
93
+ "dev": "babel --out-dir=dist --watch source",
94
+ "test": "prettier --check . && xo && ava"
95
+ }
96
+ }