@mison/wecom-cleaner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/docs/IMPLEMENTATION_PLAN.md +162 -0
- package/docs/releases/v1.0.0.md +32 -0
- package/native/README.md +9 -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 +15 -0
- package/native/zig/build.sh +68 -0
- package/native/zig/src/main.zig +96 -0
- package/package.json +62 -0
- package/src/analysis.js +58 -0
- package/src/cleanup.js +217 -0
- package/src/cli.js +2619 -0
- package/src/config.js +270 -0
- package/src/constants.js +309 -0
- package/src/doctor.js +366 -0
- package/src/error-taxonomy.js +73 -0
- package/src/lock.js +102 -0
- package/src/native-bridge.js +403 -0
- package/src/recycle-maintenance.js +335 -0
- package/src/restore.js +533 -0
- package/src/scanner.js +1277 -0
- package/src/utils.js +365 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const std = @import("std");
|
|
2
|
+
|
|
3
|
+
fn isAbsPath(path: []const u8) bool {
|
|
4
|
+
return std.fs.path.isAbsolute(path);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
fn pathSize(path: []const u8) !u64 {
|
|
8
|
+
const cwd = std.fs.cwd();
|
|
9
|
+
|
|
10
|
+
const file_res = if (isAbsPath(path)) std.fs.openFileAbsolute(path, .{}) else cwd.openFile(path, .{});
|
|
11
|
+
if (file_res) |file| {
|
|
12
|
+
defer file.close();
|
|
13
|
+
const st = try file.stat();
|
|
14
|
+
return st.size;
|
|
15
|
+
} else |err| {
|
|
16
|
+
if (err != error.IsDir) {
|
|
17
|
+
return err;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var dir = if (isAbsPath(path))
|
|
22
|
+
try std.fs.openDirAbsolute(path, .{ .iterate = true, .access_sub_paths = true })
|
|
23
|
+
else
|
|
24
|
+
try cwd.openDir(path, .{ .iterate = true, .access_sub_paths = true });
|
|
25
|
+
defer dir.close();
|
|
26
|
+
|
|
27
|
+
return dirTreeSize(&dir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn dirTreeSize(dir: *std.fs.Dir) !u64 {
|
|
31
|
+
var total: u64 = 0;
|
|
32
|
+
var it = dir.iterate();
|
|
33
|
+
|
|
34
|
+
while (try it.next()) |entry| {
|
|
35
|
+
switch (entry.kind) {
|
|
36
|
+
.file => {
|
|
37
|
+
const st = dir.statFile(entry.name) catch continue;
|
|
38
|
+
total += st.size;
|
|
39
|
+
},
|
|
40
|
+
.directory => {
|
|
41
|
+
var sub = dir.openDir(entry.name, .{ .iterate = true, .access_sub_paths = true }) catch continue;
|
|
42
|
+
defer sub.close();
|
|
43
|
+
total += dirTreeSize(&sub) catch 0;
|
|
44
|
+
},
|
|
45
|
+
.sym_link => {},
|
|
46
|
+
else => {
|
|
47
|
+
const st = dir.statFile(entry.name) catch continue;
|
|
48
|
+
if (st.kind == .file) {
|
|
49
|
+
total += st.size;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return total;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn printPing(writer: *std.Io.Writer) !void {
|
|
59
|
+
try writer.writeAll("{\"ok\":true,\"engine\":\"zig\",\"version\":\"1.0.0\"}\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn runDu(args: []const []const u8, writer: *std.Io.Writer) !void {
|
|
63
|
+
for (args) |p| {
|
|
64
|
+
const size = pathSize(p) catch 0;
|
|
65
|
+
try writer.print("{d}\t{s}\n", .{ size, p });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub fn main() !void {
|
|
70
|
+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
71
|
+
defer _ = gpa.deinit();
|
|
72
|
+
const allocator = gpa.allocator();
|
|
73
|
+
|
|
74
|
+
const args = try std.process.argsAlloc(allocator);
|
|
75
|
+
defer std.process.argsFree(allocator, args);
|
|
76
|
+
|
|
77
|
+
var stdout_buffer: [8192]u8 = undefined;
|
|
78
|
+
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
|
79
|
+
const writer = &stdout_writer.interface;
|
|
80
|
+
|
|
81
|
+
if (args.len >= 2 and std.mem.eql(u8, args[1], "--ping")) {
|
|
82
|
+
try printPing(writer);
|
|
83
|
+
try writer.flush();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (args.len >= 2 and std.mem.eql(u8, args[1], "du")) {
|
|
88
|
+
if (args.len == 2) return;
|
|
89
|
+
try runDu(args[2..], writer);
|
|
90
|
+
try writer.flush();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try writer.writeAll("Usage: wecom-cleaner-core --ping | du <path...>\n");
|
|
95
|
+
try writer.flush();
|
|
96
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mison/wecom-cleaner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "企业微信本地聊天缓存清理工具(交互式 CLI/TUI)",
|
|
5
|
+
"author": "MisonL",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/MisonL/wecom-cleaner.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/MisonL/wecom-cleaner/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MisonL/wecom-cleaner",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"wecom-cleaner": "./src/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "./src/cli.js",
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"native",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"docs"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build:native": "./native/zig/build.sh",
|
|
32
|
+
"build:native:darwin:x64": "TARGET_OS=darwin TARGET_ARCH=x64 ./native/zig/build.sh",
|
|
33
|
+
"build:native:darwin:arm64": "TARGET_OS=darwin TARGET_ARCH=arm64 ./native/zig/build.sh",
|
|
34
|
+
"build:native:release": "npm run build:native:darwin:x64 && npm run build:native:darwin:arm64",
|
|
35
|
+
"start": "node src/cli.js",
|
|
36
|
+
"dev": "node src/cli.js",
|
|
37
|
+
"test": "node --test test/*.test.js",
|
|
38
|
+
"test:coverage": "c8 --reporter=text --reporter=lcov --all --include=src/**/*.js --exclude=src/cli.js node --test test/*.test.js",
|
|
39
|
+
"test:coverage:check": "c8 --check-coverage --lines 75 --functions 80 --branches 60 --statements 75 --all --include=src/**/*.js --exclude=src/cli.js node --test test/*.test.js",
|
|
40
|
+
"format": "prettier --write .",
|
|
41
|
+
"format:check": "prettier --check .",
|
|
42
|
+
"check": "node --check src/*.js",
|
|
43
|
+
"e2e:smoke": "bash scripts/e2e-smoke.sh",
|
|
44
|
+
"prepack": "npm run build:native:release && npm run check",
|
|
45
|
+
"pack:tgz": "node scripts/pack-tgz.js",
|
|
46
|
+
"pack:tgz:dry-run": "node scripts/pack-tgz.js --dry-run"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"wecom",
|
|
50
|
+
"cleaner",
|
|
51
|
+
"wechat-work",
|
|
52
|
+
"cache",
|
|
53
|
+
"tui"
|
|
54
|
+
],
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@inquirer/prompts": "^7.8.6"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"c8": "^10.1.3",
|
|
60
|
+
"prettier": "^3.6.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/analysis.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { formatBytes, renderTable } from './utils.js';
|
|
2
|
+
|
|
3
|
+
export function printAnalysisSummary(result) {
|
|
4
|
+
const {
|
|
5
|
+
totalBytes,
|
|
6
|
+
targets,
|
|
7
|
+
accountsSummary,
|
|
8
|
+
categoriesSummary,
|
|
9
|
+
monthsSummary,
|
|
10
|
+
engineUsed,
|
|
11
|
+
nativeFallbackReason,
|
|
12
|
+
} = result;
|
|
13
|
+
|
|
14
|
+
console.log('能力说明:');
|
|
15
|
+
console.log('1. 企业微信会话数据库是私有/加密格式,无法稳定解析“会话名->本地文件夹”映射。');
|
|
16
|
+
console.log('2. 当前模式仅分析可见缓存目录的数据分布,不执行删除。');
|
|
17
|
+
console.log('3. 目录分析可作为手工清理决策依据。\n');
|
|
18
|
+
|
|
19
|
+
console.log(`扫描目录: ${targets.length} 项`);
|
|
20
|
+
console.log(`总大小 : ${formatBytes(totalBytes)}`);
|
|
21
|
+
if (engineUsed) {
|
|
22
|
+
console.log(`扫描引擎: ${engineUsed === 'zig' ? 'Zig核心' : 'Node引擎'}`);
|
|
23
|
+
}
|
|
24
|
+
if (nativeFallbackReason) {
|
|
25
|
+
console.log(`引擎提示: ${nativeFallbackReason}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (accountsSummary.length > 0) {
|
|
29
|
+
const rows = accountsSummary.map((row) => [
|
|
30
|
+
row.userName,
|
|
31
|
+
row.corpName,
|
|
32
|
+
row.shortId,
|
|
33
|
+
String(row.count),
|
|
34
|
+
formatBytes(row.sizeBytes),
|
|
35
|
+
]);
|
|
36
|
+
console.log('\n账号维度:');
|
|
37
|
+
console.log(renderTable(['用户名', '企业名', '短ID', '目录数', '大小'], rows));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (categoriesSummary.length > 0) {
|
|
41
|
+
const rows = categoriesSummary.map((row) => [
|
|
42
|
+
row.categoryLabel,
|
|
43
|
+
row.categoryKey,
|
|
44
|
+
String(row.count),
|
|
45
|
+
formatBytes(row.sizeBytes),
|
|
46
|
+
]);
|
|
47
|
+
console.log('\n类型维度:');
|
|
48
|
+
console.log(renderTable(['类型', 'Key', '目录数', '大小'], rows));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (monthsSummary.length > 0) {
|
|
52
|
+
const rows = monthsSummary
|
|
53
|
+
.slice(0, 20)
|
|
54
|
+
.map((row) => [row.monthKey, String(row.count), formatBytes(row.sizeBytes)]);
|
|
55
|
+
console.log('\n月份维度(Top 20):');
|
|
56
|
+
console.log(renderTable(['月份/目录', '目录数', '大小'], rows));
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cleanup.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { appendJsonLine, ensureDir, pathExists } from './utils.js';
|
|
5
|
+
import { classifyErrorType, ERROR_TYPES } from './error-taxonomy.js';
|
|
6
|
+
|
|
7
|
+
function generateBatchId() {
|
|
8
|
+
const date = new Date();
|
|
9
|
+
const y = date.getFullYear();
|
|
10
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
11
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
12
|
+
const hh = String(date.getHours()).padStart(2, '0');
|
|
13
|
+
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
14
|
+
const ss = String(date.getSeconds()).padStart(2, '0');
|
|
15
|
+
const rand = crypto.randomBytes(3).toString('hex');
|
|
16
|
+
return `${y}${m}${d}-${hh}${mm}${ss}-${rand}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function movePath(src, dest) {
|
|
20
|
+
await ensureDir(path.dirname(dest));
|
|
21
|
+
try {
|
|
22
|
+
await fs.rename(src, dest);
|
|
23
|
+
return;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code !== 'EXDEV') {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await fs.cp(src, dest, { recursive: true, force: true });
|
|
31
|
+
await fs.rm(src, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function escapePathForName(srcPath) {
|
|
35
|
+
const base = path.basename(srcPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
36
|
+
return base || 'unknown';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function executeCleanup({
|
|
40
|
+
targets,
|
|
41
|
+
recycleRoot,
|
|
42
|
+
indexPath,
|
|
43
|
+
dryRun,
|
|
44
|
+
scope = 'cleanup_monthly',
|
|
45
|
+
shouldSkip,
|
|
46
|
+
onProgress,
|
|
47
|
+
}) {
|
|
48
|
+
const batchId = generateBatchId();
|
|
49
|
+
const batchRoot = dryRun ? null : path.join(recycleRoot, batchId);
|
|
50
|
+
if (batchRoot) {
|
|
51
|
+
await ensureDir(batchRoot);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const summary = {
|
|
55
|
+
batchId,
|
|
56
|
+
dryRun: Boolean(dryRun),
|
|
57
|
+
successCount: 0,
|
|
58
|
+
skippedCount: 0,
|
|
59
|
+
failedCount: 0,
|
|
60
|
+
reclaimedBytes: 0,
|
|
61
|
+
errors: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const total = targets.length;
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < total; i += 1) {
|
|
67
|
+
const target = targets[i];
|
|
68
|
+
if (typeof onProgress === 'function') {
|
|
69
|
+
onProgress(i + 1, total);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let skipByPolicy = null;
|
|
73
|
+
if (typeof shouldSkip === 'function') {
|
|
74
|
+
skipByPolicy = await shouldSkip(target);
|
|
75
|
+
}
|
|
76
|
+
if (typeof skipByPolicy === 'string' && skipByPolicy) {
|
|
77
|
+
summary.skippedCount += 1;
|
|
78
|
+
await appendJsonLine(indexPath, {
|
|
79
|
+
action: 'cleanup',
|
|
80
|
+
time: Date.now(),
|
|
81
|
+
scope,
|
|
82
|
+
batchId,
|
|
83
|
+
sourcePath: target.path,
|
|
84
|
+
recyclePath: null,
|
|
85
|
+
accountId: target.accountId,
|
|
86
|
+
accountShortId: target.accountShortId,
|
|
87
|
+
userName: target.userName,
|
|
88
|
+
corpName: target.corpName,
|
|
89
|
+
categoryKey: target.categoryKey,
|
|
90
|
+
categoryLabel: target.categoryLabel,
|
|
91
|
+
monthKey: target.monthKey,
|
|
92
|
+
sizeBytes: target.sizeBytes,
|
|
93
|
+
targetKey: target.targetKey || null,
|
|
94
|
+
tier: target.tier || null,
|
|
95
|
+
status: skipByPolicy,
|
|
96
|
+
error_type: ERROR_TYPES.POLICY_SKIPPED,
|
|
97
|
+
dryRun: Boolean(dryRun),
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const exists = await pathExists(target.path);
|
|
103
|
+
if (!exists) {
|
|
104
|
+
summary.skippedCount += 1;
|
|
105
|
+
await appendJsonLine(indexPath, {
|
|
106
|
+
action: 'cleanup',
|
|
107
|
+
time: Date.now(),
|
|
108
|
+
scope,
|
|
109
|
+
batchId,
|
|
110
|
+
sourcePath: target.path,
|
|
111
|
+
recyclePath: null,
|
|
112
|
+
accountId: target.accountId,
|
|
113
|
+
accountShortId: target.accountShortId,
|
|
114
|
+
userName: target.userName,
|
|
115
|
+
corpName: target.corpName,
|
|
116
|
+
categoryKey: target.categoryKey,
|
|
117
|
+
categoryLabel: target.categoryLabel,
|
|
118
|
+
monthKey: target.monthKey,
|
|
119
|
+
sizeBytes: target.sizeBytes,
|
|
120
|
+
targetKey: target.targetKey || null,
|
|
121
|
+
tier: target.tier || null,
|
|
122
|
+
status: 'skipped_missing_source',
|
|
123
|
+
error_type: ERROR_TYPES.PATH_NOT_FOUND,
|
|
124
|
+
dryRun: Boolean(dryRun),
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (dryRun) {
|
|
130
|
+
summary.successCount += 1;
|
|
131
|
+
summary.reclaimedBytes += target.sizeBytes;
|
|
132
|
+
await appendJsonLine(indexPath, {
|
|
133
|
+
action: 'cleanup',
|
|
134
|
+
time: Date.now(),
|
|
135
|
+
scope,
|
|
136
|
+
batchId,
|
|
137
|
+
sourcePath: target.path,
|
|
138
|
+
recyclePath: null,
|
|
139
|
+
accountId: target.accountId,
|
|
140
|
+
accountShortId: target.accountShortId,
|
|
141
|
+
userName: target.userName,
|
|
142
|
+
corpName: target.corpName,
|
|
143
|
+
categoryKey: target.categoryKey,
|
|
144
|
+
categoryLabel: target.categoryLabel,
|
|
145
|
+
monthKey: target.monthKey,
|
|
146
|
+
sizeBytes: target.sizeBytes,
|
|
147
|
+
targetKey: target.targetKey || null,
|
|
148
|
+
tier: target.tier || null,
|
|
149
|
+
status: 'dry_run',
|
|
150
|
+
dryRun: true,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const destName = `${String(i + 1).padStart(4, '0')}_${escapePathForName(target.path)}`;
|
|
156
|
+
const recyclePath = path.join(batchRoot, destName);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await movePath(target.path, recyclePath);
|
|
160
|
+
summary.successCount += 1;
|
|
161
|
+
summary.reclaimedBytes += target.sizeBytes;
|
|
162
|
+
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
await appendJsonLine(indexPath, {
|
|
165
|
+
action: 'cleanup',
|
|
166
|
+
time: now,
|
|
167
|
+
scope,
|
|
168
|
+
batchId,
|
|
169
|
+
sourcePath: target.path,
|
|
170
|
+
recyclePath,
|
|
171
|
+
accountId: target.accountId,
|
|
172
|
+
accountShortId: target.accountShortId,
|
|
173
|
+
userName: target.userName,
|
|
174
|
+
corpName: target.corpName,
|
|
175
|
+
categoryKey: target.categoryKey,
|
|
176
|
+
categoryLabel: target.categoryLabel,
|
|
177
|
+
monthKey: target.monthKey,
|
|
178
|
+
sizeBytes: target.sizeBytes,
|
|
179
|
+
targetKey: target.targetKey || null,
|
|
180
|
+
tier: target.tier || null,
|
|
181
|
+
status: 'success',
|
|
182
|
+
dryRun: false,
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
summary.failedCount += 1;
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
summary.errors.push({
|
|
188
|
+
path: target.path,
|
|
189
|
+
message,
|
|
190
|
+
});
|
|
191
|
+
await appendJsonLine(indexPath, {
|
|
192
|
+
action: 'cleanup',
|
|
193
|
+
time: Date.now(),
|
|
194
|
+
scope,
|
|
195
|
+
batchId,
|
|
196
|
+
sourcePath: target.path,
|
|
197
|
+
recyclePath,
|
|
198
|
+
accountId: target.accountId,
|
|
199
|
+
accountShortId: target.accountShortId,
|
|
200
|
+
userName: target.userName,
|
|
201
|
+
corpName: target.corpName,
|
|
202
|
+
categoryKey: target.categoryKey,
|
|
203
|
+
categoryLabel: target.categoryLabel,
|
|
204
|
+
monthKey: target.monthKey,
|
|
205
|
+
sizeBytes: target.sizeBytes,
|
|
206
|
+
targetKey: target.targetKey || null,
|
|
207
|
+
tier: target.tier || null,
|
|
208
|
+
status: 'failed',
|
|
209
|
+
error_type: classifyErrorType(message),
|
|
210
|
+
dryRun: false,
|
|
211
|
+
error: message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return summary;
|
|
217
|
+
}
|