@mison/wecom-cleaner 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -32
- package/docs/IMPLEMENTATION_PLAN.md +7 -7
- package/docs/NON_INTERACTIVE_SPEC.md +115 -0
- package/docs/releases/v1.1.0.md +36 -0
- package/native/bin/darwin-arm64/wecom-cleaner-core +0 -0
- package/native/bin/darwin-x64/wecom-cleaner-core +0 -0
- package/native/manifest.json +4 -4
- package/native/zig/src/main.zig +1 -1
- package/package.json +7 -3
- package/skills/wecom-cleaner-agent/SKILL.md +72 -0
- package/skills/wecom-cleaner-agent/agents/openai.yaml +5 -0
- package/skills/wecom-cleaner-agent/references/commands.md +140 -0
- package/src/cleanup.js +189 -0
- package/src/cli.js +932 -66
- package/src/config.js +246 -0
- package/src/doctor.js +1 -1
- package/src/lock.js +50 -22
- package/src/native-bridge.js +16 -2
- package/src/recycle-maintenance.js +5 -1
- package/src/restore.js +22 -0
- package/src/scanner.js +69 -10
- package/src/skill-cli.js +97 -0
- package/src/skill-installer.js +76 -0
package/src/config.js
CHANGED
|
@@ -4,6 +4,25 @@ import { ensureDir, expandHome, readJson, writeJson } from './utils.js';
|
|
|
4
4
|
import { normalizeRecycleRetention } from './recycle-maintenance.js';
|
|
5
5
|
|
|
6
6
|
const ALLOWED_THEMES = new Set(['auto', 'light', 'dark']);
|
|
7
|
+
const ALLOWED_OUTPUTS = new Set(['json', 'text']);
|
|
8
|
+
const ALLOWED_CONFLICT_STRATEGIES = new Set(['skip', 'overwrite', 'rename']);
|
|
9
|
+
const ALLOWED_EXTERNAL_ROOT_SOURCES = new Set(['preset', 'configured', 'auto', 'all']);
|
|
10
|
+
const ALLOWED_GOVERNANCE_TIERS = new Set(['safe', 'caution', 'protected']);
|
|
11
|
+
const ACTION_FLAG_MAP = new Map([
|
|
12
|
+
['--cleanup-monthly', 'cleanup_monthly'],
|
|
13
|
+
['--analysis-only', 'analysis_only'],
|
|
14
|
+
['--space-governance', 'space_governance'],
|
|
15
|
+
['--recycle-maintain', 'recycle_maintain'],
|
|
16
|
+
['--doctor', 'doctor'],
|
|
17
|
+
]);
|
|
18
|
+
const MODE_TO_ACTION_MAP = new Map([
|
|
19
|
+
['cleanup_monthly', 'cleanup_monthly'],
|
|
20
|
+
['analysis_only', 'analysis_only'],
|
|
21
|
+
['space_governance', 'space_governance'],
|
|
22
|
+
['recycle_maintain', 'recycle_maintain'],
|
|
23
|
+
['restore', 'restore'],
|
|
24
|
+
['doctor', 'doctor'],
|
|
25
|
+
]);
|
|
7
26
|
|
|
8
27
|
export class CliArgError extends Error {
|
|
9
28
|
constructor(message) {
|
|
@@ -23,6 +42,42 @@ function normalizeTheme(theme) {
|
|
|
23
42
|
return normalized;
|
|
24
43
|
}
|
|
25
44
|
|
|
45
|
+
function normalizeOutput(output) {
|
|
46
|
+
if (typeof output !== 'string') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const normalized = output.trim().toLowerCase();
|
|
50
|
+
if (!ALLOWED_OUTPUTS.has(normalized)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseCsvList(rawValue) {
|
|
57
|
+
return String(rawValue || '')
|
|
58
|
+
.split(/[,\n;]/)
|
|
59
|
+
.map((item) => item.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parsePositiveInteger(flag, rawValue) {
|
|
64
|
+
const num = Number.parseInt(String(rawValue || ''), 10);
|
|
65
|
+
if (!Number.isFinite(num) || num < 1) {
|
|
66
|
+
throw new CliArgError(`参数 ${flag} 的值必须是 >= 1 的整数: ${rawValue}`);
|
|
67
|
+
}
|
|
68
|
+
return num;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseEnumValue(flag, rawValue, allowedSet) {
|
|
72
|
+
const normalized = String(rawValue || '')
|
|
73
|
+
.trim()
|
|
74
|
+
.toLowerCase();
|
|
75
|
+
if (!allowedSet.has(normalized)) {
|
|
76
|
+
throw new CliArgError(`参数 ${flag} 的值无效: ${rawValue}`);
|
|
77
|
+
}
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
export function defaultConfig() {
|
|
27
82
|
const stateRoot = DEFAULT_STATE_ROOT;
|
|
28
83
|
const recycleRetention = normalizeRecycleRetention({
|
|
@@ -91,9 +146,35 @@ export function parseCliArgs(argv) {
|
|
|
91
146
|
dryRunDefault: null,
|
|
92
147
|
mode: null,
|
|
93
148
|
theme: null,
|
|
149
|
+
output: null,
|
|
150
|
+
dryRun: null,
|
|
151
|
+
yes: false,
|
|
152
|
+
saveConfig: false,
|
|
94
153
|
jsonOutput: false,
|
|
95
154
|
force: false,
|
|
155
|
+
interactive: false,
|
|
156
|
+
action: null,
|
|
157
|
+
actionFromMode: false,
|
|
158
|
+
actionFlagCount: 0,
|
|
159
|
+
restoreBatchId: null,
|
|
160
|
+
accounts: null,
|
|
161
|
+
months: null,
|
|
162
|
+
cutoffMonth: null,
|
|
163
|
+
categories: null,
|
|
164
|
+
includeNonMonthDirs: null,
|
|
165
|
+
externalRoots: null,
|
|
166
|
+
externalRootsSource: null,
|
|
167
|
+
targets: null,
|
|
168
|
+
tiers: null,
|
|
169
|
+
suggestedOnly: null,
|
|
170
|
+
allowRecentActive: null,
|
|
171
|
+
conflict: null,
|
|
172
|
+
retentionEnabled: null,
|
|
173
|
+
retentionMaxAgeDays: null,
|
|
174
|
+
retentionMinKeepBatches: null,
|
|
175
|
+
retentionSizeThresholdGB: null,
|
|
96
176
|
};
|
|
177
|
+
const actionValues = [];
|
|
97
178
|
|
|
98
179
|
const takeValue = (flag, index) => {
|
|
99
180
|
const value = argv[index + 1];
|
|
@@ -116,6 +197,12 @@ export function parseCliArgs(argv) {
|
|
|
116
197
|
|
|
117
198
|
for (let i = 0; i < argv.length; i += 1) {
|
|
118
199
|
const token = argv[i];
|
|
200
|
+
if (ACTION_FLAG_MAP.has(token)) {
|
|
201
|
+
parsed.action = ACTION_FLAG_MAP.get(token);
|
|
202
|
+
parsed.actionFlagCount += 1;
|
|
203
|
+
actionValues.push(parsed.action);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
119
206
|
if (token === '--root') {
|
|
120
207
|
parsed.rootDir = takeValue(token, i);
|
|
121
208
|
i += 1;
|
|
@@ -146,6 +233,14 @@ export function parseCliArgs(argv) {
|
|
|
146
233
|
i += 1;
|
|
147
234
|
continue;
|
|
148
235
|
}
|
|
236
|
+
if (token === '--restore-batch') {
|
|
237
|
+
parsed.restoreBatchId = takeValue(token, i);
|
|
238
|
+
parsed.action = 'restore';
|
|
239
|
+
parsed.actionFlagCount += 1;
|
|
240
|
+
actionValues.push('restore');
|
|
241
|
+
i += 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
149
244
|
if (token === '--theme') {
|
|
150
245
|
const theme = normalizeTheme(takeValue(token, i));
|
|
151
246
|
if (!theme) {
|
|
@@ -155,6 +250,28 @@ export function parseCliArgs(argv) {
|
|
|
155
250
|
i += 1;
|
|
156
251
|
continue;
|
|
157
252
|
}
|
|
253
|
+
if (token === '--output') {
|
|
254
|
+
const output = normalizeOutput(takeValue(token, i));
|
|
255
|
+
if (!output) {
|
|
256
|
+
throw new CliArgError(`参数 --output 的值无效: ${argv[i + 1]}`);
|
|
257
|
+
}
|
|
258
|
+
parsed.output = output;
|
|
259
|
+
i += 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (token === '--dry-run') {
|
|
263
|
+
parsed.dryRun = parseBooleanFlag(token, takeValue(token, i));
|
|
264
|
+
i += 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (token === '--yes') {
|
|
268
|
+
parsed.yes = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (token === '--save-config') {
|
|
272
|
+
parsed.saveConfig = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
158
275
|
if (token === '--json') {
|
|
159
276
|
parsed.jsonOutput = true;
|
|
160
277
|
continue;
|
|
@@ -163,11 +280,140 @@ export function parseCliArgs(argv) {
|
|
|
163
280
|
parsed.force = true;
|
|
164
281
|
continue;
|
|
165
282
|
}
|
|
283
|
+
if (token === '--interactive') {
|
|
284
|
+
parsed.interactive = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (token === '--accounts') {
|
|
288
|
+
parsed.accounts = parseCsvList(takeValue(token, i));
|
|
289
|
+
i += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (token === '--months') {
|
|
293
|
+
parsed.months = parseCsvList(takeValue(token, i));
|
|
294
|
+
i += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (token === '--cutoff-month') {
|
|
298
|
+
parsed.cutoffMonth = takeValue(token, i);
|
|
299
|
+
i += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (token === '--categories') {
|
|
303
|
+
parsed.categories = parseCsvList(takeValue(token, i));
|
|
304
|
+
i += 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (token === '--include-non-month-dirs') {
|
|
308
|
+
parsed.includeNonMonthDirs = parseBooleanFlag(token, takeValue(token, i));
|
|
309
|
+
i += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (token === '--external-roots') {
|
|
313
|
+
parsed.externalRoots = parseCsvList(takeValue(token, i));
|
|
314
|
+
i += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (token === '--external-roots-source') {
|
|
318
|
+
const sourceValues = parseCsvList(takeValue(token, i)).map((item) => item.toLowerCase());
|
|
319
|
+
if (sourceValues.length === 0) {
|
|
320
|
+
throw new CliArgError('参数 --external-roots-source 至少需要一个值');
|
|
321
|
+
}
|
|
322
|
+
for (const source of sourceValues) {
|
|
323
|
+
if (!ALLOWED_EXTERNAL_ROOT_SOURCES.has(source)) {
|
|
324
|
+
throw new CliArgError(`参数 --external-roots-source 的值无效: ${source}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
parsed.externalRootsSource = sourceValues;
|
|
328
|
+
i += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (token === '--targets') {
|
|
332
|
+
parsed.targets = parseCsvList(takeValue(token, i));
|
|
333
|
+
i += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (token === '--tiers') {
|
|
337
|
+
const values = parseCsvList(takeValue(token, i)).map((item) => item.toLowerCase());
|
|
338
|
+
if (values.length === 0) {
|
|
339
|
+
throw new CliArgError('参数 --tiers 至少需要一个值');
|
|
340
|
+
}
|
|
341
|
+
for (const tier of values) {
|
|
342
|
+
if (!ALLOWED_GOVERNANCE_TIERS.has(tier)) {
|
|
343
|
+
throw new CliArgError(`参数 --tiers 的值无效: ${tier}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
parsed.tiers = values;
|
|
347
|
+
i += 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (token === '--suggested-only') {
|
|
351
|
+
parsed.suggestedOnly = parseBooleanFlag(token, takeValue(token, i));
|
|
352
|
+
i += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (token === '--allow-recent-active') {
|
|
356
|
+
parsed.allowRecentActive = parseBooleanFlag(token, takeValue(token, i));
|
|
357
|
+
i += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (token === '--conflict') {
|
|
361
|
+
parsed.conflict = parseEnumValue(token, takeValue(token, i), ALLOWED_CONFLICT_STRATEGIES);
|
|
362
|
+
i += 1;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (token === '--retention-enabled') {
|
|
366
|
+
parsed.retentionEnabled = parseBooleanFlag(token, takeValue(token, i));
|
|
367
|
+
i += 1;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (token === '--retention-max-age-days') {
|
|
371
|
+
parsed.retentionMaxAgeDays = parsePositiveInteger(token, takeValue(token, i));
|
|
372
|
+
i += 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (token === '--retention-min-keep-batches') {
|
|
376
|
+
parsed.retentionMinKeepBatches = parsePositiveInteger(token, takeValue(token, i));
|
|
377
|
+
i += 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (token === '--retention-size-threshold-gb') {
|
|
381
|
+
parsed.retentionSizeThresholdGB = parsePositiveInteger(token, takeValue(token, i));
|
|
382
|
+
i += 1;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
166
385
|
if (token.startsWith('-')) {
|
|
167
386
|
throw new CliArgError(`不支持的参数: ${token}`);
|
|
168
387
|
}
|
|
169
388
|
}
|
|
170
389
|
|
|
390
|
+
if (parsed.months && parsed.cutoffMonth) {
|
|
391
|
+
throw new CliArgError('参数 --months 与 --cutoff-month 不能同时使用');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (parsed.actionFlagCount > 1 || new Set(actionValues).size > 1) {
|
|
395
|
+
throw new CliArgError('动作参数冲突:一次只能指定一个动作(如 --cleanup-monthly)');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (parsed.mode && parsed.action) {
|
|
399
|
+
throw new CliArgError('参数 --mode 不能与动作参数同时使用');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (parsed.mode && !parsed.action) {
|
|
403
|
+
const mapped = MODE_TO_ACTION_MAP.get(String(parsed.mode || '').trim());
|
|
404
|
+
if (mapped) {
|
|
405
|
+
parsed.action = mapped;
|
|
406
|
+
parsed.actionFromMode = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (parsed.jsonOutput) {
|
|
411
|
+
if (parsed.output && parsed.output !== 'json') {
|
|
412
|
+
throw new CliArgError('参数 --json 与 --output text 不能同时使用');
|
|
413
|
+
}
|
|
414
|
+
parsed.output = 'json';
|
|
415
|
+
}
|
|
416
|
+
|
|
171
417
|
return parsed;
|
|
172
418
|
}
|
|
173
419
|
|
package/src/doctor.js
CHANGED
|
@@ -331,7 +331,7 @@ export async function runDoctor({ config, aliases, projectRoot, appVersion }) {
|
|
|
331
331
|
'回收区健康',
|
|
332
332
|
recycleOverThreshold ? STATUS_WARN : STATUS_PASS,
|
|
333
333
|
`批次 ${recycleStats.totalBatches} 个,容量 ${recycleStats.totalBytes} bytes,阈值 ${thresholdBytes} bytes`,
|
|
334
|
-
recycleOverThreshold ? '建议执行回收区治理(--
|
|
334
|
+
recycleOverThreshold ? '建议执行回收区治理(--recycle-maintain)。' : ''
|
|
335
335
|
)
|
|
336
336
|
);
|
|
337
337
|
|
package/src/lock.js
CHANGED
|
@@ -63,11 +63,12 @@ export async function breakLock(lockPath) {
|
|
|
63
63
|
await fs.rm(lockPath, { force: true });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export async function acquireLock(stateRoot, mode) {
|
|
66
|
+
export async function acquireLock(stateRoot, mode, options = {}) {
|
|
67
|
+
const allowStaleBreak = options.allowStaleBreak !== false;
|
|
67
68
|
const lockPath = resolveLockPath(stateRoot);
|
|
68
69
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
69
70
|
|
|
70
|
-
const
|
|
71
|
+
const payloadBase = {
|
|
71
72
|
pid: process.pid,
|
|
72
73
|
mode: String(mode || 'unknown'),
|
|
73
74
|
startedAt: Date.now(),
|
|
@@ -75,28 +76,55 @@ export async function acquireLock(stateRoot, mode) {
|
|
|
75
76
|
version: process.env.npm_package_version || null,
|
|
76
77
|
};
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
let recoveredFromStale = false;
|
|
80
|
+
let staleLockInfo = null;
|
|
81
|
+
|
|
82
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
83
|
+
const payload = recoveredFromStale
|
|
84
|
+
? {
|
|
85
|
+
...payloadBase,
|
|
86
|
+
recoveredFromStale: true,
|
|
87
|
+
recoveredAt: Date.now(),
|
|
88
|
+
staleLockPid: staleLockInfo?.pid || null,
|
|
89
|
+
}
|
|
90
|
+
: payloadBase;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await writeLockFile(lockPath, payload);
|
|
94
|
+
return {
|
|
95
|
+
lockPath,
|
|
96
|
+
lockInfo: payload,
|
|
97
|
+
async release() {
|
|
98
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error && error.code !== 'EEXIST') {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
84
105
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
const lockInfo = await readLockInfo(lockPath);
|
|
107
|
+
const lockPid = Number.parseInt(String(lockInfo?.pid || ''), 10);
|
|
108
|
+
const isStale = !isProcessRunning(lockPid);
|
|
109
|
+
|
|
110
|
+
if (isStale && allowStaleBreak && attempt === 0) {
|
|
111
|
+
staleLockInfo = lockInfo;
|
|
112
|
+
recoveredFromStale = true;
|
|
113
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new LockHeldError('检测到另一个实例正在运行', {
|
|
118
|
+
lockPath,
|
|
119
|
+
lockInfo,
|
|
120
|
+
isStale,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
93
123
|
}
|
|
94
124
|
|
|
95
|
-
|
|
125
|
+
throw new LockHeldError('检测到锁文件冲突,且自动恢复失败', {
|
|
96
126
|
lockPath,
|
|
97
|
-
lockInfo:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
},
|
|
101
|
-
};
|
|
127
|
+
lockInfo: staleLockInfo,
|
|
128
|
+
isStale: Boolean(staleLockInfo),
|
|
129
|
+
});
|
|
102
130
|
}
|
package/src/native-bridge.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
5
5
|
|
|
6
6
|
const NATIVE_CACHE_DIR = 'native-cache';
|
|
7
7
|
const MANIFEST_RELATIVE_PATH = path.join('native', 'manifest.json');
|
|
8
|
-
const
|
|
8
|
+
const DEFAULT_DOWNLOAD_REPO_BASE_URL = 'https://raw.githubusercontent.com/MisonL/wecom-cleaner';
|
|
9
9
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
10
10
|
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
|
|
11
11
|
|
|
@@ -219,6 +219,20 @@ function resolveManifestVersion(manifest) {
|
|
|
219
219
|
return null;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
function resolveReleaseTag(version) {
|
|
223
|
+
const normalized = String(version || '').trim();
|
|
224
|
+
if (!normalized) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return normalized.startsWith('v') ? normalized : `v${normalized}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolveDefaultDownloadBaseUrl(manifest) {
|
|
231
|
+
const releaseTag = resolveReleaseTag(resolveManifestVersion(manifest));
|
|
232
|
+
const ref = releaseTag || 'main';
|
|
233
|
+
return `${DEFAULT_DOWNLOAD_REPO_BASE_URL}/${ref}/native/bin`;
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
function resolveDownloadUrl({ target, manifest, manifestTarget }) {
|
|
223
237
|
const fileName = manifestTarget?.binaryName || target.binaryName;
|
|
224
238
|
const overrideBaseUrl = getDownloadBaseUrlOverride();
|
|
@@ -239,7 +253,7 @@ function resolveDownloadUrl({ target, manifest, manifestTarget }) {
|
|
|
239
253
|
return `${manifestBaseUrl}/${target.targetTag}/${fileName}`;
|
|
240
254
|
}
|
|
241
255
|
|
|
242
|
-
return `${
|
|
256
|
+
return `${resolveDefaultDownloadBaseUrl(manifest)}/${target.targetTag}/${fileName}`;
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
async function verifySha256OrReason(filePath, expectedSha256) {
|
|
@@ -201,7 +201,11 @@ export function selectBatchesForMaintenance(batches, policy, now = Date.now()) {
|
|
|
201
201
|
export async function maintainRecycleBin({ indexPath, recycleRoot, policy, dryRun, onProgress }) {
|
|
202
202
|
const normalizedPolicy = normalizeRecycleRetention(policy);
|
|
203
203
|
const now = Date.now();
|
|
204
|
-
const before = await collectRecycleStats({
|
|
204
|
+
const before = await collectRecycleStats({
|
|
205
|
+
indexPath,
|
|
206
|
+
recycleRoot,
|
|
207
|
+
createIfMissing: !dryRun,
|
|
208
|
+
});
|
|
205
209
|
const selected = selectBatchesForMaintenance(before.batches, normalizedPolicy, now);
|
|
206
210
|
const thresholdBytes = selected.thresholdBytes;
|
|
207
211
|
|
package/src/restore.js
CHANGED
|
@@ -262,6 +262,21 @@ async function validateRestoreEntryPath({ originalPath, recyclePath, scope, vali
|
|
|
262
262
|
return null;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
function buildRestoreAuditMeta(entry = {}) {
|
|
266
|
+
return {
|
|
267
|
+
accountId: entry.accountId || null,
|
|
268
|
+
accountShortId: entry.accountShortId || null,
|
|
269
|
+
userName: entry.userName || null,
|
|
270
|
+
corpName: entry.corpName || null,
|
|
271
|
+
categoryKey: entry.categoryKey || null,
|
|
272
|
+
categoryLabel: entry.categoryLabel || null,
|
|
273
|
+
monthKey: entry.monthKey || null,
|
|
274
|
+
targetKey: entry.targetKey || null,
|
|
275
|
+
tier: entry.tier || null,
|
|
276
|
+
sizeBytes: Number(entry.sizeBytes || 0),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
export async function listRestorableBatches(indexPath, options = {}) {
|
|
266
281
|
const recycleRoot = typeof options.recycleRoot === 'string' ? options.recycleRoot : null;
|
|
267
282
|
const restoredSet = new Set();
|
|
@@ -354,6 +369,7 @@ export async function restoreBatch({
|
|
|
354
369
|
const recyclePath = entry.recyclePath;
|
|
355
370
|
const originalPath = entry.sourcePath;
|
|
356
371
|
const scope = typeof entry.scope === 'string' && entry.scope ? entry.scope : 'cleanup_monthly';
|
|
372
|
+
const auditMeta = buildRestoreAuditMeta(entry);
|
|
357
373
|
const invalidPathReason = await validateRestoreEntryPath({
|
|
358
374
|
originalPath,
|
|
359
375
|
recyclePath,
|
|
@@ -370,6 +386,7 @@ export async function restoreBatch({
|
|
|
370
386
|
batchId: batch.batchId,
|
|
371
387
|
recyclePath,
|
|
372
388
|
sourcePath: originalPath,
|
|
389
|
+
...auditMeta,
|
|
373
390
|
status: 'skipped_invalid_path',
|
|
374
391
|
error_type: ERROR_TYPES.PATH_VALIDATION_FAILED,
|
|
375
392
|
invalid_reason: invalidPathReason,
|
|
@@ -391,6 +408,7 @@ export async function restoreBatch({
|
|
|
391
408
|
batchId: batch.batchId,
|
|
392
409
|
recyclePath,
|
|
393
410
|
sourcePath: originalPath,
|
|
411
|
+
...auditMeta,
|
|
394
412
|
status: 'skipped_missing_recycle',
|
|
395
413
|
error_type: ERROR_TYPES.PATH_NOT_FOUND,
|
|
396
414
|
profile_root: profileRoot,
|
|
@@ -436,6 +454,7 @@ export async function restoreBatch({
|
|
|
436
454
|
batchId: batch.batchId,
|
|
437
455
|
recyclePath,
|
|
438
456
|
sourcePath: originalPath,
|
|
457
|
+
...auditMeta,
|
|
439
458
|
status: 'skipped_conflict',
|
|
440
459
|
error_type: ERROR_TYPES.CONFLICT,
|
|
441
460
|
profile_root: profileRoot,
|
|
@@ -464,6 +483,7 @@ export async function restoreBatch({
|
|
|
464
483
|
recyclePath,
|
|
465
484
|
sourcePath: originalPath,
|
|
466
485
|
restoredPath: targetPath,
|
|
486
|
+
...auditMeta,
|
|
467
487
|
status: 'dry_run',
|
|
468
488
|
dryRun: true,
|
|
469
489
|
conflict_strategy: conflictStrategy,
|
|
@@ -493,6 +513,7 @@ export async function restoreBatch({
|
|
|
493
513
|
recyclePath,
|
|
494
514
|
sourcePath: originalPath,
|
|
495
515
|
restoredPath: targetPath,
|
|
516
|
+
...auditMeta,
|
|
496
517
|
status: 'success',
|
|
497
518
|
dryRun: false,
|
|
498
519
|
profile_root: profileRoot,
|
|
@@ -516,6 +537,7 @@ export async function restoreBatch({
|
|
|
516
537
|
batchId: batch.batchId,
|
|
517
538
|
recyclePath,
|
|
518
539
|
sourcePath: originalPath,
|
|
540
|
+
...auditMeta,
|
|
519
541
|
status: 'failed',
|
|
520
542
|
error_type: classifyErrorType(error instanceof Error ? error.message : String(error)),
|
|
521
543
|
dryRun: false,
|
package/src/scanner.js
CHANGED
|
@@ -31,6 +31,17 @@ const EXTERNAL_SOURCE_AUTO = 'auto';
|
|
|
31
31
|
const EXTERNAL_STORAGE_SCAN_MAX_DEPTH_DEFAULT = 2;
|
|
32
32
|
const EXTERNAL_STORAGE_SCAN_MAX_VISITS_DEFAULT = 400;
|
|
33
33
|
const EXTERNAL_STORAGE_CACHE_TTL_MS_DEFAULT = 15_000;
|
|
34
|
+
const EXTERNAL_STORAGE_KNOWN_CATEGORY_DIRS = new Set(
|
|
35
|
+
CACHE_CATEGORIES.map((item) => {
|
|
36
|
+
const relativePath = String(item?.relativePath || '');
|
|
37
|
+
if (!relativePath.startsWith('Caches/')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const suffix = relativePath.slice('Caches/'.length);
|
|
41
|
+
const [head] = suffix.split(/[\\/]+/).filter(Boolean);
|
|
42
|
+
return head ? head.toLowerCase() : null;
|
|
43
|
+
}).filter(Boolean)
|
|
44
|
+
);
|
|
34
45
|
const EXTERNAL_STORAGE_SCAN_SKIP_NAMES = new Set([
|
|
35
46
|
'.',
|
|
36
47
|
'..',
|
|
@@ -150,6 +161,45 @@ async function isDirectoryPath(targetPath) {
|
|
|
150
161
|
return Boolean(stat?.isDirectory());
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
async function detectExternalStorageMarkers(cacheRoot) {
|
|
165
|
+
const entries = await listDirectoryEntries(cacheRoot);
|
|
166
|
+
const categoryDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
167
|
+
const knownCategoryDirs = categoryDirs.filter((name) =>
|
|
168
|
+
EXTERNAL_STORAGE_KNOWN_CATEGORY_DIRS.has(name.toLowerCase())
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
let monthLikeCategoryCount = 0;
|
|
172
|
+
for (const categoryName of knownCategoryDirs) {
|
|
173
|
+
const childDirs = await listSubDirectories(path.join(cacheRoot, categoryName));
|
|
174
|
+
if (childDirs.some((dirName) => normalizeMonthKey(dirName))) {
|
|
175
|
+
monthLikeCategoryCount += 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
knownCategoryCount: knownCategoryDirs.length,
|
|
181
|
+
monthLikeCategoryCount,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function isLikelyExternalStorageRoot(rootPath, options = {}) {
|
|
186
|
+
const strictMarkers = options.strictMarkers === true;
|
|
187
|
+
const cacheRoot = path.join(rootPath, EXTERNAL_STORAGE_CACHE_RELATIVE);
|
|
188
|
+
if (!(await isDirectoryPath(cacheRoot))) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!strictMarkers) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const markers = await detectExternalStorageMarkers(cacheRoot);
|
|
197
|
+
if (markers.monthLikeCategoryCount >= 1) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return markers.knownCategoryCount >= 2;
|
|
201
|
+
}
|
|
202
|
+
|
|
153
203
|
function normalizeExternalStorageRootCandidate(rawPath) {
|
|
154
204
|
const input = String(rawPath || '').trim();
|
|
155
205
|
if (!input) {
|
|
@@ -169,13 +219,13 @@ function normalizeExternalStorageRootCandidate(rawPath) {
|
|
|
169
219
|
return normalized;
|
|
170
220
|
}
|
|
171
221
|
|
|
172
|
-
async function resolveExternalStorageRoot(rawPath) {
|
|
222
|
+
async function resolveExternalStorageRoot(rawPath, options = {}) {
|
|
173
223
|
const root = normalizeExternalStorageRootCandidate(rawPath);
|
|
174
224
|
if (!root) {
|
|
175
225
|
return null;
|
|
176
226
|
}
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
227
|
+
const likely = await isLikelyExternalStorageRoot(root, options);
|
|
228
|
+
if (!likely) {
|
|
179
229
|
return null;
|
|
180
230
|
}
|
|
181
231
|
return root;
|
|
@@ -227,10 +277,7 @@ function collectBuiltInStorageRootCandidates(options = {}) {
|
|
|
227
277
|
async function collectDefaultExternalSearchBaseRoots() {
|
|
228
278
|
const bases = new Set();
|
|
229
279
|
const home = os.homedir();
|
|
230
|
-
bases.add(home);
|
|
231
280
|
bases.add(path.join(home, 'Documents'));
|
|
232
|
-
bases.add(path.join(home, 'Desktop'));
|
|
233
|
-
bases.add(path.join(home, 'Downloads'));
|
|
234
281
|
|
|
235
282
|
const volumeEntries = await fs.readdir('/Volumes', { withFileTypes: true }).catch(() => []);
|
|
236
283
|
for (const entry of volumeEntries) {
|
|
@@ -623,10 +670,11 @@ export async function detectExternalStorageRoots(options = {}) {
|
|
|
623
670
|
autoDetectedRootCount: 0,
|
|
624
671
|
truncatedRoots: [],
|
|
625
672
|
visitedDirs: 0,
|
|
673
|
+
autoRejectedRootCount: 0,
|
|
626
674
|
};
|
|
627
675
|
|
|
628
676
|
for (const candidate of builtInCandidates) {
|
|
629
|
-
const root = await resolveExternalStorageRoot(candidate);
|
|
677
|
+
const root = await resolveExternalStorageRoot(candidate, { strictMarkers: true });
|
|
630
678
|
if (!root || seen.has(root)) {
|
|
631
679
|
continue;
|
|
632
680
|
}
|
|
@@ -636,7 +684,7 @@ export async function detectExternalStorageRoots(options = {}) {
|
|
|
636
684
|
}
|
|
637
685
|
|
|
638
686
|
for (const candidate of configuredRoots) {
|
|
639
|
-
const root = await resolveExternalStorageRoot(candidate);
|
|
687
|
+
const root = await resolveExternalStorageRoot(candidate, { strictMarkers: false });
|
|
640
688
|
if (!root || seen.has(root)) {
|
|
641
689
|
continue;
|
|
642
690
|
}
|
|
@@ -651,16 +699,26 @@ export async function detectExternalStorageRoots(options = {}) {
|
|
|
651
699
|
? options.searchBaseRoots
|
|
652
700
|
: await collectDefaultExternalSearchBaseRoots();
|
|
653
701
|
const autoScan = await findExternalStorageRootsByStructure(baseRoots, options);
|
|
654
|
-
autoDetectMeta =
|
|
702
|
+
autoDetectMeta = {
|
|
703
|
+
...autoScan.meta,
|
|
704
|
+
autoRejectedRootCount: 0,
|
|
705
|
+
};
|
|
655
706
|
for (const root of autoScan.roots) {
|
|
656
|
-
const normalized = await resolveExternalStorageRoot(root);
|
|
707
|
+
const normalized = await resolveExternalStorageRoot(root, { strictMarkers: true });
|
|
657
708
|
if (!normalized || seen.has(normalized)) {
|
|
709
|
+
if (!normalized) {
|
|
710
|
+
autoDetectMeta.autoRejectedRootCount += 1;
|
|
711
|
+
}
|
|
658
712
|
continue;
|
|
659
713
|
}
|
|
660
714
|
seen.add(normalized);
|
|
661
715
|
resolved.push(normalized);
|
|
662
716
|
sourceByRoot.set(normalized, EXTERNAL_SOURCE_AUTO);
|
|
663
717
|
}
|
|
718
|
+
autoDetectMeta.autoDetectedRootCount = Math.max(
|
|
719
|
+
0,
|
|
720
|
+
autoScan.roots.length - autoDetectMeta.autoRejectedRootCount
|
|
721
|
+
);
|
|
664
722
|
}
|
|
665
723
|
|
|
666
724
|
resolved.sort();
|
|
@@ -687,6 +745,7 @@ export async function detectExternalStorageRoots(options = {}) {
|
|
|
687
745
|
const meta = {
|
|
688
746
|
searchedRootsCount: autoDetectMeta.searchedRootsCount,
|
|
689
747
|
autoDetectedRootCount: autoDetectMeta.autoDetectedRootCount,
|
|
748
|
+
autoRejectedRootCount: autoDetectMeta.autoRejectedRootCount,
|
|
690
749
|
truncatedRoots: autoDetectMeta.truncatedRoots,
|
|
691
750
|
visitedDirs: autoDetectMeta.visitedDirs,
|
|
692
751
|
resolvedRootCount: resolved.length,
|