@mison/wecom-cleaner 1.0.0 → 1.2.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 +199 -35
- package/docs/IMPLEMENTATION_PLAN.md +7 -7
- package/docs/NON_INTERACTIVE_SPEC.md +239 -0
- package/docs/releases/v1.1.0.md +36 -0
- package/docs/releases/v1.2.0.md +33 -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 +5 -4
- package/native/zig/src/main.zig +4 -3
- package/package.json +9 -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 +100 -0
- package/skills/wecom-cleaner-agent/scripts/analysis_report.sh +275 -0
- package/skills/wecom-cleaner-agent/scripts/cleanup_monthly_report.sh +450 -0
- package/skills/wecom-cleaner-agent/scripts/doctor_report.sh +167 -0
- package/skills/wecom-cleaner-agent/scripts/recycle_maintain_report.sh +281 -0
- package/skills/wecom-cleaner-agent/scripts/restore_batch_report.sh +349 -0
- package/skills/wecom-cleaner-agent/scripts/space_governance_report.sh +401 -0
- package/src/cleanup.js +369 -0
- package/src/cli.js +2228 -172
- package/src/config.js +256 -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 +42 -1
- package/src/restore.js +196 -0
- package/src/scanner.js +69 -10
- package/src/skill-cli.js +97 -0
- package/src/skill-installer.js +76 -0
package/src/cleanup.js
CHANGED
|
@@ -36,11 +36,342 @@ function escapePathForName(srcPath) {
|
|
|
36
36
|
return base || 'unknown';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function resolveCleanupTargetRoot(target = {}) {
|
|
40
|
+
const accountPath = String(target.accountPath || '').trim();
|
|
41
|
+
const categoryPath = String(target.categoryPath || '').trim();
|
|
42
|
+
if (accountPath && categoryPath) {
|
|
43
|
+
return path.resolve(accountPath, categoryPath);
|
|
44
|
+
}
|
|
45
|
+
if (accountPath) {
|
|
46
|
+
return path.resolve(accountPath);
|
|
47
|
+
}
|
|
48
|
+
if (target.path) {
|
|
49
|
+
return path.dirname(path.resolve(target.path));
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createBreakdownRow(seed = {}) {
|
|
55
|
+
return {
|
|
56
|
+
...seed,
|
|
57
|
+
totalCount: 0,
|
|
58
|
+
totalBytes: 0,
|
|
59
|
+
successCount: 0,
|
|
60
|
+
successBytes: 0,
|
|
61
|
+
skippedCount: 0,
|
|
62
|
+
skippedBytes: 0,
|
|
63
|
+
failedCount: 0,
|
|
64
|
+
failedBytes: 0,
|
|
65
|
+
dryRunCount: 0,
|
|
66
|
+
dryRunBytes: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyBreakdownStatus(row, statusKey, sizeBytes) {
|
|
71
|
+
const bytes = Number(sizeBytes || 0);
|
|
72
|
+
row.totalCount += 1;
|
|
73
|
+
row.totalBytes += bytes;
|
|
74
|
+
if (statusKey === 'success') {
|
|
75
|
+
row.successCount += 1;
|
|
76
|
+
row.successBytes += bytes;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (statusKey === 'skipped') {
|
|
80
|
+
row.skippedCount += 1;
|
|
81
|
+
row.skippedBytes += bytes;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (statusKey === 'failed') {
|
|
85
|
+
row.failedCount += 1;
|
|
86
|
+
row.failedBytes += bytes;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
row.dryRunCount += 1;
|
|
90
|
+
row.dryRunBytes += bytes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pushTopPathSample(samples, sample, limit = 20) {
|
|
94
|
+
samples.push(sample);
|
|
95
|
+
samples.sort((a, b) => Number(b.sizeBytes || 0) - Number(a.sizeBytes || 0));
|
|
96
|
+
if (samples.length > limit) {
|
|
97
|
+
samples.length = limit;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createCleanupBreakdownTracker(topPathLimit = 20) {
|
|
102
|
+
return {
|
|
103
|
+
byCategory: new Map(),
|
|
104
|
+
byMonth: new Map(),
|
|
105
|
+
byRoot: new Map(),
|
|
106
|
+
status: {
|
|
107
|
+
success: { count: 0, bytes: 0 },
|
|
108
|
+
skipped: { count: 0, bytes: 0 },
|
|
109
|
+
failed: { count: 0, bytes: 0 },
|
|
110
|
+
dryRun: { count: 0, bytes: 0 },
|
|
111
|
+
},
|
|
112
|
+
topPaths: [],
|
|
113
|
+
topPathLimit,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function updateCleanupBreakdown(tracker, target, statusKey, statusLabel) {
|
|
118
|
+
const bytes = Number(target?.sizeBytes || 0);
|
|
119
|
+
|
|
120
|
+
if (!tracker.status[statusKey]) {
|
|
121
|
+
tracker.status[statusKey] = { count: 0, bytes: 0 };
|
|
122
|
+
}
|
|
123
|
+
tracker.status[statusKey].count += 1;
|
|
124
|
+
tracker.status[statusKey].bytes += bytes;
|
|
125
|
+
|
|
126
|
+
const categoryKey = String(target?.categoryKey || 'unknown');
|
|
127
|
+
if (!tracker.byCategory.has(categoryKey)) {
|
|
128
|
+
tracker.byCategory.set(
|
|
129
|
+
categoryKey,
|
|
130
|
+
createBreakdownRow({
|
|
131
|
+
categoryKey,
|
|
132
|
+
categoryLabel: target?.categoryLabel || categoryKey,
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
applyBreakdownStatus(tracker.byCategory.get(categoryKey), statusKey, bytes);
|
|
137
|
+
|
|
138
|
+
const monthKey = String(target?.monthKey || '非月份目录');
|
|
139
|
+
if (!tracker.byMonth.has(monthKey)) {
|
|
140
|
+
tracker.byMonth.set(monthKey, createBreakdownRow({ monthKey }));
|
|
141
|
+
}
|
|
142
|
+
applyBreakdownStatus(tracker.byMonth.get(monthKey), statusKey, bytes);
|
|
143
|
+
|
|
144
|
+
const rootPath = resolveCleanupTargetRoot(target);
|
|
145
|
+
const rootKey = rootPath || '(unknown)';
|
|
146
|
+
if (!tracker.byRoot.has(rootKey)) {
|
|
147
|
+
tracker.byRoot.set(
|
|
148
|
+
rootKey,
|
|
149
|
+
createBreakdownRow({
|
|
150
|
+
rootPath: rootPath || null,
|
|
151
|
+
rootType: target?.isExternalStorage ? 'external' : 'profile',
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
applyBreakdownStatus(tracker.byRoot.get(rootKey), statusKey, bytes);
|
|
156
|
+
|
|
157
|
+
pushTopPathSample(
|
|
158
|
+
tracker.topPaths,
|
|
159
|
+
{
|
|
160
|
+
path: target?.path || null,
|
|
161
|
+
sizeBytes: bytes,
|
|
162
|
+
status: statusLabel,
|
|
163
|
+
categoryKey,
|
|
164
|
+
categoryLabel: target?.categoryLabel || categoryKey,
|
|
165
|
+
monthKey: target?.monthKey || null,
|
|
166
|
+
accountShortId: target?.accountShortId || null,
|
|
167
|
+
isExternalStorage: Boolean(target?.isExternalStorage),
|
|
168
|
+
},
|
|
169
|
+
tracker.topPathLimit
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sortBreakdownRowsByBytes(rows = []) {
|
|
174
|
+
return [...rows].sort((a, b) => {
|
|
175
|
+
const bytesDiff = Number(b.totalBytes || 0) - Number(a.totalBytes || 0);
|
|
176
|
+
if (bytesDiff !== 0) {
|
|
177
|
+
return bytesDiff;
|
|
178
|
+
}
|
|
179
|
+
return Number(b.totalCount || 0) - Number(a.totalCount || 0);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sortMonthBreakdownRows(rows = []) {
|
|
184
|
+
const nonMonthKey = '非月份目录';
|
|
185
|
+
return [...rows].sort((a, b) => {
|
|
186
|
+
const aMonth = String(a.monthKey || nonMonthKey);
|
|
187
|
+
const bMonth = String(b.monthKey || nonMonthKey);
|
|
188
|
+
if (aMonth === nonMonthKey && bMonth !== nonMonthKey) {
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
if (aMonth !== nonMonthKey && bMonth === nonMonthKey) {
|
|
192
|
+
return -1;
|
|
193
|
+
}
|
|
194
|
+
if (aMonth === bMonth) {
|
|
195
|
+
return Number(b.totalBytes || 0) - Number(a.totalBytes || 0);
|
|
196
|
+
}
|
|
197
|
+
return aMonth.localeCompare(bMonth);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function finalizeCleanupBreakdown(tracker) {
|
|
202
|
+
return {
|
|
203
|
+
byStatus: tracker.status,
|
|
204
|
+
byCategory: sortBreakdownRowsByBytes([...tracker.byCategory.values()]),
|
|
205
|
+
byMonth: sortMonthBreakdownRows([...tracker.byMonth.values()]),
|
|
206
|
+
byRoot: sortBreakdownRowsByBytes([...tracker.byRoot.values()]),
|
|
207
|
+
topPaths: [...tracker.topPaths],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeRootList(rootPaths) {
|
|
212
|
+
return [
|
|
213
|
+
...new Set(
|
|
214
|
+
(rootPaths || [])
|
|
215
|
+
.map((item) => String(item || '').trim())
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.map((item) => path.resolve(item))
|
|
218
|
+
),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isPathWithinRoot(rootPath, targetPath) {
|
|
223
|
+
const rootAbs = path.resolve(rootPath);
|
|
224
|
+
const targetAbs = path.resolve(targetPath);
|
|
225
|
+
const rel = path.relative(rootAbs, targetAbs);
|
|
226
|
+
if (!rel) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isPathWithinAnyRoot(rootPaths, targetPath) {
|
|
233
|
+
for (const rootPath of rootPaths || []) {
|
|
234
|
+
if (!rootPath) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (isPathWithinRoot(rootPath, targetPath)) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function safeRealpath(targetPath) {
|
|
245
|
+
try {
|
|
246
|
+
return await fs.realpath(targetPath);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function resolveExistingAncestorRealpath(targetPathAbs) {
|
|
253
|
+
let current = path.resolve(targetPathAbs);
|
|
254
|
+
while (true) {
|
|
255
|
+
const real = await safeRealpath(current);
|
|
256
|
+
if (real) {
|
|
257
|
+
return {
|
|
258
|
+
ancestorPath: current,
|
|
259
|
+
ancestorRealPath: real,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const parent = path.dirname(current);
|
|
263
|
+
if (parent === current) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
current = parent;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function resolvePathForBoundaryCheck(targetPath) {
|
|
271
|
+
const targetAbs = path.resolve(targetPath);
|
|
272
|
+
const stat = await fs.lstat(targetAbs).catch(() => null);
|
|
273
|
+
if (stat) {
|
|
274
|
+
const targetReal = await safeRealpath(targetAbs);
|
|
275
|
+
if (!targetReal) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
resolvedPath: targetReal,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const ancestor = await resolveExistingAncestorRealpath(targetAbs);
|
|
287
|
+
if (!ancestor) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rel = path.relative(ancestor.ancestorPath, targetAbs);
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
resolvedPath: path.resolve(ancestor.ancestorRealPath, rel || '.'),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isPathWithinResolvedRoot(rootPathResolved, targetPathResolved) {
|
|
301
|
+
const rel = path.relative(rootPathResolved, targetPathResolved);
|
|
302
|
+
if (!rel) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isPathWithinAnyResolvedRoot(rootPathsResolved, targetPathResolved) {
|
|
309
|
+
for (const rootPathResolved of rootPathsResolved || []) {
|
|
310
|
+
if (!rootPathResolved) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (isPathWithinResolvedRoot(rootPathResolved, targetPathResolved)) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function resolveRootsRealpath(rootPaths) {
|
|
321
|
+
const resolved = [];
|
|
322
|
+
for (const rootPath of normalizeRootList(rootPaths)) {
|
|
323
|
+
const real = await safeRealpath(rootPath);
|
|
324
|
+
if (!real) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
resolved.push(real);
|
|
328
|
+
}
|
|
329
|
+
return [...new Set(resolved)];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function buildCleanupValidationState(allowedRoots) {
|
|
333
|
+
const allowedRootsRaw = normalizeRootList(allowedRoots);
|
|
334
|
+
const allowedRootsReal = await resolveRootsRealpath(allowedRootsRaw);
|
|
335
|
+
return {
|
|
336
|
+
allowedRootsRaw,
|
|
337
|
+
allowedRootsReal,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function validateCleanupTargetPath(targetPath, validationState) {
|
|
342
|
+
if (!validationState || !Array.isArray(validationState.allowedRootsRaw)) {
|
|
343
|
+
return 'missing_allowed_root';
|
|
344
|
+
}
|
|
345
|
+
if (validationState.allowedRootsReal.length === 0) {
|
|
346
|
+
return 'missing_allowed_root';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sourceChecked = await resolvePathForBoundaryCheck(targetPath);
|
|
350
|
+
if (!sourceChecked.ok) {
|
|
351
|
+
return 'source_path_unresolvable';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const sourceInside = isPathWithinAnyResolvedRoot(
|
|
355
|
+
validationState.allowedRootsReal,
|
|
356
|
+
sourceChecked.resolvedPath
|
|
357
|
+
);
|
|
358
|
+
if (sourceInside) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const rawInside = isPathWithinAnyRoot(validationState.allowedRootsRaw, targetPath);
|
|
363
|
+
if (rawInside) {
|
|
364
|
+
return 'source_symlink_escape';
|
|
365
|
+
}
|
|
366
|
+
return 'source_outside_allowed_root';
|
|
367
|
+
}
|
|
368
|
+
|
|
39
369
|
export async function executeCleanup({
|
|
40
370
|
targets,
|
|
41
371
|
recycleRoot,
|
|
42
372
|
indexPath,
|
|
43
373
|
dryRun,
|
|
374
|
+
allowedRoots = [],
|
|
44
375
|
scope = 'cleanup_monthly',
|
|
45
376
|
shouldSkip,
|
|
46
377
|
onProgress,
|
|
@@ -60,6 +391,8 @@ export async function executeCleanup({
|
|
|
60
391
|
reclaimedBytes: 0,
|
|
61
392
|
errors: [],
|
|
62
393
|
};
|
|
394
|
+
const validationState = await buildCleanupValidationState(allowedRoots);
|
|
395
|
+
const breakdownTracker = createCleanupBreakdownTracker();
|
|
63
396
|
|
|
64
397
|
const total = targets.length;
|
|
65
398
|
|
|
@@ -75,6 +408,7 @@ export async function executeCleanup({
|
|
|
75
408
|
}
|
|
76
409
|
if (typeof skipByPolicy === 'string' && skipByPolicy) {
|
|
77
410
|
summary.skippedCount += 1;
|
|
411
|
+
updateCleanupBreakdown(breakdownTracker, target, 'skipped', skipByPolicy);
|
|
78
412
|
await appendJsonLine(indexPath, {
|
|
79
413
|
action: 'cleanup',
|
|
80
414
|
time: Date.now(),
|
|
@@ -102,6 +436,7 @@ export async function executeCleanup({
|
|
|
102
436
|
const exists = await pathExists(target.path);
|
|
103
437
|
if (!exists) {
|
|
104
438
|
summary.skippedCount += 1;
|
|
439
|
+
updateCleanupBreakdown(breakdownTracker, target, 'skipped', 'skipped_missing_source');
|
|
105
440
|
await appendJsonLine(indexPath, {
|
|
106
441
|
action: 'cleanup',
|
|
107
442
|
time: Date.now(),
|
|
@@ -126,9 +461,40 @@ export async function executeCleanup({
|
|
|
126
461
|
continue;
|
|
127
462
|
}
|
|
128
463
|
|
|
464
|
+
const invalidPathReason = await validateCleanupTargetPath(target.path, validationState);
|
|
465
|
+
if (invalidPathReason) {
|
|
466
|
+
summary.skippedCount += 1;
|
|
467
|
+
updateCleanupBreakdown(breakdownTracker, target, 'skipped', 'skipped_invalid_path');
|
|
468
|
+
await appendJsonLine(indexPath, {
|
|
469
|
+
action: 'cleanup',
|
|
470
|
+
time: Date.now(),
|
|
471
|
+
scope,
|
|
472
|
+
batchId,
|
|
473
|
+
sourcePath: target.path,
|
|
474
|
+
recyclePath: null,
|
|
475
|
+
accountId: target.accountId,
|
|
476
|
+
accountShortId: target.accountShortId,
|
|
477
|
+
userName: target.userName,
|
|
478
|
+
corpName: target.corpName,
|
|
479
|
+
categoryKey: target.categoryKey,
|
|
480
|
+
categoryLabel: target.categoryLabel,
|
|
481
|
+
monthKey: target.monthKey,
|
|
482
|
+
sizeBytes: target.sizeBytes,
|
|
483
|
+
targetKey: target.targetKey || null,
|
|
484
|
+
tier: target.tier || null,
|
|
485
|
+
status: 'skipped_invalid_path',
|
|
486
|
+
error_type: ERROR_TYPES.PATH_VALIDATION_FAILED,
|
|
487
|
+
invalid_reason: invalidPathReason,
|
|
488
|
+
allowed_roots: validationState.allowedRootsRaw,
|
|
489
|
+
dryRun: Boolean(dryRun),
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
129
494
|
if (dryRun) {
|
|
130
495
|
summary.successCount += 1;
|
|
131
496
|
summary.reclaimedBytes += target.sizeBytes;
|
|
497
|
+
updateCleanupBreakdown(breakdownTracker, target, 'dryRun', 'dry_run');
|
|
132
498
|
await appendJsonLine(indexPath, {
|
|
133
499
|
action: 'cleanup',
|
|
134
500
|
time: Date.now(),
|
|
@@ -159,6 +525,7 @@ export async function executeCleanup({
|
|
|
159
525
|
await movePath(target.path, recyclePath);
|
|
160
526
|
summary.successCount += 1;
|
|
161
527
|
summary.reclaimedBytes += target.sizeBytes;
|
|
528
|
+
updateCleanupBreakdown(breakdownTracker, target, 'success', 'success');
|
|
162
529
|
|
|
163
530
|
const now = Date.now();
|
|
164
531
|
await appendJsonLine(indexPath, {
|
|
@@ -188,6 +555,7 @@ export async function executeCleanup({
|
|
|
188
555
|
path: target.path,
|
|
189
556
|
message,
|
|
190
557
|
});
|
|
558
|
+
updateCleanupBreakdown(breakdownTracker, target, 'failed', 'failed');
|
|
191
559
|
await appendJsonLine(indexPath, {
|
|
192
560
|
action: 'cleanup',
|
|
193
561
|
time: Date.now(),
|
|
@@ -213,5 +581,6 @@ export async function executeCleanup({
|
|
|
213
581
|
}
|
|
214
582
|
}
|
|
215
583
|
|
|
584
|
+
summary.breakdown = finalizeCleanupBreakdown(breakdownTracker);
|
|
216
585
|
return summary;
|
|
217
586
|
}
|