@hunchoweb/nclean 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/cleanup/remove.d.ts +2 -0
- package/dist/cleanup/remove.js +26 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.js +67 -0
- package/dist/commands/scan.d.ts +2 -0
- package/dist/commands/scan.js +44 -0
- package/dist/scanner/activity.d.ts +2 -0
- package/dist/scanner/activity.js +51 -0
- package/dist/scanner/discover.d.ts +2 -0
- package/dist/scanner/discover.js +65 -0
- package/dist/scanner/size.d.ts +1 -0
- package/dist/scanner/size.js +29 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/dist/ui/diagnostics.d.ts +2 -0
- package/dist/ui/diagnostics.js +27 -0
- package/dist/ui/interactive.d.ts +10 -0
- package/dist/ui/interactive.js +88 -0
- package/dist/ui/layout.d.ts +14 -0
- package/dist/ui/layout.js +128 -0
- package/dist/ui/output.d.ts +13 -0
- package/dist/ui/output.js +72 -0
- package/dist/ui/progress.d.ts +11 -0
- package/dist/ui/progress.js +75 -0
- package/dist/ui/typography.d.ts +2 -0
- package/dist/ui/typography.js +40 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +24 -0
- package/dist/utils/package-manager.d.ts +3 -0
- package/dist/utils/package-manager.js +18 -0
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/paths.js +48 -0
- package/package.json +58 -4
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
2
|
+
export interface Candidate {
|
|
3
|
+
project: string;
|
|
4
|
+
path: string;
|
|
5
|
+
root: string;
|
|
6
|
+
manager: PackageManager;
|
|
7
|
+
lastActive: number | null;
|
|
8
|
+
bytes: number | null;
|
|
9
|
+
device: number;
|
|
10
|
+
inode: number;
|
|
11
|
+
error: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ScanResult {
|
|
14
|
+
projects: number;
|
|
15
|
+
entries: Candidate[];
|
|
16
|
+
warnings: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface Options {
|
|
19
|
+
olderThan?: number;
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
yes?: boolean;
|
|
22
|
+
verbose?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ScanProgress {
|
|
25
|
+
phase: 'discovery' | 'measurement';
|
|
26
|
+
projects: number;
|
|
27
|
+
folders: number;
|
|
28
|
+
completed: number;
|
|
29
|
+
total: number | null;
|
|
30
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { terminalText } from '../utils/format.js';
|
|
2
|
+
import { textLines } from './layout.js';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
export function scanDiagnostics(warnings, entries, verbose = false) {
|
|
5
|
+
const unreadable = entries.filter(entry => entry.error);
|
|
6
|
+
if (!warnings.length && !unreadable.length)
|
|
7
|
+
return '';
|
|
8
|
+
const restricted = warnings.filter(value => /\b(?:EPERM|EACCES):/.test(value)).length;
|
|
9
|
+
const lines = [];
|
|
10
|
+
if (warnings.length) {
|
|
11
|
+
const other = warnings.length - restricted;
|
|
12
|
+
const reasons = [restricted ? `${restricted} access denied` : '', other ? `${other} other ${other === 1 ? 'warning' : 'warnings'}` : ''].filter(Boolean).join(', ');
|
|
13
|
+
lines.push(`Partial scan · ${warnings.length} ${warnings.length === 1 ? 'folder' : 'folders'} skipped (${reasons}).`);
|
|
14
|
+
}
|
|
15
|
+
if (unreadable.length)
|
|
16
|
+
lines.push(`${unreadable.length} dependency ${unreadable.length === 1 ? 'folder is' : 'folders are'} unreadable and excluded from cleanup.`);
|
|
17
|
+
const result = [pc.yellow(textLines(lines.join('\n')))];
|
|
18
|
+
if (verbose) {
|
|
19
|
+
for (const value of warnings)
|
|
20
|
+
result.push(textLines(`Scan warning: ${terminalText(value)}`));
|
|
21
|
+
for (const entry of unreadable)
|
|
22
|
+
result.push(textLines(`${entry.path}: ${terminalText(entry.error)}`));
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
result.push(pc.dim(textLines('Use --verbose for details. Accessible projects are still shown.')));
|
|
26
|
+
return `${result.join('\n')}\n`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Candidate } from '../types.js';
|
|
2
|
+
export declare const isInteractive: () => boolean;
|
|
3
|
+
interface SelectionConfig {
|
|
4
|
+
entries: Candidate[];
|
|
5
|
+
}
|
|
6
|
+
export declare const dependencyPrompt: import("@inquirer/type").Prompt<Candidate[], SelectionConfig>;
|
|
7
|
+
export declare function selectEntries(entries: Candidate[]): Promise<Candidate[]>;
|
|
8
|
+
export declare const cleanupPrompt: import("@inquirer/type").Prompt<boolean, Record<string, never>>;
|
|
9
|
+
export declare function confirmCleanup(): Promise<boolean>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createPrompt, useState, useKeypress, useEffect, isUpKey, isDownKey, isSpaceKey, isEnterKey, usePagination } from '@inquirer/core';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { formatSize } from '../utils/format.js';
|
|
4
|
+
import { rows, terminalWidth, keyboardHelp, truncate, textLines, frame } from './layout.js';
|
|
5
|
+
export const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6
|
+
export const dependencyPrompt = createPrompt((config, done) => {
|
|
7
|
+
const [active, setActive] = useState(0);
|
|
8
|
+
const [selected, setSelected] = useState(new Set());
|
|
9
|
+
const [finished, setFinished] = useState(false);
|
|
10
|
+
const [cancelled, setCancelled] = useState(false);
|
|
11
|
+
const [width, setWidth] = useState(terminalWidth());
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const resize = () => setWidth(terminalWidth());
|
|
14
|
+
process.stdout.on('resize', resize);
|
|
15
|
+
return () => { process.stdout.off('resize', resize); };
|
|
16
|
+
}, []);
|
|
17
|
+
useKeypress(key => {
|
|
18
|
+
if (isUpKey(key))
|
|
19
|
+
setActive(Math.max(0, active - 1));
|
|
20
|
+
else if (isDownKey(key))
|
|
21
|
+
setActive(Math.min(config.entries.length - 1, active + 1));
|
|
22
|
+
else if (isSpaceKey(key)) {
|
|
23
|
+
const next = new Set(selected);
|
|
24
|
+
if (next.has(active))
|
|
25
|
+
next.delete(active);
|
|
26
|
+
else
|
|
27
|
+
next.add(active);
|
|
28
|
+
setSelected(next);
|
|
29
|
+
}
|
|
30
|
+
else if (key.name === 'a')
|
|
31
|
+
setSelected(selected.size === config.entries.length ? new Set() : new Set(config.entries.map((_, index) => index)));
|
|
32
|
+
else if (key.name === 'escape') {
|
|
33
|
+
setCancelled(true);
|
|
34
|
+
setFinished(true);
|
|
35
|
+
done([]);
|
|
36
|
+
}
|
|
37
|
+
else if (isEnterKey(key)) {
|
|
38
|
+
setFinished(true);
|
|
39
|
+
done(config.entries.filter((_, index) => selected.has(index)));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const layout = rows(config.entries, width - 6, 4);
|
|
43
|
+
const page = usePagination({
|
|
44
|
+
items: config.entries, active, loop: false,
|
|
45
|
+
pageSize: Math.max(1, Math.min(10, (process.stdout.rows || 24) - 15)),
|
|
46
|
+
renderItem: ({ item, index, isActive }) => {
|
|
47
|
+
const marker = selected.has(index) ? '●' : '○';
|
|
48
|
+
const cursor = isActive ? pc.cyan('›') : ' ';
|
|
49
|
+
return `${cursor} ${selected.has(index) ? pc.cyan(marker) : pc.dim(marker)} ${layout.render(item)}`;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const bytes = config.entries.reduce((sum, entry, index) => sum + (selected.has(index) ? entry.bytes ?? 0 : 0), 0);
|
|
53
|
+
const summary = `${selected.size} selected · ${formatSize(bytes)}`;
|
|
54
|
+
// Finish as one compact sentence, never a comma-joined list of paths.
|
|
55
|
+
if (finished)
|
|
56
|
+
return pc.dim(` ${truncate(cancelled ? 'Selection cancelled' : summary, width - 3)}`);
|
|
57
|
+
const position = config.entries.length > 10 ? ` ${active + 1}/${config.entries.length}\n` : '';
|
|
58
|
+
return ` ${pc.bold(width < 40 ? 'Select dependencies' : 'Select dependencies to remove')}\n\n\n${frame(page.split('\n'), width, ` ${layout.header}`)}\n${position}\n\n ${pc.cyan(pc.bold(truncate(summary, width - 3)))}\n\n\n${keyboardHelp(width)}\n`;
|
|
59
|
+
});
|
|
60
|
+
export async function selectEntries(entries) {
|
|
61
|
+
return dependencyPrompt({ entries });
|
|
62
|
+
}
|
|
63
|
+
// A restrained Yes / No prompt; default and initial focus are always No.
|
|
64
|
+
export const cleanupPrompt = createPrompt((_config, done) => {
|
|
65
|
+
const [yes, setYes] = useState(false);
|
|
66
|
+
const [finished, setFinished] = useState(false);
|
|
67
|
+
useKeypress(key => {
|
|
68
|
+
if (key.name === 'left' || key.name === 'right' || key.name === 'tab' || isSpaceKey(key))
|
|
69
|
+
setYes(!yes);
|
|
70
|
+
else if (key.name === 'y')
|
|
71
|
+
setYes(true);
|
|
72
|
+
else if (key.name === 'n')
|
|
73
|
+
setYes(false);
|
|
74
|
+
else if (key.name === 'escape') {
|
|
75
|
+
setYes(false);
|
|
76
|
+
setFinished(true);
|
|
77
|
+
done(false);
|
|
78
|
+
}
|
|
79
|
+
else if (isEnterKey(key)) {
|
|
80
|
+
setFinished(true);
|
|
81
|
+
done(yes);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (finished)
|
|
85
|
+
return ` ${pc.dim(`Continue? ${yes ? 'Yes' : 'No'}`)}`;
|
|
86
|
+
return ` ${pc.bold('Continue?')} [y/N] ${yes ? 'y' : ''}\n\n${pc.dim(textLines('[y] yes [n] no [enter] confirm', terminalWidth()))}`;
|
|
87
|
+
});
|
|
88
|
+
export async function confirmCleanup() { return cleanupPrompt({}); }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Candidate } from '../types.js';
|
|
2
|
+
export declare const terminalWidth: () => number;
|
|
3
|
+
export declare function truncate(value: string, width: number): string;
|
|
4
|
+
export declare function pad(value: string, width: number, right?: boolean): string;
|
|
5
|
+
export declare function compactRoot(root: string, width?: number): string;
|
|
6
|
+
export declare function projectLabel(entry: Candidate, entries: Candidate[]): string;
|
|
7
|
+
export declare function compactAge(time: number | null, now?: number): string;
|
|
8
|
+
export declare function rows(entries: Candidate[], width?: number, prefixWidth?: number, metadata?: boolean): {
|
|
9
|
+
header: string;
|
|
10
|
+
render: (entry: Candidate) => string;
|
|
11
|
+
};
|
|
12
|
+
export declare function keyboardHelp(width?: number): string;
|
|
13
|
+
export declare function textLines(value: string, width?: number): string;
|
|
14
|
+
export declare function frame(lines: string[], width?: number, header?: string): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import stringWidth from 'string-width';
|
|
4
|
+
import { displayPath } from '../utils/paths.js';
|
|
5
|
+
import { DAY, formatSize, terminalText } from '../utils/format.js';
|
|
6
|
+
export const terminalWidth = () => Math.max(8, Math.min(100, process.stdout.columns || 80));
|
|
7
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
8
|
+
export function truncate(value, width) {
|
|
9
|
+
value = terminalText(value);
|
|
10
|
+
if (width <= 0)
|
|
11
|
+
return '';
|
|
12
|
+
if (stringWidth(value) <= width)
|
|
13
|
+
return value;
|
|
14
|
+
const marker = width > 3 ? '...' : '.'.repeat(width);
|
|
15
|
+
const remaining = width - marker.length;
|
|
16
|
+
const chars = [...graphemes.segment(value)].map(part => part.segment);
|
|
17
|
+
const take = (parts, budget) => {
|
|
18
|
+
let result = '';
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (stringWidth(result + part) > budget)
|
|
21
|
+
break;
|
|
22
|
+
result += part;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
const start = take(chars, Math.ceil(remaining * 0.4));
|
|
27
|
+
const end = take(chars.toReversed(), remaining - stringWidth(start));
|
|
28
|
+
return start + marker + [...graphemes.segment(end)].map(part => part.segment).reverse().join('');
|
|
29
|
+
}
|
|
30
|
+
export function pad(value, width, right = false) {
|
|
31
|
+
const fitted = truncate(value, width);
|
|
32
|
+
const spaces = ' '.repeat(Math.max(0, width - stringWidth(fitted)));
|
|
33
|
+
return right ? spaces + fitted : fitted + spaces;
|
|
34
|
+
}
|
|
35
|
+
export function compactRoot(root, width = terminalWidth() - 4) {
|
|
36
|
+
const display = displayPath(root);
|
|
37
|
+
return truncate(display.startsWith('~') ? display : `.../${path.basename(root)}`, width);
|
|
38
|
+
}
|
|
39
|
+
export function projectLabel(entry, entries) {
|
|
40
|
+
const relative = path.relative(entry.root, entry.project).split(path.sep).join('/') || path.basename(entry.project);
|
|
41
|
+
const roots = [...new Set(entries.map(item => item.root))];
|
|
42
|
+
if (roots.length === 1)
|
|
43
|
+
return terminalText(relative);
|
|
44
|
+
const name = path.basename(entry.root);
|
|
45
|
+
const sameName = roots.filter(root => path.basename(root) === name).length > 1;
|
|
46
|
+
return terminalText(`${sameName ? `[${roots.indexOf(entry.root) + 1}]` : name}/${relative}`);
|
|
47
|
+
}
|
|
48
|
+
export function compactAge(time, now = Date.now()) {
|
|
49
|
+
if (time === null)
|
|
50
|
+
return 'unknown';
|
|
51
|
+
const days = Math.floor(Math.max(0, now - time) / DAY);
|
|
52
|
+
if (days >= 365)
|
|
53
|
+
return `${Math.floor(days / 365)}y`;
|
|
54
|
+
if (days >= 90)
|
|
55
|
+
return `${Math.floor(days / 30)}mo`;
|
|
56
|
+
return `${days}d`;
|
|
57
|
+
}
|
|
58
|
+
export function rows(entries, width = terminalWidth(), prefixWidth = 2, metadata = true) {
|
|
59
|
+
const space = Math.max(1, width - prefixWidth - 1); // Leave one cell to avoid terminal auto-wrap.
|
|
60
|
+
const showSize = space >= 20;
|
|
61
|
+
const showAge = metadata && space >= 38;
|
|
62
|
+
const showManager = metadata && space >= 54;
|
|
63
|
+
const sizeWidth = showSize ? entries.reduce((max, entry) => Math.max(max, stringWidth(entry.bytes === null ? 'unknown' : formatSize(entry.bytes))), 8) : 0;
|
|
64
|
+
const ageWidth = showAge ? entries.reduce((max, entry) => Math.max(max, stringWidth(compactAge(entry.lastActive))), 8) : 0;
|
|
65
|
+
const managerWidth = showManager ? 4 : 0;
|
|
66
|
+
const extra = [sizeWidth, ageWidth, managerWidth].filter(Boolean).reduce((sum, value) => sum + value + 3, 0);
|
|
67
|
+
const nameWidth = Math.max(1, Math.min(60, space - extra, entries.reduce((max, entry) => Math.max(max, stringWidth(projectLabel(entry, entries))), 22)));
|
|
68
|
+
const compose = (name, size, age, manager, styled) => {
|
|
69
|
+
const cells = [pad(name, nameWidth)];
|
|
70
|
+
if (showSize)
|
|
71
|
+
cells.push(pad(size, sizeWidth, true));
|
|
72
|
+
if (showAge)
|
|
73
|
+
cells.push(styled ? pc.dim(pad(age, ageWidth, true)) : pad(age, ageWidth, true));
|
|
74
|
+
if (showManager)
|
|
75
|
+
cells.push(styled ? pc.dim(pad(manager, managerWidth)) : pad(manager, managerWidth));
|
|
76
|
+
return cells.join(' ');
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
header: pc.dim(compose('Project', 'Size', 'Inactive', 'PM', false)),
|
|
80
|
+
render: (entry) => compose(projectLabel(entry, entries), entry.bytes === null ? 'unknown' : formatSize(entry.bytes), compactAge(entry.lastActive), entry.manager, true),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function keyboardHelp(width = terminalWidth()) {
|
|
84
|
+
const controls = ['[space] select', '[a] all', '[enter] clean', '[esc] cancel'];
|
|
85
|
+
const lines = [];
|
|
86
|
+
let line = '';
|
|
87
|
+
for (const control of controls) {
|
|
88
|
+
const next = line ? `${line} ${control}` : control;
|
|
89
|
+
if (line && stringWidth(next) > width - 3) {
|
|
90
|
+
lines.push(line);
|
|
91
|
+
line = control;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
line = next;
|
|
95
|
+
}
|
|
96
|
+
if (line)
|
|
97
|
+
lines.push(line);
|
|
98
|
+
return pc.dim(lines.map(value => ` ${truncate(value, width - 3)}`).join('\n'));
|
|
99
|
+
}
|
|
100
|
+
// Static prose wraps at word boundaries; project rows always remain a single line.
|
|
101
|
+
export function textLines(value, width = terminalWidth()) {
|
|
102
|
+
const budget = Math.max(1, width - 3);
|
|
103
|
+
return value.split('\n').flatMap(paragraph => {
|
|
104
|
+
if (!paragraph)
|
|
105
|
+
return [''];
|
|
106
|
+
const lines = [];
|
|
107
|
+
let line = '';
|
|
108
|
+
for (const word of terminalText(paragraph).split(' ')) {
|
|
109
|
+
if (line && stringWidth(`${line} ${word}`) > budget) {
|
|
110
|
+
lines.push(` ${line}`);
|
|
111
|
+
line = '';
|
|
112
|
+
}
|
|
113
|
+
const fitted = truncate(word, budget);
|
|
114
|
+
line = line ? `${line} ${fitted}` : fitted;
|
|
115
|
+
}
|
|
116
|
+
if (line)
|
|
117
|
+
lines.push(` ${line}`);
|
|
118
|
+
return lines;
|
|
119
|
+
}).join('\n');
|
|
120
|
+
}
|
|
121
|
+
// One muted frame around a list, with a cell budget shared by all row renderers.
|
|
122
|
+
export function frame(lines, width = terminalWidth(), header) {
|
|
123
|
+
const boxWidth = Math.max(5, width - 3);
|
|
124
|
+
const inner = boxWidth - 4;
|
|
125
|
+
const edge = (left, right) => ` ${pc.dim(`${left}${'─'.repeat(boxWidth - 2)}${right}`)}`;
|
|
126
|
+
const line = (content) => ` ${pc.dim('│')} ${content}${' '.repeat(Math.max(0, inner - stringWidth(content)))} ${pc.dim('│')}`;
|
|
127
|
+
return [edge('┌', '┐'), ...(header ? [line(header), edge('├', '┤')] : []), ...lines.map(line), edge('└', '┘')].join('\n');
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Candidate } from '../types.js';
|
|
2
|
+
export declare function banner(): void;
|
|
3
|
+
export declare function total(entries: Candidate[]): number;
|
|
4
|
+
export declare function message(title: string, detail: string, tip?: string): void;
|
|
5
|
+
export declare function note(value: string): void;
|
|
6
|
+
export declare function warning(value: string): void;
|
|
7
|
+
export declare function scanning(roots: string[]): void;
|
|
8
|
+
export declare function scanComplete(projects: number, folders: number, entries: Candidate[], filtered: boolean): void;
|
|
9
|
+
export declare function table(entries: Candidate[], minimal?: boolean): void;
|
|
10
|
+
export declare function plan(entries: Candidate[]): void;
|
|
11
|
+
export declare function cleanupComplete(removed: Candidate[], bytes: number, failed: number): void;
|
|
12
|
+
export declare function restores(entries: Candidate[]): void;
|
|
13
|
+
export declare function deletionFailure(entry: Candidate, entries: Candidate[], reason: string): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { formatSize, terminalText } from '../utils/format.js';
|
|
3
|
+
import { compactRoot, projectLabel, rows, terminalWidth, textLines, truncate, frame } from './layout.js';
|
|
4
|
+
import { wordmark, sizeHero } from './typography.js';
|
|
5
|
+
export function banner() {
|
|
6
|
+
console.log(`\n${pc.cyan(wordmark().map(line => ` ${line}`).join('\n'))}\n\n${pc.dim(textLines('Node modules cleanup, without the drama.'))}\n\n`);
|
|
7
|
+
}
|
|
8
|
+
export function total(entries) { return entries.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0); }
|
|
9
|
+
export function message(title, detail, tip) {
|
|
10
|
+
const heading = title.toLowerCase().replace(/^./, char => char.toUpperCase());
|
|
11
|
+
console.log(`\n${pc.bold(textLines(`${heading}.`))}\n\n${textLines(detail)}${tip ? `\n\n${pc.dim(textLines(tip))}` : ''}\n\n`);
|
|
12
|
+
}
|
|
13
|
+
export function note(value) { console.log(pc.dim(textLines(value))); }
|
|
14
|
+
export function warning(value) { console.error(textLines(value)); }
|
|
15
|
+
export function scanning(roots) {
|
|
16
|
+
if (roots.length === 1)
|
|
17
|
+
console.log(textLines(`Scanning ${compactRoot(roots[0], terminalWidth() - 15)}...`));
|
|
18
|
+
else {
|
|
19
|
+
console.log(textLines('Scanning projects...'));
|
|
20
|
+
for (const [index, root] of roots.entries())
|
|
21
|
+
note(`[${index + 1}] ${compactRoot(root, terminalWidth() - 8)}`);
|
|
22
|
+
}
|
|
23
|
+
console.log('\n');
|
|
24
|
+
}
|
|
25
|
+
export function scanComplete(projects, folders, entries, filtered) {
|
|
26
|
+
const eligible = entries.filter(entry => entry.bytes !== null && entry.lastActive !== null && !entry.error);
|
|
27
|
+
console.log(textLines(`${projects} ${projects === 1 ? 'project' : 'projects'} scanned`));
|
|
28
|
+
note(`${folders} dependency ${folders === 1 ? 'folder' : 'folders'} found`);
|
|
29
|
+
if (eligible.length) {
|
|
30
|
+
console.log(textLines(`${eligible.length} ${filtered ? 'inactive ' : 'eligible '}${eligible.length === 1 ? 'project' : 'projects'}`));
|
|
31
|
+
console.log(`\n${pc.cyan(pc.bold(textLines(`${formatSize(total(eligible))} reclaimable`)))}`);
|
|
32
|
+
}
|
|
33
|
+
console.log('\n');
|
|
34
|
+
}
|
|
35
|
+
export function table(entries, minimal = false) {
|
|
36
|
+
const layout = rows(entries, terminalWidth() - 6, 0, !minimal);
|
|
37
|
+
// Read-only reports need headings. Selection and confirmation rely on spacing.
|
|
38
|
+
console.log(frame(entries.map(entry => layout.render(entry)), terminalWidth(), minimal ? undefined : layout.header));
|
|
39
|
+
console.log('\n');
|
|
40
|
+
}
|
|
41
|
+
export function plan(entries) {
|
|
42
|
+
console.log(`\n${pc.yellow(pc.bold(textLines('Ready to clean.')))}\n\n`);
|
|
43
|
+
console.log(textLines(`${entries.length} node_modules ${entries.length === 1 ? 'directory' : 'directories'}`));
|
|
44
|
+
console.log(pc.bold(textLines(formatSize(total(entries)))));
|
|
45
|
+
console.log('\n');
|
|
46
|
+
table(entries, true);
|
|
47
|
+
note('Only node_modules in the listed projects will be removed.');
|
|
48
|
+
note('Your project files and lockfiles will not be touched.');
|
|
49
|
+
console.log('\n');
|
|
50
|
+
}
|
|
51
|
+
export function cleanupComplete(removed, bytes, failed) {
|
|
52
|
+
console.log(`\n${pc.bold(textLines(failed ? 'Cleanup finished with errors.' : 'Cleanup complete.'))}\n`);
|
|
53
|
+
console.log(removed.length ? pc.green(sizeHero(formatSize(bytes))) : sizeHero(formatSize(bytes)));
|
|
54
|
+
console.log(textLines(`${removed.length} node_modules removed${failed ? ` · ${failed} failed` : ''}`));
|
|
55
|
+
note(`${removed.length} ${removed.length === 1 ? 'project' : 'projects'} untouched`);
|
|
56
|
+
console.log('\n');
|
|
57
|
+
}
|
|
58
|
+
export function restores(entries) {
|
|
59
|
+
note('Restore dependencies whenever you need them.');
|
|
60
|
+
console.log();
|
|
61
|
+
for (const manager of [...new Set(entries.map(entry => entry.manager))]) {
|
|
62
|
+
console.log(` ${pc.bold(`${manager} install`)}`);
|
|
63
|
+
for (const entry of entries.filter(entry => entry.manager === manager))
|
|
64
|
+
console.log(pc.dim(` ${truncate(projectLabel(entry, entries), terminalWidth() - 7)}`));
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
67
|
+
console.log(`\n${textLines('Done.')}\n`);
|
|
68
|
+
}
|
|
69
|
+
export function deletionFailure(entry, entries, reason) {
|
|
70
|
+
console.error(pc.red(textLines(`Could not remove ${projectLabel(entry, entries)}/node_modules`)));
|
|
71
|
+
console.error(pc.dim(textLines(`Reason: ${terminalText(reason)}`)));
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ScanProgress } from '../types.js';
|
|
2
|
+
export declare function progressBar(percent: number | null, width?: number, step?: number): string;
|
|
3
|
+
export declare function progressPercent(state: ScanProgress): number | null;
|
|
4
|
+
export declare function startScanProgress(): {
|
|
5
|
+
update: (state: ScanProgress) => void;
|
|
6
|
+
stop: () => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function startCleanupProgress(total: number): {
|
|
9
|
+
update: (completed: number, failed: number) => void;
|
|
10
|
+
stop: () => void;
|
|
11
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { terminalWidth, truncate } from './layout.js';
|
|
4
|
+
export function progressBar(percent, width = 80, step = 0) {
|
|
5
|
+
const length = Math.max(4, Math.min(32, width - 18));
|
|
6
|
+
if (percent !== null) {
|
|
7
|
+
const filled = Math.round(length * Math.max(0, Math.min(100, percent)) / 100);
|
|
8
|
+
return '█'.repeat(filled) + '░'.repeat(length - filled);
|
|
9
|
+
}
|
|
10
|
+
const segment = Math.min(5, Math.max(1, Math.floor(length / 3)));
|
|
11
|
+
const travel = Math.max(1, length - segment);
|
|
12
|
+
const offset = travel - Math.abs((step % (travel * 2)) - travel);
|
|
13
|
+
return '░'.repeat(offset) + '█'.repeat(segment) + '░'.repeat(length - segment - offset);
|
|
14
|
+
}
|
|
15
|
+
export function progressPercent(state) {
|
|
16
|
+
return state.phase === 'measurement' && state.total !== null && state.total > 0
|
|
17
|
+
? Math.floor(state.completed / state.total * 100) : null;
|
|
18
|
+
}
|
|
19
|
+
function startProgress(initial) {
|
|
20
|
+
let current = initial;
|
|
21
|
+
let frameKey = '';
|
|
22
|
+
const spinner = ora({ indent: 2, color: 'cyan', isEnabled: Boolean(process.stderr.isTTY), isSilent: !process.stderr.isTTY });
|
|
23
|
+
const update = (state) => {
|
|
24
|
+
current = state;
|
|
25
|
+
const width = terminalWidth();
|
|
26
|
+
const percent = state.percent;
|
|
27
|
+
const key = `${width}:${percent}`;
|
|
28
|
+
if (key !== frameKey) {
|
|
29
|
+
spinner.spinner = {
|
|
30
|
+
interval: 180,
|
|
31
|
+
frames: percent === null
|
|
32
|
+
? Array.from({ length: 56 }, (_, index) => progressBar(null, width, index))
|
|
33
|
+
: [progressBar(percent, width)],
|
|
34
|
+
};
|
|
35
|
+
frameKey = key;
|
|
36
|
+
}
|
|
37
|
+
const label = percent === null ? ' discovering' : ` ${pc.bold(`${percent}%`)}`;
|
|
38
|
+
spinner.text = `${label}\n\n ${pc.dim(truncate(state.detail(width), width - 6))}`;
|
|
39
|
+
};
|
|
40
|
+
update(current);
|
|
41
|
+
spinner.start();
|
|
42
|
+
const resize = () => update(current);
|
|
43
|
+
process.stdout.on('resize', resize);
|
|
44
|
+
return {
|
|
45
|
+
update,
|
|
46
|
+
stop: () => {
|
|
47
|
+
process.stdout.off('resize', resize);
|
|
48
|
+
if (spinner.isSpinning && current.percent === 100) {
|
|
49
|
+
spinner.stopAndPersist({ symbol: pc.cyan(progressBar(100, terminalWidth())), text: pc.bold('100%') });
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
spinner.stop();
|
|
53
|
+
if (process.stderr.isTTY)
|
|
54
|
+
console.log();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function startScanProgress() {
|
|
59
|
+
const view = (state) => ({
|
|
60
|
+
percent: progressPercent(state),
|
|
61
|
+
detail: width => state.phase === 'discovery'
|
|
62
|
+
? (width < 48 ? `${state.projects} projects, ${state.folders} dirs` : `${state.projects} projects · ${state.folders} dependency folders`)
|
|
63
|
+
: `${state.completed}/${state.total} ${width < 48 ? 'measured' : 'dependency folders measured'}`,
|
|
64
|
+
});
|
|
65
|
+
const progress = startProgress(view({ phase: 'discovery', projects: 0, folders: 0, completed: 0, total: null }));
|
|
66
|
+
return { update: state => progress.update(view(state)), stop: progress.stop };
|
|
67
|
+
}
|
|
68
|
+
export function startCleanupProgress(total) {
|
|
69
|
+
const view = (completed, failed) => ({
|
|
70
|
+
percent: total > 0 ? Math.floor(completed / total * 100) : 100,
|
|
71
|
+
detail: width => `${width < 48 ? 'Cleaning' : 'Cleaning dependencies'} · ${completed}/${total} processed${failed ? ` · ${failed} failed` : ''}`,
|
|
72
|
+
});
|
|
73
|
+
const progress = startProgress(view(0, 0));
|
|
74
|
+
return { update: (completed, failed) => progress.update(view(completed, failed)), stop: progress.stop };
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import stringWidth from 'string-width';
|
|
3
|
+
import { terminalWidth, textLines } from './layout.js';
|
|
4
|
+
// Outlined, slanted letterforms: each stroke has two visible edges.
|
|
5
|
+
const outlined = {
|
|
6
|
+
n: [' ', ' ____ ', ' / __ \\ ', '/ / / / ', '/_/ /_/ '],
|
|
7
|
+
c: [' ', ' _____ ', ' / ___/ ', '/ /__ ', '\\____/ '],
|
|
8
|
+
l: [' __ ', ' / / ', ' / / ', '/ /__ ', '\\___/ '],
|
|
9
|
+
e: [' ', ' ____ ', ' / __ \\ ', '/ ____/ ', '\\___/ '],
|
|
10
|
+
a: [' ', ' ____ ', ' / __ | ', '/ /_/ / ', '\\__,_/ '],
|
|
11
|
+
};
|
|
12
|
+
export function wordmark(width = terminalWidth()) {
|
|
13
|
+
if (width < 50)
|
|
14
|
+
return ['nclean'];
|
|
15
|
+
const gap = width >= 60 ? ' ' : '';
|
|
16
|
+
return outlined.n.map((_, row) => [...'nclean'].map(char => outlined[char][row].trimEnd().padEnd(7)).join(gap).trimEnd());
|
|
17
|
+
}
|
|
18
|
+
const digits = {
|
|
19
|
+
'0': [' _ ', '| |', '|_|'], '1': [' ', ' |', ' |'],
|
|
20
|
+
'2': [' _ ', ' _|', '|_ '], '3': [' _ ', ' _|', ' _|'],
|
|
21
|
+
'4': [' ', '|_|', ' |'], '5': [' _ ', '|_ ', ' _|'],
|
|
22
|
+
'6': [' _ ', '|_ ', '|_|'], '7': [' _ ', ' |', ' |'],
|
|
23
|
+
'8': [' _ ', '|_|', '|_|'], '9': [' _ ', '|_|', ' _|'],
|
|
24
|
+
'.': [' ', ' ', '.'],
|
|
25
|
+
};
|
|
26
|
+
export function sizeHero(size, width = terminalWidth()) {
|
|
27
|
+
const [value = '', unit = ''] = size.split(' ');
|
|
28
|
+
const glyphs = [...value].map(char => digits[char]);
|
|
29
|
+
const art = glyphs.every(Boolean) ? [0, 1, 2].map(row => glyphs.map(glyph => glyph[row]).join(' ')) : [];
|
|
30
|
+
// The unit stays in native type, aligned with the enlarged number's baseline.
|
|
31
|
+
const artWidth = art.length ? stringWidth(`${art[2]} ${unit}`) : Infinity;
|
|
32
|
+
const column = Math.min(64, width - 4);
|
|
33
|
+
if (width < 48 || artWidth > column)
|
|
34
|
+
return `\n${pc.bold(textLines(size, width))}\n${pc.dim(textLines('space reclaimed', width))}\n`;
|
|
35
|
+
const indent = ' '.repeat(2 + Math.floor((column - artWidth) / 2));
|
|
36
|
+
const lines = art.map((line, row) => pc.bold(`${indent}${line}${row === 2 ? ` ${unit}` : ''}`));
|
|
37
|
+
const caption = 'space reclaimed';
|
|
38
|
+
const captionIndent = ' '.repeat(2 + Math.max(0, Math.floor((column - caption.length) / 2)));
|
|
39
|
+
return `\n${lines.join('\n')}\n\n${pc.dim(`${captionIndent}${caption}`)}\n`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DAY = 86400000;
|
|
2
|
+
export declare function formatSize(bytes: number): string;
|
|
3
|
+
export declare function formatAge(time: number | null, now?: number): string;
|
|
4
|
+
export declare function oldEnough(time: number | null, days: number, now?: number): boolean;
|
|
5
|
+
export declare function errorMessage(error: unknown): string;
|
|
6
|
+
export declare function terminalText(value: string): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const DAY = 86_400_000;
|
|
2
|
+
export function formatSize(bytes) {
|
|
3
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
4
|
+
let i = 0;
|
|
5
|
+
while (bytes >= 1024 && i < units.length - 1) {
|
|
6
|
+
bytes /= 1024;
|
|
7
|
+
i++;
|
|
8
|
+
}
|
|
9
|
+
return `${bytes < 100 && i > 0 ? bytes.toFixed(1) : Math.round(bytes)} ${units[i]}`;
|
|
10
|
+
}
|
|
11
|
+
export function formatAge(time, now = Date.now()) {
|
|
12
|
+
if (time === null)
|
|
13
|
+
return 'unknown';
|
|
14
|
+
const days = Math.floor(Math.max(0, now - time) / DAY);
|
|
15
|
+
return days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`;
|
|
16
|
+
}
|
|
17
|
+
export function oldEnough(time, days, now = Date.now()) {
|
|
18
|
+
return time !== null && now - time >= days * DAY;
|
|
19
|
+
}
|
|
20
|
+
export function errorMessage(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
// Prevent filenames from injecting terminal escape sequences or extra rows.
|
|
24
|
+
export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f]/g, '?'); }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const lockfiles = [
|
|
4
|
+
['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'],
|
|
5
|
+
];
|
|
6
|
+
export async function detectManager(project) {
|
|
7
|
+
for (const [file, manager] of lockfiles) {
|
|
8
|
+
try {
|
|
9
|
+
if ((await lstat(path.join(project, file))).isFile())
|
|
10
|
+
return manager;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== 'ENOENT')
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return 'npm';
|
|
18
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function expandPath(input: string): string;
|
|
2
|
+
export declare function within(root: string, target: string): boolean;
|
|
3
|
+
export declare function displayPath(value: string): string;
|
|
4
|
+
export declare function scanRoots(inputs: string[]): Promise<string[]>;
|
|
5
|
+
export declare function assertRealPath(root: string, target: string): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { terminalText } from './format.js';
|
|
5
|
+
export function expandPath(input) {
|
|
6
|
+
if (input === '~')
|
|
7
|
+
return os.homedir();
|
|
8
|
+
return path.resolve(input.startsWith('~/') || input.startsWith('~\\') ? path.join(os.homedir(), input.slice(2)) : input);
|
|
9
|
+
}
|
|
10
|
+
export function within(root, target) {
|
|
11
|
+
const relative = path.relative(root, target);
|
|
12
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
13
|
+
}
|
|
14
|
+
export function displayPath(value) {
|
|
15
|
+
const home = os.homedir();
|
|
16
|
+
return terminalText(within(home, value) ? `~${value.slice(home.length)}` : value);
|
|
17
|
+
}
|
|
18
|
+
export async function scanRoots(inputs) {
|
|
19
|
+
const roots = [];
|
|
20
|
+
for (const input of inputs.length ? inputs : [process.cwd()]) {
|
|
21
|
+
const resolved = expandPath(input);
|
|
22
|
+
const stat = await lstat(resolved);
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
24
|
+
throw new Error(`Scan root must be a real directory: ${displayPath(resolved)}`);
|
|
25
|
+
const canonical = await realpath(resolved);
|
|
26
|
+
if (canonical === path.parse(canonical).root)
|
|
27
|
+
throw new Error('Scanning the filesystem root is not supported. Choose a project directory.');
|
|
28
|
+
if (canonical.split(path.sep).includes('node_modules'))
|
|
29
|
+
throw new Error('Choose a project directory, not a directory inside node_modules.');
|
|
30
|
+
roots.push(canonical);
|
|
31
|
+
}
|
|
32
|
+
return [...new Set(roots)].filter(root => !roots.some(other => other !== root && within(other, root)));
|
|
33
|
+
}
|
|
34
|
+
export async function assertRealPath(root, target) {
|
|
35
|
+
if (!within(root, target))
|
|
36
|
+
throw new Error('Path is outside the scan root.');
|
|
37
|
+
let current = root;
|
|
38
|
+
const parts = path.relative(root, target).split(path.sep).filter(Boolean);
|
|
39
|
+
for (const part of ['', ...parts]) {
|
|
40
|
+
if (part)
|
|
41
|
+
current = path.join(current, part);
|
|
42
|
+
const stat = await lstat(current);
|
|
43
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
44
|
+
throw new Error(`Directory changed or is a symlink: ${displayPath(current)}`);
|
|
45
|
+
}
|
|
46
|
+
if (await realpath(target) !== target)
|
|
47
|
+
throw new Error('Path resolves through a symlink.');
|
|
48
|
+
}
|