dsh-disk-cleaner 0.0.0-stage → 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 +21 -0
- package/README.md +427 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/lib/audit.js +262 -0
- package/lib/catalog.js +418 -0
- package/lib/client.js +1234 -0
- package/lib/index.js +331 -0
- package/lib/plan.js +150 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
- package/smoke.mjs +281 -0
package/lib/audit.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only space assessment.
|
|
3
|
+
*
|
|
4
|
+
* The catalog above can only ever *delete* from directories it already knows are caches.
|
|
5
|
+
* Most of the space on a real disk is not cache at all — it is installed programs, WSL
|
|
6
|
+
* disk images, chat-app data, Windows component store, and the user's own files. Those
|
|
7
|
+
* must never be auto-deleted, but the user still needs to see where the space went.
|
|
8
|
+
*
|
|
9
|
+
* So this module measures and reports, and exposes no deletion at all. Anything it finds
|
|
10
|
+
* is advisory: the panel shows it as a lead to investigate, not a button to press.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { drives, measurePath, scan } from './catalog.js';
|
|
17
|
+
import { buildPlan } from './plan.js';
|
|
18
|
+
|
|
19
|
+
const { promises: fsp } = fs;
|
|
20
|
+
|
|
21
|
+
const MB = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const env = (name) => {
|
|
24
|
+
const value = process.env[name];
|
|
25
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isDir = (p) => {
|
|
29
|
+
try {
|
|
30
|
+
return fs.statSync(p).isDirectory();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const sizeOfFile = (p) => {
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.statSync(p);
|
|
39
|
+
return stat.isFile() ? stat.size : 0;
|
|
40
|
+
} catch {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Each area is a category the user recognises, described by where it lives and why it is
|
|
47
|
+
* not something this plugin will remove. `group` separates the three different kinds of
|
|
48
|
+
* answer: `system` (Windows itself), `app` (installed software and its data), `personal`
|
|
49
|
+
* (the user's own files).
|
|
50
|
+
*/
|
|
51
|
+
const AREAS = () => {
|
|
52
|
+
const home = os.homedir();
|
|
53
|
+
const local = env('LOCALAPPDATA') ?? '';
|
|
54
|
+
const roaming = env('APPDATA') ?? '';
|
|
55
|
+
const systemDrive = env('SystemDrive') ?? 'C:';
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
id: 'wsl',
|
|
60
|
+
group: 'system',
|
|
61
|
+
label: 'WSL distros (ext4.vhdx)',
|
|
62
|
+
why: 'Virtual disks grow but never shrink on their own; compacting one is a separate, deliberate operation.',
|
|
63
|
+
paths: () => virtualDisks(path.join(local, 'wsl')),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'winsxs',
|
|
67
|
+
group: 'system',
|
|
68
|
+
label: 'Windows component store (WinSxS)',
|
|
69
|
+
why: 'Holds rollback copies of system components. Only DISM can service it, and it needs an elevated prompt.',
|
|
70
|
+
paths: () => [path.join(systemDrive + path.sep, 'Windows', 'WinSxS')],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'system-files',
|
|
74
|
+
group: 'system',
|
|
75
|
+
label: 'Hibernation and page files',
|
|
76
|
+
why: 'Reserved by Windows at boot. Turned off through system settings, never by deleting the file.',
|
|
77
|
+
files: () => ['hiberfil.sys', 'pagefile.sys', 'swapfile.sys'].map((name) => path.join(systemDrive + path.sep, name)),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'programs',
|
|
81
|
+
group: 'app',
|
|
82
|
+
label: 'Installed programs (user scope)',
|
|
83
|
+
why: 'Actual applications under your profile. Uninstall them, do not delete the folder.',
|
|
84
|
+
paths: () => [path.join(local, 'Programs')],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'program-files',
|
|
88
|
+
group: 'app',
|
|
89
|
+
label: 'Installed programs (machine scope)',
|
|
90
|
+
why: 'Machine-wide applications. Uninstall through Settings.',
|
|
91
|
+
paths: () => [path.join(systemDrive + path.sep, 'Program Files'), path.join(systemDrive + path.sep, 'Program Files (x86)')],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'downloads',
|
|
95
|
+
group: 'personal',
|
|
96
|
+
label: 'Downloads',
|
|
97
|
+
why: 'Your own files. Only you can decide what is finished with.',
|
|
98
|
+
paths: () => [path.join(home, 'Downloads')],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'documents',
|
|
102
|
+
group: 'personal',
|
|
103
|
+
label: 'Documents',
|
|
104
|
+
why: 'Your own files.',
|
|
105
|
+
paths: () => [path.join(home, 'Documents')],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'pictures',
|
|
109
|
+
group: 'personal',
|
|
110
|
+
label: 'Pictures',
|
|
111
|
+
why: 'Your own files.',
|
|
112
|
+
paths: () => [path.join(home, 'Pictures')],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'videos',
|
|
116
|
+
group: 'personal',
|
|
117
|
+
label: 'Videos',
|
|
118
|
+
why: 'Your own files.',
|
|
119
|
+
paths: () => [path.join(home, 'Videos')],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'desktop',
|
|
123
|
+
group: 'personal',
|
|
124
|
+
label: 'Desktop',
|
|
125
|
+
why: 'Your own files.',
|
|
126
|
+
paths: () => [path.join(home, 'Desktop')],
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** WSL keeps one ext4.vhdx per distro, one level down under a GUID-named folder. */
|
|
132
|
+
const virtualDisks = (root) => {
|
|
133
|
+
if (!isDir(root)) return [];
|
|
134
|
+
const out = [];
|
|
135
|
+
let entries = [];
|
|
136
|
+
try {
|
|
137
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
138
|
+
} catch {
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
143
|
+
const distro = path.join(root, entry.name);
|
|
144
|
+
let inner = [];
|
|
145
|
+
try {
|
|
146
|
+
inner = fs.readdirSync(distro, { withFileTypes: true });
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const item of inner) {
|
|
151
|
+
if (item.isFile() && item.name.toLowerCase().endsWith('.vhdx')) out.push(path.join(distro, item.name));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Measure every area that can be addressed at all. Areas that resolve to nothing are
|
|
159
|
+
* dropped rather than reported as zero, so the list only shows what actually exists.
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* Long enough that returning to the panel within a normal work session is instant, short
|
|
163
|
+
* enough that a folder cleared half an hour ago does not still show its old size.
|
|
164
|
+
*/
|
|
165
|
+
const CACHE_TTL = 600_000;
|
|
166
|
+
let cached = undefined;
|
|
167
|
+
let cachedAt = 0;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A real deletion invalidates the measurement it was based on: the panel re-assesses right
|
|
171
|
+
* after a clean, and serving the old numbers would keep recommending space already freed.
|
|
172
|
+
*/
|
|
173
|
+
export function invalidateAudit() {
|
|
174
|
+
cached = undefined;
|
|
175
|
+
cachedAt = 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Measure the whole disk's non-cache space. This is a deep walk (it takes a minute or two
|
|
180
|
+
* on a full disk), so a result is reused for a short while: the panel and the agent ask
|
|
181
|
+
* for the same picture within moments of each other, and re-walking every app folder for
|
|
182
|
+
* the second caller buys nothing.
|
|
183
|
+
*/
|
|
184
|
+
export async function audit() {
|
|
185
|
+
if (cached !== undefined && Date.now() - cachedAt < CACHE_TTL) return { ...cached, cached: true };
|
|
186
|
+
const [areas, largestApps, reclaimable] = await Promise.all([
|
|
187
|
+
Promise.all(AREAS().map(measureArea)),
|
|
188
|
+
largestAppDirs(),
|
|
189
|
+
scan(),
|
|
190
|
+
]);
|
|
191
|
+
const measured = areas.filter(Boolean).sort((a, b) => b.bytes - a.bytes);
|
|
192
|
+
const plan = buildPlan({ areas: measured, largestApps, reclaimable, driveInfo: drives() });
|
|
193
|
+
cached = { areas: measured, largestApps, plan };
|
|
194
|
+
cachedAt = Date.now();
|
|
195
|
+
return { ...cached, cached: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Measure one area, or return null when none of its locations exist. */
|
|
199
|
+
async function measureArea(area) {
|
|
200
|
+
let bytes = 0;
|
|
201
|
+
let files = 0;
|
|
202
|
+
let present = 0;
|
|
203
|
+
let first;
|
|
204
|
+
|
|
205
|
+
for (const file of area.files?.() ?? []) {
|
|
206
|
+
const size = sizeOfFile(file);
|
|
207
|
+
if (size > 0) {
|
|
208
|
+
bytes += size;
|
|
209
|
+
files += 1;
|
|
210
|
+
present += 1;
|
|
211
|
+
first ??= file;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const abs of area.paths?.() ?? []) {
|
|
215
|
+
const found = isDir(abs) ? await measurePath(abs) : { bytes: sizeOfFile(abs), files: 1 };
|
|
216
|
+
if (found.bytes <= 0 && found.files <= 0) continue;
|
|
217
|
+
bytes += found.bytes;
|
|
218
|
+
files += found.files;
|
|
219
|
+
present += 1;
|
|
220
|
+
first ??= abs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (present === 0) return null;
|
|
224
|
+
return { id: area.id, group: area.group, label: area.label, why: area.why, bytes, files, first };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The single biggest contributors under the two app-data roots. This is what makes the
|
|
229
|
+
* audit actionable: "3.6 GB in Roaming\kingsoft" points somewhere a Settings page does not.
|
|
230
|
+
*
|
|
231
|
+
* ponytail: the trees under the named areas (Programs, wsl, Temp, Downloads) get walked a
|
|
232
|
+
* second time here, which is most of the ~45s first-run cost. The TTL cache means only the
|
|
233
|
+
* first caller pays and every later one is instant. Measure each app-data root once into a
|
|
234
|
+
* shared map only if a cold run is still too slow to sit through.
|
|
235
|
+
*/
|
|
236
|
+
async function largestAppDirs() {
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const [root, label] of [
|
|
239
|
+
[env('LOCALAPPDATA'), 'local'],
|
|
240
|
+
[env('APPDATA'), 'roaming'],
|
|
241
|
+
]) {
|
|
242
|
+
if (root === undefined || !isDir(root)) continue;
|
|
243
|
+
let entries = [];
|
|
244
|
+
try {
|
|
245
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
246
|
+
} catch {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const found = await Promise.all(
|
|
250
|
+
entries
|
|
251
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
252
|
+
.map(async (entry) => {
|
|
253
|
+
const measured = await measurePath(path.join(root, entry.name));
|
|
254
|
+
return { scope: label, name: entry.name, bytes: measured.bytes, files: measured.files };
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
for (const item of found) {
|
|
258
|
+
if (item.bytes >= 100 * MB) out.push(item);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out.sort((a, b) => b.bytes - a.bytes).slice(0, 10);
|
|
262
|
+
}
|
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known-safe cleanup targets and the two operations over them: measure and purge.
|
|
3
|
+
*
|
|
4
|
+
* Safety rules that must never be relaxed here:
|
|
5
|
+
* - Only paths built from this catalog are ever touched. Callers select catalog ids,
|
|
6
|
+
* never raw paths, so no user-supplied string can reach an unlink.
|
|
7
|
+
* - A resolved target must sit inside the environment root it was built from, checked
|
|
8
|
+
* after realpath, so a junction planted in a cache directory cannot widen the blast radius.
|
|
9
|
+
* - Symlinks and junctions met during a walk are skipped, never followed or deleted.
|
|
10
|
+
* - Locked or denied entries are counted as failures and the walk continues.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const { promises: fsp } = fs;
|
|
16
|
+
|
|
17
|
+
const DAY = 86_400_000;
|
|
18
|
+
const MAX_ENTRIES = 2_000_000;
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ id: string, root: string, paths: string[] }} Resolved */
|
|
21
|
+
|
|
22
|
+
const env = (name) => {
|
|
23
|
+
const value = process.env[name];
|
|
24
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isDir = (p) => {
|
|
28
|
+
try {
|
|
29
|
+
return fs.statSync(p).isDirectory();
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const subdirs = (parent, names) => {
|
|
36
|
+
const out = [];
|
|
37
|
+
if (!isDir(parent)) return out;
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const candidate = path.join(parent, name);
|
|
40
|
+
if (isDir(candidate)) out.push(candidate);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The per-account recycle bin directories on every mounted volume. Each SID directory
|
|
47
|
+
* is a real three-segment path, so purging them keeps the `tooBroad` guard intact and
|
|
48
|
+
* never reaches the $Recycle.Bin container itself.
|
|
49
|
+
*/
|
|
50
|
+
const recycleBins = () => {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
53
|
+
const base = `${letter}:\\$Recycle.Bin`;
|
|
54
|
+
if (!isDir(base)) continue;
|
|
55
|
+
let entries = [];
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
63
|
+
if (!/^S-1-5-/.test(entry.name)) continue;
|
|
64
|
+
out.push(path.join(base, entry.name));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Chromium-family caches live per browser profile, so they are discovered, not spelled out. */
|
|
71
|
+
const browserCaches = (userData) => {
|
|
72
|
+
if (!isDir(userData)) return [];
|
|
73
|
+
const out = [];
|
|
74
|
+
let entries = [];
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(userData, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory()) continue;
|
|
82
|
+
const profile = path.join(userData, entry.name);
|
|
83
|
+
out.push(...subdirs(profile, ['Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'ShaderCache', 'GrShaderCache']));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `purge` targets drop every entry under the path. `aged` targets keep anything modified
|
|
90
|
+
* within `minAgeDays`, because those files may belong to a process that is still running.
|
|
91
|
+
*
|
|
92
|
+
* `note` says why a target carries the `caution` risk, in one stable code the UI
|
|
93
|
+
* translates: 'in-use' (a running program may hold these files), 'rebuild' (the owner
|
|
94
|
+
* regenerates them, so the first use afterwards is slower), or 'elevation' (Windows
|
|
95
|
+
* refuses an unelevated delete). Safe targets have none.
|
|
96
|
+
*/
|
|
97
|
+
const CATALOG = [
|
|
98
|
+
{
|
|
99
|
+
id: 'temp',
|
|
100
|
+
kind: 'aged',
|
|
101
|
+
minAgeDays: 1,
|
|
102
|
+
risk: 'caution',
|
|
103
|
+
note: 'in-use',
|
|
104
|
+
roots: ['TEMP', 'LOCALAPPDATA'],
|
|
105
|
+
paths: () => [env('TEMP'), env('TMP'), path.join(env('LOCALAPPDATA') ?? '', 'Temp')],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'windows-temp',
|
|
109
|
+
kind: 'aged',
|
|
110
|
+
minAgeDays: 1,
|
|
111
|
+
risk: 'caution',
|
|
112
|
+
// Measured on Windows 11: this folder's ACL grants BUILTIN\Users create and delete, so an
|
|
113
|
+
// unelevated run does remove what it owns here — the real blocker is EBUSY on a file a
|
|
114
|
+
// service still holds, which the age limit already handles. Calling it `elevation` sent
|
|
115
|
+
// the user to an elevated prompt that changed nothing and hid the bytes from the plan.
|
|
116
|
+
note: 'in-use',
|
|
117
|
+
roots: [],
|
|
118
|
+
paths: () => ['C:\\Windows\\Temp'],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'npm-cache',
|
|
122
|
+
kind: 'purge',
|
|
123
|
+
risk: 'safe',
|
|
124
|
+
roots: ['LOCALAPPDATA'],
|
|
125
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'npm-cache')],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'pnpm-store',
|
|
129
|
+
kind: 'purge',
|
|
130
|
+
risk: 'safe',
|
|
131
|
+
roots: ['LOCALAPPDATA'],
|
|
132
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'store')],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'pip-cache',
|
|
136
|
+
kind: 'purge',
|
|
137
|
+
risk: 'safe',
|
|
138
|
+
roots: ['LOCALAPPDATA'],
|
|
139
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'pip', 'Cache')],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'uv-cache',
|
|
143
|
+
kind: 'purge',
|
|
144
|
+
risk: 'safe',
|
|
145
|
+
roots: ['LOCALAPPDATA'],
|
|
146
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'uv', 'cache')],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'crash-dumps',
|
|
150
|
+
kind: 'purge',
|
|
151
|
+
risk: 'safe',
|
|
152
|
+
roots: ['LOCALAPPDATA'],
|
|
153
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'CrashDumps')],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'chrome-cache',
|
|
157
|
+
kind: 'purge',
|
|
158
|
+
risk: 'caution',
|
|
159
|
+
note: 'rebuild',
|
|
160
|
+
roots: ['LOCALAPPDATA'],
|
|
161
|
+
paths: () => browserCaches(path.join(env('LOCALAPPDATA') ?? '', 'Google', 'Chrome', 'User Data')),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'edge-cache',
|
|
165
|
+
kind: 'purge',
|
|
166
|
+
risk: 'caution',
|
|
167
|
+
note: 'rebuild',
|
|
168
|
+
roots: ['LOCALAPPDATA'],
|
|
169
|
+
paths: () => browserCaches(path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Edge', 'User Data')),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'code-cache',
|
|
173
|
+
kind: 'purge',
|
|
174
|
+
risk: 'caution',
|
|
175
|
+
note: 'rebuild',
|
|
176
|
+
roots: ['APPDATA'],
|
|
177
|
+
paths: () => subdirs(env('APPDATA') ?? '', [path.join('Code', 'Cache'), path.join('Code', 'CachedData'), path.join('Code', 'CachedExtensionVSIXs')]),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'windows-update',
|
|
181
|
+
kind: 'purge',
|
|
182
|
+
risk: 'caution',
|
|
183
|
+
note: 'elevation',
|
|
184
|
+
roots: [],
|
|
185
|
+
paths: () => ['C:\\Windows\\SoftwareDistribution\\Download'],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'recycle-bin',
|
|
189
|
+
kind: 'purge',
|
|
190
|
+
risk: 'safe',
|
|
191
|
+
roots: [],
|
|
192
|
+
paths: () => recycleBins(),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'inet-cache',
|
|
196
|
+
kind: 'purge',
|
|
197
|
+
risk: 'caution',
|
|
198
|
+
note: 'rebuild',
|
|
199
|
+
roots: ['LOCALAPPDATA'],
|
|
200
|
+
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Windows', 'INetCache')],
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
|
|
205
|
+
|
|
206
|
+
/** A path is refused unless it is deep enough to be a cache and not a filesystem root. */
|
|
207
|
+
const tooBroad = (abs) => {
|
|
208
|
+
const parts = path.resolve(abs).split(path.sep).filter(Boolean);
|
|
209
|
+
return parts.length < 3 || /^[A-Za-z]:$/.test(path.resolve(abs));
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const realOrSelf = (p) => {
|
|
213
|
+
try {
|
|
214
|
+
return fs.realpathSync.native(p);
|
|
215
|
+
} catch {
|
|
216
|
+
return path.resolve(p);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const within = (abs, root) => {
|
|
221
|
+
const a = realOrSelf(abs);
|
|
222
|
+
const r = realOrSelf(root);
|
|
223
|
+
const rel = path.relative(r, a);
|
|
224
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** A volume such as `C:\`; anything else (including undefined) means every volume. */
|
|
228
|
+
const onDrive = (abs, drive) => drive === undefined || path.parse(abs).root.toLowerCase() === drive.toLowerCase();
|
|
229
|
+
|
|
230
|
+
/** Resolve catalog ids to the concrete directories that exist right now, optionally on one volume. */
|
|
231
|
+
export function resolveTargets(only, drive) {
|
|
232
|
+
const selected = only === undefined ? CATALOG : CATALOG.filter((entry) => only.includes(entry.id));
|
|
233
|
+
return selected.map((entry) => {
|
|
234
|
+
const found = [];
|
|
235
|
+
for (const candidate of entry.paths()) {
|
|
236
|
+
if (typeof candidate !== 'string' || candidate === '') continue;
|
|
237
|
+
if (tooBroad(candidate)) continue;
|
|
238
|
+
if (!isDir(candidate)) continue;
|
|
239
|
+
if (!onDrive(candidate, drive)) continue;
|
|
240
|
+
const roots = entry.roots.map((name) => env(name)).filter(Boolean);
|
|
241
|
+
if (roots.length > 0 && !roots.some((root) => within(candidate, root))) continue;
|
|
242
|
+
if (!found.includes(candidate)) found.push(candidate);
|
|
243
|
+
}
|
|
244
|
+
// The volume each target sits on, so a caller can group or filter by drive.
|
|
245
|
+
const drives = [...new Set(found.map((abs) => path.parse(abs).root))];
|
|
246
|
+
return {
|
|
247
|
+
id: entry.id,
|
|
248
|
+
kind: entry.kind,
|
|
249
|
+
risk: entry.risk,
|
|
250
|
+
note: entry.note ?? '',
|
|
251
|
+
minAgeDays: entry.minAgeDays ?? 0,
|
|
252
|
+
paths: found,
|
|
253
|
+
drives,
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function measurePath(abs) {
|
|
259
|
+
let bytes = 0;
|
|
260
|
+
let files = 0;
|
|
261
|
+
let seen = 0;
|
|
262
|
+
const stack = [abs];
|
|
263
|
+
while (stack.length > 0 && seen < MAX_ENTRIES) {
|
|
264
|
+
const dir = stack.pop();
|
|
265
|
+
let handle;
|
|
266
|
+
try {
|
|
267
|
+
handle = await fsp.opendir(dir);
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
for await (const entry of handle) {
|
|
273
|
+
seen += 1;
|
|
274
|
+
if (entry.isSymbolicLink()) continue;
|
|
275
|
+
if (entry.isDirectory()) {
|
|
276
|
+
stack.push(path.join(dir, entry.name));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!entry.isFile()) continue;
|
|
280
|
+
try {
|
|
281
|
+
bytes += (await fsp.stat(path.join(dir, entry.name))).size;
|
|
282
|
+
files += 1;
|
|
283
|
+
} catch {
|
|
284
|
+
/* a file that vanished or is locked contributes nothing */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
/* an unreadable directory ends this branch */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { bytes, files };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Measure every resolved target concurrently, keeping the host event loop free. */
|
|
295
|
+
export async function scan(only, drive) {
|
|
296
|
+
const targets = resolveTargets(only, drive);
|
|
297
|
+
const measured = await Promise.all(
|
|
298
|
+
targets.map(async (target) => {
|
|
299
|
+
let bytes = 0;
|
|
300
|
+
let files = 0;
|
|
301
|
+
for (const abs of target.paths) {
|
|
302
|
+
const size = await measurePath(abs);
|
|
303
|
+
bytes += size.bytes;
|
|
304
|
+
files += size.files;
|
|
305
|
+
}
|
|
306
|
+
return { ...target, bytes, files };
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
return measured;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function purgePath(abs, cutoff) {
|
|
313
|
+
let bytes = 0;
|
|
314
|
+
let removed = 0;
|
|
315
|
+
let failed = 0;
|
|
316
|
+
const dirs = [];
|
|
317
|
+
const stack = [abs];
|
|
318
|
+
while (stack.length > 0 && dirs.length < MAX_ENTRIES) {
|
|
319
|
+
const dir = stack.pop();
|
|
320
|
+
dirs.push(dir);
|
|
321
|
+
let handle;
|
|
322
|
+
try {
|
|
323
|
+
handle = await fsp.opendir(dir);
|
|
324
|
+
} catch {
|
|
325
|
+
failed += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
for await (const entry of handle) {
|
|
330
|
+
const full = path.join(dir, entry.name);
|
|
331
|
+
if (entry.isSymbolicLink()) continue;
|
|
332
|
+
if (entry.isDirectory()) {
|
|
333
|
+
stack.push(full);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!entry.isFile()) continue;
|
|
337
|
+
try {
|
|
338
|
+
const stat = await fsp.stat(full);
|
|
339
|
+
if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
|
|
340
|
+
await fsp.unlink(full);
|
|
341
|
+
bytes += stat.size;
|
|
342
|
+
removed += 1;
|
|
343
|
+
} catch {
|
|
344
|
+
failed += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
failed += 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Deepest first; index 0 is the target itself, which stays as an empty directory.
|
|
352
|
+
for (let i = dirs.length - 1; i > 0; i -= 1) {
|
|
353
|
+
try {
|
|
354
|
+
await fsp.rmdir(dirs[i]);
|
|
355
|
+
} catch {
|
|
356
|
+
/* still holds a kept or undeletable file, which is expected */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { bytes, removed, failed };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Delete the contents of the selected targets, or with `dryRun` only report what it would delete. */
|
|
363
|
+
export async function clean(ids, options = {}) {
|
|
364
|
+
const dryRun = options.dryRun === true;
|
|
365
|
+
const targets = resolveTargets(ids, options.drive);
|
|
366
|
+
const results = [];
|
|
367
|
+
for (const target of targets) {
|
|
368
|
+
const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
|
|
369
|
+
if (dryRun) {
|
|
370
|
+
let bytes = 0;
|
|
371
|
+
let files = 0;
|
|
372
|
+
for (const abs of target.paths) {
|
|
373
|
+
const size = await measurePath(abs);
|
|
374
|
+
bytes += size.bytes;
|
|
375
|
+
files += size.files;
|
|
376
|
+
}
|
|
377
|
+
results.push({ id: target.id, bytes, removed: files, failed: 0 });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
let bytes = 0;
|
|
381
|
+
let removed = 0;
|
|
382
|
+
let failed = 0;
|
|
383
|
+
for (const abs of target.paths) {
|
|
384
|
+
if (tooBroad(abs)) {
|
|
385
|
+
failed += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const outcome = await purgePath(abs, cutoff);
|
|
389
|
+
bytes += outcome.bytes;
|
|
390
|
+
removed += outcome.removed;
|
|
391
|
+
failed += outcome.failed;
|
|
392
|
+
}
|
|
393
|
+
results.push({ id: target.id, bytes, removed, failed });
|
|
394
|
+
}
|
|
395
|
+
return results;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Per-drive capacity, measured with the same call the filesystem itself answers. */
|
|
399
|
+
export function drives() {
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
402
|
+
const root = `${letter}:\\`;
|
|
403
|
+
try {
|
|
404
|
+
const stat = fs.statfsSync(root);
|
|
405
|
+
const blockSize = Number(stat.bsize);
|
|
406
|
+
const total = blockSize * Number(stat.blocks);
|
|
407
|
+
const free = blockSize * Number(stat.bavail);
|
|
408
|
+
if (!Number.isFinite(total) || total <= 0) continue;
|
|
409
|
+
out.push({ root, total, free, used: total - free });
|
|
410
|
+
} catch {
|
|
411
|
+
/* no volume at this letter */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const catalogIds = () => CATALOG.map((entry) => entry.id);
|
|
418
|
+
export const hasTarget = (id) => byId.has(id);
|