@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,403 @@
|
|
|
1
|
+
import { promises as fs, createReadStream } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const NATIVE_CACHE_DIR = 'native-cache';
|
|
7
|
+
const MANIFEST_RELATIVE_PATH = path.join('native', 'manifest.json');
|
|
8
|
+
const DEFAULT_DOWNLOAD_BASE_URL = 'https://raw.githubusercontent.com/MisonL/wecom-cleaner/v1.0.0/native/bin';
|
|
9
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
10
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
|
|
11
|
+
|
|
12
|
+
function resolveRuntimeTarget() {
|
|
13
|
+
const runtimePlatform = process.platform;
|
|
14
|
+
const runtimeArch = process.arch;
|
|
15
|
+
|
|
16
|
+
const osTag =
|
|
17
|
+
runtimePlatform === 'win32'
|
|
18
|
+
? 'windows'
|
|
19
|
+
: runtimePlatform === 'darwin'
|
|
20
|
+
? 'darwin'
|
|
21
|
+
: runtimePlatform === 'linux'
|
|
22
|
+
? 'linux'
|
|
23
|
+
: runtimePlatform;
|
|
24
|
+
|
|
25
|
+
const archTag =
|
|
26
|
+
runtimeArch === 'x64'
|
|
27
|
+
? 'x64'
|
|
28
|
+
: runtimeArch === 'arm64'
|
|
29
|
+
? 'arm64'
|
|
30
|
+
: runtimeArch === 'x86_64'
|
|
31
|
+
? 'x64'
|
|
32
|
+
: runtimeArch === 'aarch64'
|
|
33
|
+
? 'arm64'
|
|
34
|
+
: runtimeArch;
|
|
35
|
+
|
|
36
|
+
const ext = osTag === 'windows' ? '.exe' : '';
|
|
37
|
+
const binaryName = `wecom-cleaner-core${ext}`;
|
|
38
|
+
return {
|
|
39
|
+
osTag,
|
|
40
|
+
archTag,
|
|
41
|
+
targetTag: `${osTag}-${archTag}`,
|
|
42
|
+
binaryName,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveBundledBinaryPath(projectRoot, target) {
|
|
47
|
+
return path.join(projectRoot, 'native', 'bin', target.targetTag, target.binaryName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveCachedBinaryPath(stateRoot, target) {
|
|
51
|
+
return path.join(stateRoot, NATIVE_CACHE_DIR, target.targetTag, target.binaryName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function isExecutableFile(filePath) {
|
|
55
|
+
return fs
|
|
56
|
+
.stat(filePath)
|
|
57
|
+
.then((s) => s.isFile())
|
|
58
|
+
.catch(() => false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function probeNativeCore(binPath) {
|
|
62
|
+
const exists = await isExecutableFile(binPath);
|
|
63
|
+
if (!exists) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const probeTimeoutMs = getProbeTimeoutMs();
|
|
68
|
+
const probe = spawnSync(binPath, ['--ping'], {
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
maxBuffer: 1024 * 1024,
|
|
71
|
+
timeout: probeTimeoutMs,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (probe.error) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (probe.signal) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (probe.status !== 0) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const payload = JSON.parse((probe.stdout || '').trim());
|
|
86
|
+
return payload?.ok === true && payload?.engine === 'zig';
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function shouldAutoRepair() {
|
|
93
|
+
const raw = String(process.env.WECOM_CLEANER_NATIVE_AUTO_REPAIR || 'true')
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.trim();
|
|
96
|
+
return !(raw === '0' || raw === 'false' || raw === 'no' || raw === 'off');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getDownloadBaseUrlOverride() {
|
|
100
|
+
const raw = process.env.WECOM_CLEANER_NATIVE_BASE_URL;
|
|
101
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
102
|
+
return raw.trim().replace(/\/+$/, '');
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getDownloadTimeoutMs() {
|
|
108
|
+
const raw = Number.parseInt(process.env.WECOM_CLEANER_NATIVE_DOWNLOAD_TIMEOUT_MS || '', 10);
|
|
109
|
+
if (Number.isFinite(raw) && raw >= 1_000) {
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
return DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getProbeTimeoutMs() {
|
|
116
|
+
const raw = Number.parseInt(process.env.WECOM_CLEANER_NATIVE_PROBE_TIMEOUT_MS || '', 10);
|
|
117
|
+
if (Number.isFinite(raw) && raw >= 500) {
|
|
118
|
+
return raw;
|
|
119
|
+
}
|
|
120
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function downloadNativeCore(url, destinationPath) {
|
|
124
|
+
const timeout = getDownloadTimeoutMs();
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
127
|
+
if (typeof timer.unref === 'function') {
|
|
128
|
+
timer.unref();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tmpPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(url, {
|
|
135
|
+
method: 'GET',
|
|
136
|
+
redirect: 'follow',
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
143
|
+
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
144
|
+
await fs.writeFile(tmpPath, data);
|
|
145
|
+
await fs.chmod(tmpPath, 0o755).catch(() => {});
|
|
146
|
+
await fs.rename(tmpPath, destinationPath);
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeSha256(raw) {
|
|
154
|
+
if (typeof raw !== 'string') {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const normalized = raw.trim().toLowerCase();
|
|
158
|
+
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function calculateFileSha256(filePath) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const hash = createHash('sha256');
|
|
164
|
+
const stream = createReadStream(filePath);
|
|
165
|
+
stream.on('error', reject);
|
|
166
|
+
stream.on('data', (chunk) => {
|
|
167
|
+
hash.update(chunk);
|
|
168
|
+
});
|
|
169
|
+
stream.on('end', () => {
|
|
170
|
+
resolve(hash.digest('hex'));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readNativeManifest(projectRoot) {
|
|
176
|
+
const manifestPath = path.join(projectRoot, MANIFEST_RELATIVE_PATH);
|
|
177
|
+
try {
|
|
178
|
+
const text = await fs.readFile(manifestPath, 'utf-8');
|
|
179
|
+
const payload = JSON.parse(text);
|
|
180
|
+
if (!payload || typeof payload !== 'object') {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return payload;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function resolveManifestTarget(manifest, target) {
|
|
190
|
+
if (
|
|
191
|
+
!manifest ||
|
|
192
|
+
typeof manifest !== 'object' ||
|
|
193
|
+
!manifest.targets ||
|
|
194
|
+
typeof manifest.targets !== 'object'
|
|
195
|
+
) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const raw = manifest.targets[target.targetTag];
|
|
200
|
+
if (!raw || typeof raw !== 'object') {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
binaryName:
|
|
206
|
+
typeof raw.binaryName === 'string' && raw.binaryName.trim() ? raw.binaryName.trim() : target.binaryName,
|
|
207
|
+
sha256: normalizeSha256(raw.sha256),
|
|
208
|
+
url: typeof raw.url === 'string' && raw.url.trim() ? raw.url.trim() : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveManifestVersion(manifest) {
|
|
213
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (typeof manifest.version === 'string' && manifest.version.trim()) {
|
|
217
|
+
return manifest.version.trim();
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveDownloadUrl({ target, manifest, manifestTarget }) {
|
|
223
|
+
const fileName = manifestTarget?.binaryName || target.binaryName;
|
|
224
|
+
const overrideBaseUrl = getDownloadBaseUrlOverride();
|
|
225
|
+
if (overrideBaseUrl) {
|
|
226
|
+
return `${overrideBaseUrl}/${target.targetTag}/${fileName}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (manifestTarget?.url) {
|
|
230
|
+
return manifestTarget.url;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const manifestBaseUrl =
|
|
234
|
+
typeof manifest?.baseUrl === 'string' && manifest.baseUrl.trim()
|
|
235
|
+
? manifest.baseUrl.trim().replace(/\/+$/, '')
|
|
236
|
+
: null;
|
|
237
|
+
|
|
238
|
+
if (manifestBaseUrl) {
|
|
239
|
+
return `${manifestBaseUrl}/${target.targetTag}/${fileName}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return `${DEFAULT_DOWNLOAD_BASE_URL}/${target.targetTag}/${fileName}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function verifySha256OrReason(filePath, expectedSha256) {
|
|
246
|
+
const expected = normalizeSha256(expectedSha256);
|
|
247
|
+
if (!expected) {
|
|
248
|
+
return { ok: false, reason: 'missing_expected', actual: null };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const actual = await calculateFileSha256(filePath);
|
|
252
|
+
if (actual === expected) {
|
|
253
|
+
return { ok: true, reason: null, actual };
|
|
254
|
+
}
|
|
255
|
+
return { ok: false, reason: 'sha256_mismatch', actual };
|
|
256
|
+
} catch {
|
|
257
|
+
return { ok: false, reason: 'sha256_failed', actual: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatHashShort(hashValue) {
|
|
262
|
+
if (typeof hashValue !== 'string' || hashValue.length < 12) {
|
|
263
|
+
return 'unknown';
|
|
264
|
+
}
|
|
265
|
+
return `${hashValue.slice(0, 8)}...${hashValue.slice(-4)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function repairNativeCore({ stateRoot, target, manifest, manifestTarget }) {
|
|
269
|
+
if (!stateRoot) {
|
|
270
|
+
return {
|
|
271
|
+
nativeCorePath: null,
|
|
272
|
+
repairNote: '自动修复: 无可写状态目录,已继续使用Node',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!manifestTarget) {
|
|
277
|
+
return {
|
|
278
|
+
nativeCorePath: null,
|
|
279
|
+
repairNote: `自动修复: 当前平台(${target.targetTag})缺少可信核心清单,已继续使用Node`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!manifestTarget.sha256) {
|
|
284
|
+
return {
|
|
285
|
+
nativeCorePath: null,
|
|
286
|
+
repairNote: `自动修复: 当前平台(${target.targetTag})缺少SHA256校验值,已继续使用Node`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const destinationPath = resolveCachedBinaryPath(stateRoot, target);
|
|
291
|
+
const downloadUrl = resolveDownloadUrl({ target, manifest, manifestTarget });
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await downloadNativeCore(downloadUrl, destinationPath);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
297
|
+
return {
|
|
298
|
+
nativeCorePath: null,
|
|
299
|
+
repairNote: `自动修复: 下载失败(${message}),已继续使用Node`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const digestResult = await verifySha256OrReason(destinationPath, manifestTarget.sha256);
|
|
304
|
+
if (!digestResult.ok) {
|
|
305
|
+
await fs.rm(destinationPath, { force: true }).catch(() => {});
|
|
306
|
+
if (digestResult.reason === 'sha256_mismatch') {
|
|
307
|
+
return {
|
|
308
|
+
nativeCorePath: null,
|
|
309
|
+
repairNote: `自动修复: 校验失败(SHA256不匹配,实际 ${formatHashShort(digestResult.actual)}),已继续使用Node`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
nativeCorePath: null,
|
|
314
|
+
repairNote: '自动修复: 校验失败(SHA256计算异常),已继续使用Node',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const valid = await probeNativeCore(destinationPath);
|
|
319
|
+
if (!valid) {
|
|
320
|
+
await fs.rm(destinationPath, { force: true }).catch(() => {});
|
|
321
|
+
return {
|
|
322
|
+
nativeCorePath: null,
|
|
323
|
+
repairNote: '自动修复: 下载结果探针失败(--ping异常),已继续使用Node',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const manifestVersion = resolveManifestVersion(manifest);
|
|
328
|
+
const versionText = manifestVersion ? `, v${manifestVersion}` : '';
|
|
329
|
+
return {
|
|
330
|
+
nativeCorePath: destinationPath,
|
|
331
|
+
repairNote: `自动修复: Zig核心已恢复(${target.targetTag}${versionText})`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function detectNativeCore(projectRoot, options = {}) {
|
|
336
|
+
const stateRoot = typeof options.stateRoot === 'string' ? options.stateRoot : null;
|
|
337
|
+
const allowAutoRepair = options.allowAutoRepair !== false;
|
|
338
|
+
const target = resolveRuntimeTarget();
|
|
339
|
+
const manifest = await readNativeManifest(projectRoot);
|
|
340
|
+
const manifestTarget = resolveManifestTarget(manifest, target);
|
|
341
|
+
|
|
342
|
+
const bundledPath = resolveBundledBinaryPath(projectRoot, target);
|
|
343
|
+
const bundledOk = await probeNativeCore(bundledPath);
|
|
344
|
+
if (bundledOk) {
|
|
345
|
+
return { nativeCorePath: bundledPath, repairNote: null };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let cacheCheckNote = null;
|
|
349
|
+
if (stateRoot) {
|
|
350
|
+
const cachedPath = resolveCachedBinaryPath(stateRoot, target);
|
|
351
|
+
const cachedExists = await isExecutableFile(cachedPath);
|
|
352
|
+
if (cachedExists) {
|
|
353
|
+
if (!manifestTarget?.sha256) {
|
|
354
|
+
await fs.rm(cachedPath, { force: true }).catch(() => {});
|
|
355
|
+
cacheCheckNote = `自动修复: 本地缓存缺少可信SHA256清单(${target.targetTag})`;
|
|
356
|
+
} else {
|
|
357
|
+
const digestResult = await verifySha256OrReason(cachedPath, manifestTarget.sha256);
|
|
358
|
+
if (!digestResult.ok) {
|
|
359
|
+
await fs.rm(cachedPath, { force: true }).catch(() => {});
|
|
360
|
+
cacheCheckNote =
|
|
361
|
+
digestResult.reason === 'sha256_mismatch'
|
|
362
|
+
? `自动修复: 本地缓存校验失败(SHA256不匹配,实际 ${formatHashShort(digestResult.actual)})`
|
|
363
|
+
: '自动修复: 本地缓存校验失败(SHA256计算异常)';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (await isExecutableFile(cachedPath)) {
|
|
368
|
+
const cachedOk = await probeNativeCore(cachedPath);
|
|
369
|
+
if (cachedOk) {
|
|
370
|
+
return {
|
|
371
|
+
nativeCorePath: cachedPath,
|
|
372
|
+
repairNote: '自动修复: 已使用本地缓存并通过校验的Zig核心',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
await fs.rm(cachedPath, { force: true }).catch(() => {});
|
|
376
|
+
cacheCheckNote = cacheCheckNote || '自动修复: 本地缓存探针失败(--ping异常)';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const autoRepairEnabled = allowAutoRepair && shouldAutoRepair();
|
|
382
|
+
if (!autoRepairEnabled) {
|
|
383
|
+
if (cacheCheckNote) {
|
|
384
|
+
return { nativeCorePath: null, repairNote: cacheCheckNote };
|
|
385
|
+
}
|
|
386
|
+
if (!manifestTarget) {
|
|
387
|
+
return {
|
|
388
|
+
nativeCorePath: null,
|
|
389
|
+
repairNote: `自动修复: 当前平台(${target.targetTag})缺少可信核心清单,已继续使用Node`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return { nativeCorePath: null, repairNote: null };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const repaired = await repairNativeCore({ stateRoot, target, manifest, manifestTarget });
|
|
396
|
+
if (cacheCheckNote && repaired.repairNote && !repaired.nativeCorePath) {
|
|
397
|
+
return {
|
|
398
|
+
nativeCorePath: null,
|
|
399
|
+
repairNote: `${cacheCheckNote};${repaired.repairNote}`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return repaired;
|
|
403
|
+
}
|