@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.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 +38 -2
- package/client.js +754 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1052 -0
- package/host/actions-win32.js +1299 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2057 -0
- package/host/collector.js +71 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +294 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +65 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — cleanup scanner and deleter (macOS).
|
|
3
|
+
*
|
|
4
|
+
* A fixed allowlist of cache/log/trash/developer roots, a bounded recursive
|
|
5
|
+
* size walk (`lstat` only, symlinks never followed) and a delete path that can
|
|
6
|
+
* only ever touch one of those fixed absolute paths. Callers pass cleanup item
|
|
7
|
+
* ids, never paths: `deleteCleanup(['user-caches'])` is the whole public
|
|
8
|
+
* surface, and an id that is not in the allowlist is refused before any
|
|
9
|
+
* filesystem call.
|
|
10
|
+
*
|
|
11
|
+
* Everything returned is JSON-safe and no public function throws: per-item
|
|
12
|
+
* failures (permissions, disappearing files, admin-only roots) are reported in
|
|
13
|
+
* the result instead.
|
|
14
|
+
*
|
|
15
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/cleanup-darwin
|
|
16
|
+
*/
|
|
17
|
+
import * as fsp from 'node:fs/promises';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** Host platform for this implementation. */
|
|
22
|
+
export const PLATFORM = 'darwin';
|
|
23
|
+
|
|
24
|
+
/** Scan cache TTL (ms). */
|
|
25
|
+
const SCAN_TTL = 60_000;
|
|
26
|
+
/** Maximum directory entries visited per root (scan and measure). */
|
|
27
|
+
const MAX_ENTRIES = 150_000;
|
|
28
|
+
/** Wall-clock budget per root (ms). */
|
|
29
|
+
const TIME_BUDGET_MS = 5_000;
|
|
30
|
+
/** Maximum ids accepted by one deletion request. */
|
|
31
|
+
const MAX_DELETE_IDS = 20;
|
|
32
|
+
/** Maximum diagnostic strings kept in one result. */
|
|
33
|
+
const MAX_ERRORS = 40;
|
|
34
|
+
|
|
35
|
+
const HOME = os.homedir();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The frozen cleanup allowlist. Every `path` here is a fixed absolute path
|
|
39
|
+
* computed once at module load; nothing in this list is ever derived from a
|
|
40
|
+
* request.
|
|
41
|
+
*
|
|
42
|
+
* `mode: 'recreate'` removes the whole directory and recreates it empty;
|
|
43
|
+
* `mode: 'empty'` removes only the directory's contents (Trash keeps its own
|
|
44
|
+
* directory and permissions).
|
|
45
|
+
*/
|
|
46
|
+
const ITEMS = Object.freeze(
|
|
47
|
+
[
|
|
48
|
+
{
|
|
49
|
+
id: 'user-caches',
|
|
50
|
+
label: 'User caches',
|
|
51
|
+
detail: '~/Library/Caches',
|
|
52
|
+
category: 'cache',
|
|
53
|
+
path: path.join(HOME, 'Library', 'Caches'),
|
|
54
|
+
requiresAdmin: false,
|
|
55
|
+
kind: 'fs',
|
|
56
|
+
mode: 'recreate',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'user-logs',
|
|
60
|
+
label: 'User logs',
|
|
61
|
+
detail: '~/Library/Logs',
|
|
62
|
+
category: 'logs',
|
|
63
|
+
path: path.join(HOME, 'Library', 'Logs'),
|
|
64
|
+
requiresAdmin: false,
|
|
65
|
+
kind: 'fs',
|
|
66
|
+
mode: 'recreate',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'trash',
|
|
70
|
+
label: 'Trash',
|
|
71
|
+
detail: '~/.Trash',
|
|
72
|
+
category: 'trash',
|
|
73
|
+
path: path.join(HOME, '.Trash'),
|
|
74
|
+
requiresAdmin: false,
|
|
75
|
+
kind: 'fs',
|
|
76
|
+
mode: 'empty',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'xcode-derived-data',
|
|
80
|
+
label: 'Xcode DerivedData',
|
|
81
|
+
detail: '~/Library/Developer/Xcode/DerivedData',
|
|
82
|
+
category: 'developer',
|
|
83
|
+
path: path.join(HOME, 'Library', 'Developer', 'Xcode', 'DerivedData'),
|
|
84
|
+
requiresAdmin: false,
|
|
85
|
+
kind: 'fs',
|
|
86
|
+
mode: 'recreate',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'crash-reports',
|
|
90
|
+
label: 'Crash reporter logs',
|
|
91
|
+
detail: '~/Library/Application Support/CrashReporter',
|
|
92
|
+
category: 'logs',
|
|
93
|
+
path: path.join(HOME, 'Library', 'Application Support', 'CrashReporter'),
|
|
94
|
+
requiresAdmin: false,
|
|
95
|
+
kind: 'fs',
|
|
96
|
+
mode: 'recreate',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'core-simulator-caches',
|
|
100
|
+
label: 'CoreSimulator caches',
|
|
101
|
+
detail: '~/Library/Developer/CoreSimulator/Caches',
|
|
102
|
+
category: 'developer',
|
|
103
|
+
path: path.join(HOME, 'Library', 'Developer', 'CoreSimulator', 'Caches'),
|
|
104
|
+
requiresAdmin: false,
|
|
105
|
+
kind: 'fs',
|
|
106
|
+
mode: 'recreate',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'system-caches',
|
|
110
|
+
label: 'System caches',
|
|
111
|
+
detail: '/Library/Caches',
|
|
112
|
+
category: 'cache',
|
|
113
|
+
path: '/Library/Caches',
|
|
114
|
+
requiresAdmin: true,
|
|
115
|
+
kind: 'fs',
|
|
116
|
+
mode: 'recreate',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'system-logs',
|
|
120
|
+
label: 'System logs',
|
|
121
|
+
detail: '/Library/Logs',
|
|
122
|
+
category: 'logs',
|
|
123
|
+
path: '/Library/Logs',
|
|
124
|
+
requiresAdmin: true,
|
|
125
|
+
kind: 'fs',
|
|
126
|
+
mode: 'recreate',
|
|
127
|
+
},
|
|
128
|
+
].map((item) => Object.freeze(item)),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** @type {Map<string, (typeof ITEMS)[number]>} */
|
|
132
|
+
const ITEM_BY_ID = new Map(ITEMS.map((item) => [item.id, item]));
|
|
133
|
+
|
|
134
|
+
/* -------------------------------------------------------------------------- */
|
|
135
|
+
/* Small utilities */
|
|
136
|
+
/* -------------------------------------------------------------------------- */
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Shorten an error message to one JSON-friendly line.
|
|
140
|
+
* @param {unknown} value
|
|
141
|
+
* @param {number} [max]
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
function shortErr(value, max = 220) {
|
|
145
|
+
const text = String(value ?? 'unknown error')
|
|
146
|
+
.replace(/\s+/g, ' ')
|
|
147
|
+
.trim();
|
|
148
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalise a thrown filesystem error into a `code: message` string.
|
|
153
|
+
* @param {unknown} error
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function errorMessage(error) {
|
|
157
|
+
if (error && typeof error === 'object') {
|
|
158
|
+
const candidate = /** @type {{code?: unknown, message?: unknown}} */ (error);
|
|
159
|
+
const code = typeof candidate.code === 'string' ? candidate.code : null;
|
|
160
|
+
const message = 'message' in candidate ? String(candidate.message) : String(candidate);
|
|
161
|
+
return shortErr(code && !message.startsWith(code) ? `${code}: ${message}` : message);
|
|
162
|
+
}
|
|
163
|
+
return shortErr(String(error ?? 'unknown error'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when `candidate` is `root` or lives beneath it (both already resolved).
|
|
168
|
+
* @param {string} candidate
|
|
169
|
+
* @param {string} root
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function isInside(candidate, root) {
|
|
173
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every fixed absolute path the deletion path may ever touch.
|
|
178
|
+
* @returns {Set<string>}
|
|
179
|
+
*/
|
|
180
|
+
function allowlistPaths() {
|
|
181
|
+
/** @type {Set<string>} */
|
|
182
|
+
const allowed = new Set();
|
|
183
|
+
for (const item of ITEMS) {
|
|
184
|
+
if (typeof item.path === 'string' && path.isAbsolute(item.path)) {
|
|
185
|
+
allowed.add(path.resolve(item.path));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return allowed;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* True when `candidate` is exactly one of the allowlisted cleanup roots.
|
|
193
|
+
* @param {unknown} candidate
|
|
194
|
+
* @param {Set<string>} [allowlist]
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
function isAllowlistedPath(candidate, allowlist = allowlistPaths()) {
|
|
198
|
+
if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
|
|
199
|
+
return allowlist.has(path.resolve(candidate));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Refuse anything that is not exactly one of the allowlisted cleanup roots.
|
|
204
|
+
* The returned value is the resolved absolute path and is the only value the
|
|
205
|
+
* `fs.rm` path is ever given.
|
|
206
|
+
* @param {unknown} target
|
|
207
|
+
* @param {Set<string>} [allowlist]
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
function assertInsideAllowlist(target, allowlist = allowlistPaths()) {
|
|
211
|
+
if (!isAllowlistedPath(target, allowlist)) {
|
|
212
|
+
const error = new Error(`Refusing to delete a path outside the cleanup allowlist: ${String(target)}`);
|
|
213
|
+
// @ts-expect-error - annotate the thrown Error for callers.
|
|
214
|
+
error.code = 'PATH_NOT_ALLOWED';
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
return path.resolve(/** @type {string} */ (target));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* -------------------------------------------------------------------------- */
|
|
221
|
+
/* Bounded size walk */
|
|
222
|
+
/* -------------------------------------------------------------------------- */
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @typedef {object} WalkResult
|
|
226
|
+
* @property {number} bytes bytes of regular files
|
|
227
|
+
* @property {number} files number of regular files
|
|
228
|
+
* @property {number} entries directory entries visited
|
|
229
|
+
* @property {string|null} truncated reason when the caps stopped the walk
|
|
230
|
+
* @property {string[]} errors first few unreadable directories
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Recursive size walk. Uses `lstat` for every entry (never follows symlinks),
|
|
235
|
+
* counts only regular files, and stops at a directory-entry cap or wall-clock
|
|
236
|
+
* deadline. Unreadable directories are recorded and skipped, never thrown.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} root
|
|
239
|
+
* @param {{maxEntries?: number, timeBudgetMs?: number}} [options]
|
|
240
|
+
* @returns {Promise<WalkResult>}
|
|
241
|
+
*/
|
|
242
|
+
async function walkRoot(root, options = {}) {
|
|
243
|
+
const maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
244
|
+
const timeBudgetMs = options.timeBudgetMs ?? TIME_BUDGET_MS;
|
|
245
|
+
const deadline = Date.now() + timeBudgetMs;
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
const errors = [];
|
|
248
|
+
let bytes = 0;
|
|
249
|
+
let files = 0;
|
|
250
|
+
let entries = 0;
|
|
251
|
+
/** @type {string|null} */
|
|
252
|
+
let truncated = null;
|
|
253
|
+
/** @type {string[]} */
|
|
254
|
+
let level = [root];
|
|
255
|
+
|
|
256
|
+
outer: while (level.length > 0) {
|
|
257
|
+
if (Date.now() > deadline) {
|
|
258
|
+
truncated = `timed out after ${timeBudgetMs} ms`;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
/** @type {string[]} */
|
|
262
|
+
const next = [];
|
|
263
|
+
for (const directory of level) {
|
|
264
|
+
let names;
|
|
265
|
+
try {
|
|
266
|
+
names = await fsp.readdir(directory);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (errors.length < 3) errors.push(`${directory}: ${errorMessage(error)}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const name of names) {
|
|
272
|
+
entries += 1;
|
|
273
|
+
if (entries > maxEntries) {
|
|
274
|
+
truncated = `truncated at ${maxEntries} entries`;
|
|
275
|
+
break outer;
|
|
276
|
+
}
|
|
277
|
+
if ((entries & 255) === 0 && Date.now() > deadline) {
|
|
278
|
+
truncated = `timed out after ${timeBudgetMs} ms`;
|
|
279
|
+
break outer;
|
|
280
|
+
}
|
|
281
|
+
const full = path.join(directory, name);
|
|
282
|
+
let stat;
|
|
283
|
+
try {
|
|
284
|
+
stat = await fsp.lstat(full);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (errors.length < 3) errors.push(`${full}: ${errorMessage(error)}`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (stat.isSymbolicLink()) continue;
|
|
290
|
+
if (stat.isDirectory()) {
|
|
291
|
+
next.push(full);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (stat.isFile()) {
|
|
295
|
+
bytes += stat.size;
|
|
296
|
+
files += 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
level = next;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { bytes, files, entries, truncated, errors };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* -------------------------------------------------------------------------- */
|
|
307
|
+
/* Deletion */
|
|
308
|
+
/* -------------------------------------------------------------------------- */
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Remove one allowlisted root. Cache/developer/log roots are recreated empty
|
|
312
|
+
* so the owning app finds its directory again; Trash-like roots are handled by
|
|
313
|
+
* {@link emptyDirectory} instead.
|
|
314
|
+
* @param {string} target
|
|
315
|
+
* @param {{recreate?: boolean}} [options]
|
|
316
|
+
* @param {Set<string>} [allowlist]
|
|
317
|
+
* @returns {Promise<{ok: boolean, bytes: number, files: number, message: string}>}
|
|
318
|
+
*/
|
|
319
|
+
async function removeTree(target, options = {}, allowlist = allowlistPaths()) {
|
|
320
|
+
const safe = assertInsideAllowlist(target, allowlist);
|
|
321
|
+
let stat;
|
|
322
|
+
try {
|
|
323
|
+
stat = await fsp.lstat(safe);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
326
|
+
if (options.recreate === true) {
|
|
327
|
+
await fsp.mkdir(safe, { recursive: true });
|
|
328
|
+
return { ok: true, bytes: 0, files: 0, message: 'Recreated the empty cleanup directory.' };
|
|
329
|
+
}
|
|
330
|
+
return { ok: true, bytes: 0, files: 0, message: 'Already empty.' };
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
if (stat.isSymbolicLink()) {
|
|
335
|
+
return { ok: false, bytes: 0, files: 0, message: 'Refusing to follow a symbolic link.' };
|
|
336
|
+
}
|
|
337
|
+
let bytes = 0;
|
|
338
|
+
let files = 0;
|
|
339
|
+
if (stat.isDirectory()) {
|
|
340
|
+
const walk = await walkRoot(safe);
|
|
341
|
+
bytes = walk.bytes;
|
|
342
|
+
files = walk.files;
|
|
343
|
+
await fsp.rm(safe, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 });
|
|
344
|
+
} else {
|
|
345
|
+
bytes = stat.isFile() ? stat.size : 0;
|
|
346
|
+
files = stat.isFile() ? 1 : 0;
|
|
347
|
+
await fsp.rm(safe, { force: true, maxRetries: 2, retryDelay: 150 });
|
|
348
|
+
}
|
|
349
|
+
if (options.recreate === true) await fsp.mkdir(safe, { recursive: true });
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
bytes,
|
|
353
|
+
files,
|
|
354
|
+
message: files > 0 ? `Removed ${files} file(s), ${bytes} byte(s).` : 'Removed the item.',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Empty one allowlisted directory but keep the directory itself (Trash).
|
|
360
|
+
* @param {string} target
|
|
361
|
+
* @param {Set<string>} [allowlist]
|
|
362
|
+
* @returns {Promise<{ok: boolean, bytes: number, files: number, message: string}>}
|
|
363
|
+
*/
|
|
364
|
+
async function emptyDirectory(target, allowlist = allowlistPaths()) {
|
|
365
|
+
const safe = assertInsideAllowlist(target, allowlist);
|
|
366
|
+
let names;
|
|
367
|
+
try {
|
|
368
|
+
names = await fsp.readdir(safe);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
371
|
+
return { ok: true, bytes: 0, files: 0, message: 'Already empty.' };
|
|
372
|
+
}
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
let bytes = 0;
|
|
376
|
+
let files = 0;
|
|
377
|
+
/** @type {string[]} */
|
|
378
|
+
const failures = [];
|
|
379
|
+
for (const name of names) {
|
|
380
|
+
const child = path.join(safe, name);
|
|
381
|
+
if (!isInside(child, safe)) continue;
|
|
382
|
+
try {
|
|
383
|
+
const stat = await fsp.lstat(child);
|
|
384
|
+
if (stat.isSymbolicLink()) {
|
|
385
|
+
// Remove the link itself, never its target.
|
|
386
|
+
await fsp.rm(child, { force: true });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (stat.isDirectory()) {
|
|
390
|
+
const walk = await walkRoot(child);
|
|
391
|
+
bytes += walk.bytes;
|
|
392
|
+
files += walk.files;
|
|
393
|
+
await fsp.rm(child, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (stat.isFile()) {
|
|
397
|
+
bytes += stat.size;
|
|
398
|
+
files += 1;
|
|
399
|
+
}
|
|
400
|
+
await fsp.rm(child, { force: true });
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (failures.length < 3) failures.push(`${name}: ${errorMessage(error)}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
ok: failures.length === 0,
|
|
407
|
+
bytes,
|
|
408
|
+
files,
|
|
409
|
+
message:
|
|
410
|
+
failures.length === 0
|
|
411
|
+
? `Emptied ${files} file(s), ${bytes} byte(s).`
|
|
412
|
+
: `Emptied the item with ${failures.length} failure(s): ${failures.join('; ')}`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Delete one resolved allowlist entry.
|
|
418
|
+
* @param {(typeof ITEMS)[number]} item
|
|
419
|
+
* @returns {Promise<{ok: boolean, bytes: number, files: number, message: string}>}
|
|
420
|
+
*/
|
|
421
|
+
async function deleteItem(item) {
|
|
422
|
+
if (item.kind !== 'fs') {
|
|
423
|
+
return { ok: false, bytes: 0, files: 0, message: `Unsupported cleanup item kind: ${String(item.kind)}` };
|
|
424
|
+
}
|
|
425
|
+
if (typeof item.path !== 'string' || !path.isAbsolute(item.path)) {
|
|
426
|
+
return { ok: false, bytes: 0, files: 0, message: 'Cleanup item path is not configured on this host.' };
|
|
427
|
+
}
|
|
428
|
+
if (item.mode === 'empty') return emptyDirectory(item.path);
|
|
429
|
+
return removeTree(item.path, { recreate: item.mode === 'recreate' });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Normalise and validate a deletion request. Only ids from {@link cleanupItems}
|
|
434
|
+
* are accepted; caller paths, duplicate ids and oversized batches are refused.
|
|
435
|
+
*
|
|
436
|
+
* @param {unknown} ids
|
|
437
|
+
* @returns {{ok: true, items: Array<(typeof ITEMS)[number]>} | {ok: false, message: string}}
|
|
438
|
+
*/
|
|
439
|
+
function normalizeDeleteIds(ids) {
|
|
440
|
+
/** @type {unknown[]} */
|
|
441
|
+
let values;
|
|
442
|
+
if (Array.isArray(ids)) {
|
|
443
|
+
values = ids;
|
|
444
|
+
} else if (typeof ids === 'string') {
|
|
445
|
+
values = ids.split(',');
|
|
446
|
+
} else {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
message: 'Parameter "ids" must be an array of cleanup item ids (or a comma separated string).',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** @type {string[]} */
|
|
453
|
+
const cleaned = [];
|
|
454
|
+
for (const value of values) {
|
|
455
|
+
if (typeof value !== 'string') return { ok: false, message: 'Cleanup item ids must be strings.' };
|
|
456
|
+
const id = value.trim();
|
|
457
|
+
if (id !== '') cleaned.push(id);
|
|
458
|
+
}
|
|
459
|
+
if (cleaned.length < 1 || cleaned.length > MAX_DELETE_IDS) {
|
|
460
|
+
return { ok: false, message: `Parameter "ids" must contain 1 to ${MAX_DELETE_IDS} cleanup item ids.` };
|
|
461
|
+
}
|
|
462
|
+
/** @type {Set<string>} */
|
|
463
|
+
const seen = new Set();
|
|
464
|
+
/** @type {Array<(typeof ITEMS)[number]>} */
|
|
465
|
+
const items = [];
|
|
466
|
+
for (const id of cleaned) {
|
|
467
|
+
if (seen.has(id)) return { ok: false, message: `Duplicate cleanup item id: ${id}` };
|
|
468
|
+
seen.add(id);
|
|
469
|
+
const item = ITEM_BY_ID.get(id);
|
|
470
|
+
if (!item) return { ok: false, message: `Unknown cleanup item id: ${id}` };
|
|
471
|
+
items.push(item);
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, items };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Delete the contents of the given allowlisted cleanup items.
|
|
478
|
+
*
|
|
479
|
+
* Never accepts a path: each id maps to its fixed absolute allowlist entry.
|
|
480
|
+
* Per-item failures (admin-only roots, locked files) are reported in
|
|
481
|
+
* `data.items[].message`; the function does not throw.
|
|
482
|
+
*
|
|
483
|
+
* @param {unknown} ids cleanup item ids (array or comma separated string)
|
|
484
|
+
* @returns {Promise<{ok: boolean, message: string, code?: string,
|
|
485
|
+
* data: {deletedBytes: number, items: Array<{id: string, ok: boolean, bytes: number, message: string}>}}>}
|
|
486
|
+
*/
|
|
487
|
+
export async function deleteCleanup(ids) {
|
|
488
|
+
const requested = normalizeDeleteIds(ids);
|
|
489
|
+
if (requested.ok !== true) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
message: requested.message,
|
|
493
|
+
code: 'BAD_PARAMS',
|
|
494
|
+
data: { deletedBytes: 0, items: [] },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
let deletedBytes = 0;
|
|
498
|
+
let succeeded = 0;
|
|
499
|
+
/** @type {Array<{id: string, ok: boolean, bytes: number, message: string}>} */
|
|
500
|
+
const results = [];
|
|
501
|
+
for (const item of requested.items) {
|
|
502
|
+
try {
|
|
503
|
+
const outcome = await deleteItem(item);
|
|
504
|
+
if (outcome.ok) {
|
|
505
|
+
succeeded += 1;
|
|
506
|
+
deletedBytes += outcome.bytes;
|
|
507
|
+
}
|
|
508
|
+
results.push({ id: item.id, ok: outcome.ok, bytes: outcome.bytes, message: outcome.message });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
results.push({ id: item.id, ok: false, bytes: 0, message: errorMessage(error) });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const ok = succeeded === results.length;
|
|
514
|
+
const message = ok
|
|
515
|
+
? results.length === 1
|
|
516
|
+
? results[0].message
|
|
517
|
+
: `Cleaned ${results.length} item(s), ${deletedBytes} byte(s) total.`
|
|
518
|
+
: `Cleaned ${succeeded} of ${results.length} item(s); ${results.length - succeeded} failed.`;
|
|
519
|
+
return { ok, message, data: { deletedBytes, items: results } };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/* -------------------------------------------------------------------------- */
|
|
523
|
+
/* Scan */
|
|
524
|
+
/* -------------------------------------------------------------------------- */
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Scan one allowlist entry. A missing path is a normal "empty" result; any
|
|
528
|
+
* other failure becomes a string in `error` and the scan continues.
|
|
529
|
+
* @param {(typeof ITEMS)[number]} item
|
|
530
|
+
* @returns {Promise<object>}
|
|
531
|
+
*/
|
|
532
|
+
async function scanItem(item) {
|
|
533
|
+
/** @type {Record<string, any>} */
|
|
534
|
+
const base = {
|
|
535
|
+
id: item.id,
|
|
536
|
+
label: item.label,
|
|
537
|
+
detail: item.detail,
|
|
538
|
+
category: item.category,
|
|
539
|
+
path: item.path,
|
|
540
|
+
bytes: 0,
|
|
541
|
+
files: 0,
|
|
542
|
+
requiresAdmin: item.requiresAdmin,
|
|
543
|
+
exists: false,
|
|
544
|
+
error: null,
|
|
545
|
+
};
|
|
546
|
+
if (item.kind !== 'fs' || typeof item.path !== 'string' || !path.isAbsolute(item.path)) {
|
|
547
|
+
return { ...base, error: 'Cleanup item path is not configured on this host.' };
|
|
548
|
+
}
|
|
549
|
+
let stat;
|
|
550
|
+
try {
|
|
551
|
+
stat = await fsp.lstat(item.path);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') return base;
|
|
554
|
+
return { ...base, error: errorMessage(error) };
|
|
555
|
+
}
|
|
556
|
+
if (stat.isSymbolicLink()) return { ...base, exists: true, error: 'Root path is a symbolic link; skipped.' };
|
|
557
|
+
if (!stat.isDirectory()) {
|
|
558
|
+
return {
|
|
559
|
+
...base,
|
|
560
|
+
exists: true,
|
|
561
|
+
bytes: stat.isFile() ? stat.size : 0,
|
|
562
|
+
files: stat.isFile() ? 1 : 0,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const walk = await walkRoot(item.path);
|
|
566
|
+
const error = walk.truncated ?? walk.errors[0] ?? null;
|
|
567
|
+
return {
|
|
568
|
+
...base,
|
|
569
|
+
exists: true,
|
|
570
|
+
bytes: walk.bytes,
|
|
571
|
+
files: walk.files,
|
|
572
|
+
error: error === null ? null : shortErr(error),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Build one complete scan document.
|
|
578
|
+
* @returns {Promise<object>}
|
|
579
|
+
*/
|
|
580
|
+
async function buildScan() {
|
|
581
|
+
const startedAt = Date.now();
|
|
582
|
+
/** @type {string[]} */
|
|
583
|
+
const errors = [];
|
|
584
|
+
const items = await Promise.all(
|
|
585
|
+
ITEMS.map(async (item) => {
|
|
586
|
+
try {
|
|
587
|
+
const scanned = await scanItem(item);
|
|
588
|
+
if (scanned.error) errors.push(`${item.id}: ${scanned.error}`);
|
|
589
|
+
return scanned;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
const message = shortErr(errorMessage(error));
|
|
592
|
+
errors.push(`${item.id}: ${message}`);
|
|
593
|
+
return {
|
|
594
|
+
id: item.id,
|
|
595
|
+
label: item.label,
|
|
596
|
+
detail: item.detail,
|
|
597
|
+
category: item.category,
|
|
598
|
+
path: item.path,
|
|
599
|
+
bytes: 0,
|
|
600
|
+
files: 0,
|
|
601
|
+
requiresAdmin: item.requiresAdmin,
|
|
602
|
+
exists: false,
|
|
603
|
+
error: message,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
let totalBytes = 0;
|
|
609
|
+
let totalFiles = 0;
|
|
610
|
+
for (const item of items) {
|
|
611
|
+
totalBytes += Number.isFinite(item.bytes) ? item.bytes : 0;
|
|
612
|
+
totalFiles += Number.isFinite(item.files) ? item.files : 0;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
platform: PLATFORM,
|
|
616
|
+
generatedAt: Date.now(),
|
|
617
|
+
durationMs: Date.now() - startedAt,
|
|
618
|
+
items,
|
|
619
|
+
totalBytes,
|
|
620
|
+
totalFiles,
|
|
621
|
+
errors: errors.slice(0, MAX_ERRORS),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @type {{value: object, at: number}|null} */
|
|
626
|
+
let scanCache = null;
|
|
627
|
+
/** @type {Promise<object>|null} */
|
|
628
|
+
let scanPending = null;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Scan every allowlisted cleanup location. The result is cached for 60 s; pass
|
|
632
|
+
* `{fresh: true}` to force a new scan.
|
|
633
|
+
*
|
|
634
|
+
* @param {{fresh?: boolean}} [options]
|
|
635
|
+
* @returns {Promise<{platform: string, generatedAt: number, durationMs: number,
|
|
636
|
+
* items: Array<{id: string, label: string, detail: string,
|
|
637
|
+
* category: 'cache'|'logs'|'trash'|'temp'|'developer'|'updates',
|
|
638
|
+
* path: string, bytes: number, files: number, requiresAdmin: boolean,
|
|
639
|
+
* exists: boolean, error: string|null}>, totalBytes: number,
|
|
640
|
+
* totalFiles: number, errors: string[]}>}
|
|
641
|
+
*/
|
|
642
|
+
export async function scanCleanup(options = {}) {
|
|
643
|
+
const fresh = options?.fresh === true;
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
if (!fresh && scanCache !== null && now - scanCache.at < SCAN_TTL) return scanCache.value;
|
|
646
|
+
if (scanPending !== null) return scanPending;
|
|
647
|
+
const task = (async () => {
|
|
648
|
+
const value = await buildScan();
|
|
649
|
+
scanCache = { value, at: Date.now() };
|
|
650
|
+
return value;
|
|
651
|
+
})().finally(() => {
|
|
652
|
+
if (scanPending === task) scanPending = null;
|
|
653
|
+
});
|
|
654
|
+
scanPending = task;
|
|
655
|
+
return task;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The cleanup allowlist as public metadata (ids, labels, categories and fixed
|
|
660
|
+
* paths). Used by the action validator, tests and client labelling.
|
|
661
|
+
* @returns {Array<{id: string, label: string, category: string,
|
|
662
|
+
* requiresAdmin: boolean, path: string}>}
|
|
663
|
+
*/
|
|
664
|
+
export function cleanupItems() {
|
|
665
|
+
return ITEMS.map((item) => ({
|
|
666
|
+
id: item.id,
|
|
667
|
+
label: item.label,
|
|
668
|
+
category: item.category,
|
|
669
|
+
requiresAdmin: item.requiresAdmin,
|
|
670
|
+
path: item.path,
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Test seam: pure helpers and injected-root sandbox hooks. */
|
|
675
|
+
export const __test = {
|
|
676
|
+
PLATFORM,
|
|
677
|
+
ITEMS,
|
|
678
|
+
SCAN_TTL,
|
|
679
|
+
MAX_ENTRIES,
|
|
680
|
+
TIME_BUDGET_MS,
|
|
681
|
+
MAX_DELETE_IDS,
|
|
682
|
+
normalizeDeleteIds,
|
|
683
|
+
isAllowlistedPath,
|
|
684
|
+
assertInsideAllowlist,
|
|
685
|
+
walkRoot,
|
|
686
|
+
/**
|
|
687
|
+
* Remove an injected root (test sandbox only). The caller must pass the
|
|
688
|
+
* sandbox path as its own allowlist; the public delete path never does this.
|
|
689
|
+
* @param {string} target
|
|
690
|
+
* @param {{recreate?: boolean}} [options]
|
|
691
|
+
* @param {Set<string>} [allowlist]
|
|
692
|
+
*/
|
|
693
|
+
removeTreeAt(target, options, allowlist) {
|
|
694
|
+
return removeTree(target, options, allowlist);
|
|
695
|
+
},
|
|
696
|
+
/**
|
|
697
|
+
* Empty an injected directory (test sandbox only).
|
|
698
|
+
* @param {string} target
|
|
699
|
+
* @param {Set<string>} allowlist
|
|
700
|
+
*/
|
|
701
|
+
emptyDirectoryAt(target, allowlist) {
|
|
702
|
+
return emptyDirectory(target, allowlist);
|
|
703
|
+
},
|
|
704
|
+
clearCache() {
|
|
705
|
+
scanCache = null;
|
|
706
|
+
scanPending = null;
|
|
707
|
+
},
|
|
708
|
+
cacheStatus() {
|
|
709
|
+
return {
|
|
710
|
+
cached: scanCache !== null,
|
|
711
|
+
ageMs: scanCache === null ? null : Date.now() - scanCache.at,
|
|
712
|
+
ttlMs: SCAN_TTL,
|
|
713
|
+
};
|
|
714
|
+
},
|
|
715
|
+
};
|