@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/src/doctor.js ADDED
@@ -0,0 +1,366 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { collectRecycleStats, normalizeRecycleRetention } from './recycle-maintenance.js';
5
+ import { detectExternalStorageRoots, discoverAccounts } from './scanner.js';
6
+
7
+ const STATUS_PASS = 'pass';
8
+ const STATUS_WARN = 'warn';
9
+ const STATUS_FAIL = 'fail';
10
+ const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
11
+
12
+ function resolveProbeTimeoutMs() {
13
+ const raw = Number.parseInt(String(process.env.WECOM_CLEANER_NATIVE_PROBE_TIMEOUT_MS || ''), 10);
14
+ if (Number.isFinite(raw) && raw >= 500) {
15
+ return raw;
16
+ }
17
+ return DEFAULT_PROBE_TIMEOUT_MS;
18
+ }
19
+
20
+ function resolveRuntimeTarget() {
21
+ const runtimePlatform = process.platform;
22
+ const runtimeArch = process.arch;
23
+
24
+ const osTag =
25
+ runtimePlatform === 'win32'
26
+ ? 'windows'
27
+ : runtimePlatform === 'darwin'
28
+ ? 'darwin'
29
+ : runtimePlatform === 'linux'
30
+ ? 'linux'
31
+ : runtimePlatform;
32
+
33
+ const archTag =
34
+ runtimeArch === 'x64'
35
+ ? 'x64'
36
+ : runtimeArch === 'arm64'
37
+ ? 'arm64'
38
+ : runtimeArch === 'x86_64'
39
+ ? 'x64'
40
+ : runtimeArch === 'aarch64'
41
+ ? 'arm64'
42
+ : runtimeArch;
43
+
44
+ const ext = osTag === 'windows' ? '.exe' : '';
45
+ const binaryName = `wecom-cleaner-core${ext}`;
46
+ return {
47
+ osTag,
48
+ archTag,
49
+ targetTag: `${osTag}-${archTag}`,
50
+ binaryName,
51
+ };
52
+ }
53
+
54
+ async function pathExists(targetPath) {
55
+ return fs
56
+ .stat(targetPath)
57
+ .then(() => true)
58
+ .catch(() => false);
59
+ }
60
+
61
+ async function pathWritable(targetPath) {
62
+ try {
63
+ await fs.access(targetPath, fs.constants.W_OK);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ async function pathReadable(targetPath) {
71
+ try {
72
+ await fs.access(targetPath, fs.constants.R_OK);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function buildCheck(id, title, status, detail, suggestion = '') {
80
+ return {
81
+ id,
82
+ title,
83
+ status,
84
+ detail,
85
+ suggestion,
86
+ };
87
+ }
88
+
89
+ function probeNativeBinary(binPath) {
90
+ const timeoutMs = resolveProbeTimeoutMs();
91
+ const probe = spawnSync(binPath, ['--ping'], {
92
+ encoding: 'utf-8',
93
+ maxBuffer: 1024 * 1024,
94
+ timeout: timeoutMs,
95
+ });
96
+
97
+ if (probe.error) {
98
+ if (probe.error?.code === 'ETIMEDOUT') {
99
+ return {
100
+ ok: false,
101
+ detail: `探针超时(${timeoutMs}ms)`,
102
+ };
103
+ }
104
+ return {
105
+ ok: false,
106
+ detail: `探针异常(${probe.error.message || 'unknown'})`,
107
+ };
108
+ }
109
+
110
+ if (probe.signal) {
111
+ return {
112
+ ok: false,
113
+ detail: `探针被信号中断(${probe.signal})`,
114
+ };
115
+ }
116
+
117
+ if (probe.status !== 0) {
118
+ return {
119
+ ok: false,
120
+ detail: `探针失败(${probe.status ?? 'unknown'})`,
121
+ };
122
+ }
123
+
124
+ try {
125
+ const payload = JSON.parse(String(probe.stdout || '').trim());
126
+ if (payload?.ok === true && payload?.engine === 'zig') {
127
+ return {
128
+ ok: true,
129
+ detail: '探针通过',
130
+ };
131
+ }
132
+ return {
133
+ ok: false,
134
+ detail: '探针返回格式异常',
135
+ };
136
+ } catch {
137
+ return {
138
+ ok: false,
139
+ detail: '探针输出非JSON',
140
+ };
141
+ }
142
+ }
143
+
144
+ function overallStatus(checks) {
145
+ if (checks.some((item) => item.status === STATUS_FAIL)) {
146
+ return STATUS_FAIL;
147
+ }
148
+ if (checks.some((item) => item.status === STATUS_WARN)) {
149
+ return STATUS_WARN;
150
+ }
151
+ return STATUS_PASS;
152
+ }
153
+
154
+ async function readManifest(projectRoot) {
155
+ const manifestPath = path.join(projectRoot, 'native', 'manifest.json');
156
+ try {
157
+ const text = await fs.readFile(manifestPath, 'utf-8');
158
+ const parsed = JSON.parse(text);
159
+ return {
160
+ manifestPath,
161
+ exists: true,
162
+ parsed,
163
+ };
164
+ } catch {
165
+ return {
166
+ manifestPath,
167
+ exists: false,
168
+ parsed: null,
169
+ };
170
+ }
171
+ }
172
+
173
+ export async function runDoctor({ config, aliases, projectRoot, appVersion }) {
174
+ const checks = [];
175
+ const rootDir = path.resolve(String(config.rootDir || ''));
176
+ const stateRoot = path.resolve(String(config.stateRoot || ''));
177
+ const recycleRoot = path.resolve(String(config.recycleRoot || ''));
178
+ const indexDir = path.dirname(
179
+ path.resolve(String(config.indexPath || path.join(stateRoot, 'index.jsonl')))
180
+ );
181
+
182
+ const rootExists = await pathExists(rootDir);
183
+ const rootReadable = rootExists ? await pathReadable(rootDir) : false;
184
+ checks.push(
185
+ buildCheck(
186
+ 'profile_root',
187
+ 'Profile 根目录',
188
+ rootExists && rootReadable ? STATUS_PASS : STATUS_FAIL,
189
+ rootExists ? (rootReadable ? `可读: ${rootDir}` : `存在但不可读: ${rootDir}`) : `不存在: ${rootDir}`,
190
+ rootExists && rootReadable ? '' : '请在“交互配置”中修正根目录并确认权限。'
191
+ )
192
+ );
193
+
194
+ const stateExists = await pathExists(stateRoot);
195
+ const stateWritable = stateExists ? await pathWritable(stateRoot) : false;
196
+ checks.push(
197
+ buildCheck(
198
+ 'state_root',
199
+ '状态目录',
200
+ stateExists && stateWritable ? STATUS_PASS : stateExists ? STATUS_WARN : STATUS_FAIL,
201
+ stateExists
202
+ ? stateWritable
203
+ ? `可写: ${stateRoot}`
204
+ : `存在但不可写: ${stateRoot}`
205
+ : `不存在: ${stateRoot}`,
206
+ stateExists && stateWritable ? '' : '请确认状态目录存在且当前用户有写权限。'
207
+ )
208
+ );
209
+
210
+ const recycleExists = await pathExists(recycleRoot);
211
+ const recycleWritable = recycleExists ? await pathWritable(recycleRoot) : false;
212
+ checks.push(
213
+ buildCheck(
214
+ 'recycle_root',
215
+ '回收区目录',
216
+ recycleExists && recycleWritable ? STATUS_PASS : recycleExists ? STATUS_WARN : STATUS_FAIL,
217
+ recycleExists
218
+ ? recycleWritable
219
+ ? `可写: ${recycleRoot}`
220
+ : `存在但不可写: ${recycleRoot}`
221
+ : `不存在: ${recycleRoot}`,
222
+ recycleExists && recycleWritable ? '' : '请确认回收区目录可写,避免删除/恢复失败。'
223
+ )
224
+ );
225
+
226
+ const indexDirExists = await pathExists(indexDir);
227
+ const indexDirWritable = indexDirExists ? await pathWritable(indexDir) : false;
228
+ checks.push(
229
+ buildCheck(
230
+ 'index_dir',
231
+ '索引目录',
232
+ indexDirExists && indexDirWritable ? STATUS_PASS : indexDirExists ? STATUS_WARN : STATUS_FAIL,
233
+ indexDirExists
234
+ ? indexDirWritable
235
+ ? `可写: ${indexDir}`
236
+ : `存在但不可写: ${indexDir}`
237
+ : `不存在: ${indexDir}`,
238
+ indexDirExists && indexDirWritable ? '' : '请确认 index.jsonl 所在目录可写。'
239
+ )
240
+ );
241
+
242
+ const accounts = await discoverAccounts(rootDir, aliases || {});
243
+ checks.push(
244
+ buildCheck(
245
+ 'accounts',
246
+ '账号发现',
247
+ accounts.length > 0 ? STATUS_PASS : STATUS_WARN,
248
+ `识别到 ${accounts.length} 个账号`,
249
+ accounts.length > 0 ? '' : '请确认 Profile 根目录是否指向真实企业微信数据目录。'
250
+ )
251
+ );
252
+
253
+ const externalStorage = await detectExternalStorageRoots({
254
+ configuredRoots: config.externalStorageRoots,
255
+ profilesRoot: rootDir,
256
+ autoDetect: config.externalStorageAutoDetect !== false,
257
+ returnMeta: true,
258
+ });
259
+ const sourceCounts = externalStorage.meta?.sourceCounts || { builtin: 0, configured: 0, auto: 0 };
260
+ checks.push(
261
+ buildCheck(
262
+ 'external_storage',
263
+ '文件存储目录识别',
264
+ externalStorage.roots.length > 0 ? STATUS_PASS : STATUS_WARN,
265
+ `共 ${externalStorage.roots.length} 个(默认${sourceCounts.builtin || 0}/手动${sourceCounts.configured || 0}/自动${sourceCounts.auto || 0})`,
266
+ externalStorage.roots.length > 0 ? '' : '若您修改过企业微信文件存储路径,请在设置中手动追加。'
267
+ )
268
+ );
269
+
270
+ const target = resolveRuntimeTarget();
271
+ const manifest = await readManifest(projectRoot);
272
+ const manifestTarget = manifest.parsed?.targets?.[target.targetTag] || null;
273
+ const manifestVersion = String(manifest.parsed?.version || '').trim();
274
+ const versionMatched = manifestVersion && appVersion ? manifestVersion === appVersion : true;
275
+
276
+ checks.push(
277
+ buildCheck(
278
+ 'native_manifest',
279
+ 'Native 清单(manifest)',
280
+ manifest.exists && manifestTarget ? (versionMatched ? STATUS_PASS : STATUS_WARN) : STATUS_WARN,
281
+ manifest.exists
282
+ ? manifestTarget
283
+ ? `存在目标 ${target.targetTag}${manifestVersion ? `,版本 ${manifestVersion}` : ''}`
284
+ : `缺少目标 ${target.targetTag}`
285
+ : 'manifest.json 不存在',
286
+ manifest.exists && manifestTarget
287
+ ? versionMatched
288
+ ? ''
289
+ : `manifest 版本(${manifestVersion})与应用版本(${appVersion})不一致,建议发布时同步。`
290
+ : '请检查 native/manifest.json 是否包含当前平台目标。'
291
+ )
292
+ );
293
+
294
+ const bundledPath = path.join(projectRoot, 'native', 'bin', target.targetTag, target.binaryName);
295
+ const bundledExists = await pathExists(bundledPath);
296
+ const bundledProbe = bundledExists ? probeNativeBinary(bundledPath) : { ok: false, detail: '未找到二进制' };
297
+ checks.push(
298
+ buildCheck(
299
+ 'native_bundled',
300
+ '随包 Zig 核心',
301
+ bundledProbe.ok ? STATUS_PASS : STATUS_WARN,
302
+ bundledExists ? `${bundledPath}(${bundledProbe.detail})` : `缺失: ${bundledPath}`,
303
+ bundledProbe.ok ? '' : '可执行 npm run build:native:release 重新构建。'
304
+ )
305
+ );
306
+
307
+ const cachedPath = path.join(stateRoot, 'native-cache', target.targetTag, target.binaryName);
308
+ const cachedExists = await pathExists(cachedPath);
309
+ const cachedProbe = cachedExists ? probeNativeBinary(cachedPath) : { ok: false, detail: '未命中缓存' };
310
+ checks.push(
311
+ buildCheck(
312
+ 'native_cache',
313
+ '缓存 Zig 核心',
314
+ cachedProbe.ok ? STATUS_PASS : STATUS_WARN,
315
+ cachedExists ? `${cachedPath}(${cachedProbe.detail})` : `缺失: ${cachedPath}`,
316
+ cachedProbe.ok ? '' : '首次运行可由自动修复下载,或手动构建后再运行。'
317
+ )
318
+ );
319
+
320
+ const retention = normalizeRecycleRetention(config.recycleRetention);
321
+ const recycleStats = await collectRecycleStats({
322
+ indexPath: config.indexPath,
323
+ recycleRoot: config.recycleRoot,
324
+ createIfMissing: false,
325
+ });
326
+ const thresholdBytes = Math.max(1, Number(retention.sizeThresholdGB || 20)) * 1024 * 1024 * 1024;
327
+ const recycleOverThreshold = recycleStats.totalBytes > thresholdBytes;
328
+ checks.push(
329
+ buildCheck(
330
+ 'recycle_health',
331
+ '回收区健康',
332
+ recycleOverThreshold ? STATUS_WARN : STATUS_PASS,
333
+ `批次 ${recycleStats.totalBatches} 个,容量 ${recycleStats.totalBytes} bytes,阈值 ${thresholdBytes} bytes`,
334
+ recycleOverThreshold ? '建议执行回收区治理(--mode recycle_maintain)。' : ''
335
+ )
336
+ );
337
+
338
+ const summary = {
339
+ pass: checks.filter((item) => item.status === STATUS_PASS).length,
340
+ warn: checks.filter((item) => item.status === STATUS_WARN).length,
341
+ fail: checks.filter((item) => item.status === STATUS_FAIL).length,
342
+ };
343
+
344
+ const recommendations = checks.filter((item) => item.suggestion).map((item) => item.suggestion);
345
+
346
+ return {
347
+ generatedAt: Date.now(),
348
+ overall: overallStatus(checks),
349
+ runtime: {
350
+ os: target.osTag,
351
+ arch: target.archTag,
352
+ targetTag: target.targetTag,
353
+ },
354
+ checks,
355
+ summary,
356
+ metrics: {
357
+ accountCount: accounts.length,
358
+ externalStorageCount: externalStorage.roots.length,
359
+ recycleBatchCount: recycleStats.totalBatches,
360
+ recycleBytes: recycleStats.totalBytes,
361
+ recycleThresholdBytes: thresholdBytes,
362
+ recycleOverThreshold,
363
+ },
364
+ recommendations,
365
+ };
366
+ }
@@ -0,0 +1,73 @@
1
+ export const ERROR_TYPES = {
2
+ PERMISSION_DENIED: 'permission_denied',
3
+ PATH_NOT_FOUND: 'path_not_found',
4
+ PATH_VALIDATION_FAILED: 'path_validation_failed',
5
+ DIR_NOT_EMPTY: 'dir_not_empty',
6
+ TIMEOUT: 'timeout',
7
+ DISK_FULL: 'disk_full',
8
+ READ_ONLY: 'read_only',
9
+ CONFLICT: 'conflict',
10
+ POLICY_SKIPPED: 'policy_skipped',
11
+ UNKNOWN: 'unknown',
12
+ };
13
+
14
+ const ERROR_TYPE_LABELS = {
15
+ [ERROR_TYPES.PERMISSION_DENIED]: '权限不足',
16
+ [ERROR_TYPES.PATH_NOT_FOUND]: '路径不存在',
17
+ [ERROR_TYPES.PATH_VALIDATION_FAILED]: '路径校验失败',
18
+ [ERROR_TYPES.DIR_NOT_EMPTY]: '目录非空',
19
+ [ERROR_TYPES.TIMEOUT]: '执行超时',
20
+ [ERROR_TYPES.DISK_FULL]: '磁盘空间不足',
21
+ [ERROR_TYPES.READ_ONLY]: '只读目录',
22
+ [ERROR_TYPES.CONFLICT]: '路径冲突',
23
+ [ERROR_TYPES.POLICY_SKIPPED]: '策略跳过',
24
+ [ERROR_TYPES.UNKNOWN]: '其他错误',
25
+ };
26
+
27
+ export function errorTypeToLabel(errorType) {
28
+ return ERROR_TYPE_LABELS[String(errorType || '')] || ERROR_TYPE_LABELS[ERROR_TYPES.UNKNOWN];
29
+ }
30
+
31
+ export function classifyErrorType(message) {
32
+ const text = String(message || '').toLowerCase();
33
+ if (!text) {
34
+ return ERROR_TYPES.UNKNOWN;
35
+ }
36
+ if (
37
+ text.includes('eacces') ||
38
+ text.includes('eperm') ||
39
+ text.includes('operation not permitted') ||
40
+ text.includes('permission denied')
41
+ ) {
42
+ return ERROR_TYPES.PERMISSION_DENIED;
43
+ }
44
+ if (
45
+ text.includes('enoent') ||
46
+ text.includes('enotdir') ||
47
+ text.includes('not found') ||
48
+ text.includes('no such file')
49
+ ) {
50
+ return ERROR_TYPES.PATH_NOT_FOUND;
51
+ }
52
+ if (
53
+ text.includes('invalid') ||
54
+ text.includes('illegal') ||
55
+ text.includes('outside') ||
56
+ text.includes('escape')
57
+ ) {
58
+ return ERROR_TYPES.PATH_VALIDATION_FAILED;
59
+ }
60
+ if (text.includes('enotempty')) {
61
+ return ERROR_TYPES.DIR_NOT_EMPTY;
62
+ }
63
+ if (text.includes('timeout')) {
64
+ return ERROR_TYPES.TIMEOUT;
65
+ }
66
+ if (text.includes('enospc') || text.includes('no space')) {
67
+ return ERROR_TYPES.DISK_FULL;
68
+ }
69
+ if (text.includes('read-only') || text.includes('readonly') || text.includes('erofs')) {
70
+ return ERROR_TYPES.READ_ONLY;
71
+ }
72
+ return ERROR_TYPES.UNKNOWN;
73
+ }
package/src/lock.js ADDED
@@ -0,0 +1,102 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const LOCK_FILE_NAME = '.wecom-cleaner.lock';
6
+
7
+ export class LockHeldError extends Error {
8
+ constructor(message, options = {}) {
9
+ super(message);
10
+ this.name = 'LockHeldError';
11
+ this.lockPath = options.lockPath || null;
12
+ this.lockInfo = options.lockInfo || null;
13
+ this.isStale = Boolean(options.isStale);
14
+ }
15
+ }
16
+
17
+ function isValidPid(pid) {
18
+ return Number.isInteger(pid) && pid > 0;
19
+ }
20
+
21
+ function isProcessRunning(pid) {
22
+ if (!isValidPid(pid)) {
23
+ return false;
24
+ }
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (error) {
29
+ if (error && error.code === 'ESRCH') {
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ }
35
+
36
+ async function readLockInfo(lockPath) {
37
+ try {
38
+ const raw = await fs.readFile(lockPath, 'utf-8');
39
+ const parsed = JSON.parse(raw);
40
+ if (!parsed || typeof parsed !== 'object') {
41
+ return null;
42
+ }
43
+ return parsed;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function writeLockFile(lockPath, payload) {
50
+ const handle = await fs.open(lockPath, 'wx');
51
+ try {
52
+ await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
53
+ } finally {
54
+ await handle.close().catch(() => {});
55
+ }
56
+ }
57
+
58
+ export function resolveLockPath(stateRoot) {
59
+ return path.join(path.resolve(String(stateRoot || '.')), LOCK_FILE_NAME);
60
+ }
61
+
62
+ export async function breakLock(lockPath) {
63
+ await fs.rm(lockPath, { force: true });
64
+ }
65
+
66
+ export async function acquireLock(stateRoot, mode) {
67
+ const lockPath = resolveLockPath(stateRoot);
68
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
69
+
70
+ const payload = {
71
+ pid: process.pid,
72
+ mode: String(mode || 'unknown'),
73
+ startedAt: Date.now(),
74
+ hostname: os.hostname(),
75
+ version: process.env.npm_package_version || null,
76
+ };
77
+
78
+ try {
79
+ await writeLockFile(lockPath, payload);
80
+ } catch (error) {
81
+ if (error && error.code !== 'EEXIST') {
82
+ throw error;
83
+ }
84
+
85
+ const lockInfo = await readLockInfo(lockPath);
86
+ const lockPid = Number.parseInt(String(lockInfo?.pid || ''), 10);
87
+ const isStale = !isProcessRunning(lockPid);
88
+ throw new LockHeldError('检测到另一个实例正在运行', {
89
+ lockPath,
90
+ lockInfo,
91
+ isStale,
92
+ });
93
+ }
94
+
95
+ return {
96
+ lockPath,
97
+ lockInfo: payload,
98
+ async release() {
99
+ await fs.rm(lockPath, { force: true }).catch(() => {});
100
+ },
101
+ };
102
+ }