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