dsh-config-manager 0.1.39 → 0.1.41

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.
Files changed (50) hide show
  1. package/lib/client.d.ts +90 -8
  2. package/lib/client.js +1904 -1086
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/cache-cleaner.d.ts +39 -0
  5. package/lib/core/cache-cleaner.js +164 -0
  6. package/lib/core/cache-cleaner.js.map +1 -0
  7. package/lib/index.js +172 -3
  8. package/lib/index.js.map +1 -1
  9. package/lib/market/github-repos.d.ts +19 -8
  10. package/lib/market/github-repos.js +62 -33
  11. package/lib/market/github-repos.js.map +1 -1
  12. package/lib/market/my-repo.d.ts +111 -3
  13. package/lib/market/my-repo.js +309 -26
  14. package/lib/market/my-repo.js.map +1 -1
  15. package/lib/market/prepare.js +3 -2
  16. package/lib/market/prepare.js.map +1 -1
  17. package/lib/market/types.d.ts +2 -0
  18. package/lib/security/secret-scanner.d.ts +18 -3
  19. package/lib/security/secret-scanner.js +144 -20
  20. package/lib/security/secret-scanner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/client/common/ConfirmDialog.tsx +135 -0
  23. package/src/client/config-manager.module.css +101 -0
  24. package/src/client/market/MarketPanel.tsx +138 -45
  25. package/src/client/market/MyConfigsView.tsx +515 -132
  26. package/src/client/market/disclaimer.test.ts +57 -0
  27. package/src/client/market/disclaimer.ts +43 -0
  28. package/src/client/market/market-api.test.ts +3 -0
  29. package/src/client/market/market-locales.ts +37 -13
  30. package/src/client/market/my-configs-api.ts +20 -2
  31. package/src/client/market/my-configs-view.test.ts +137 -2
  32. package/src/client/market/my-configs-view.ts +174 -2
  33. package/src/client/run-store.test.ts +40 -2
  34. package/src/client/run-store.ts +70 -10
  35. package/src/client/sync/SyncConfirmView.tsx +28 -22
  36. package/src/client/sync/SyncSettingsView.tsx +175 -65
  37. package/src/client/sync/sync-api.ts +19 -0
  38. package/src/client/sync/sync-locales.ts +14 -2
  39. package/src/core/cache-cleaner.test.ts +333 -0
  40. package/src/core/cache-cleaner.ts +196 -0
  41. package/src/index.ts +165 -5
  42. package/src/market/github-repos.test.ts +99 -8
  43. package/src/market/github-repos.ts +67 -35
  44. package/src/market/my-repo.test.ts +218 -27
  45. package/src/market/my-repo.ts +403 -30
  46. package/src/market/prepare.test.ts +71 -1
  47. package/src/market/prepare.ts +3 -2
  48. package/src/market/types.ts +2 -0
  49. package/src/security/secret-scanner.ts +139 -22
  50. package/src/security/security.test.ts +111 -2
@@ -0,0 +1,333 @@
1
+ /**
2
+ * cache-cleaner 测试:缓存自动清理的保留期 / 白名单边界 / 容错。
3
+ * 使用真实临时目录(node:os tmpdir)+ node:fs 真实读写(与 plugin-cli.fs.test.ts 同模式)。
4
+ * 覆盖:
5
+ * - tmp:过期 .zip 删、新 .zip 留、非 zip 文件留、dsh-sync-pull-* 目录超期删
6
+ * - exports:过期导出 zip 删、新导出 zip 留、非 zip 文件留
7
+ * - market/cache:过期 index.json / items 条目删;新条目留;删空后回收 items/hash 目录
8
+ * - market/work:过期 git 副本删、新副本留
9
+ * - 保留期边界(恰好等于保留期 → 不删)、目录不存在 → 不抛错
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs/promises';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ import {
18
+ cleanupCaches,
19
+ TMP_RETENTION_DEFAULT_MS,
20
+ EXPORTS_RETENTION_DEFAULT_MS,
21
+ MARKET_RETENTION_DEFAULT_MS,
22
+ } from './cache-cleaner.ts';
23
+
24
+ /** 建独立临时数据目录,返回 { root, tmpDir, exportsDir, marketCacheRoot, marketWorkRoot, cleanup } */
25
+ async function makeDataDir(): Promise<{
26
+ root: string;
27
+ tmpDir: string;
28
+ exportsDir: string;
29
+ marketCacheRoot: string;
30
+ marketWorkRoot: string;
31
+ cleanup: () => Promise<void>;
32
+ }> {
33
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-cache-'));
34
+ const tmpDir = path.join(root, 'tmp');
35
+ const exportsDir = path.join(root, 'exports');
36
+ const marketCacheRoot = path.join(root, 'market', 'cache');
37
+ const marketWorkRoot = path.join(root, 'market', 'work');
38
+ await fs.mkdir(tmpDir, { recursive: true });
39
+ await fs.mkdir(exportsDir, { recursive: true });
40
+ await fs.mkdir(marketCacheRoot, { recursive: true });
41
+ await fs.mkdir(marketWorkRoot, { recursive: true });
42
+ return {
43
+ root,
44
+ tmpDir,
45
+ exportsDir,
46
+ marketCacheRoot,
47
+ marketWorkRoot,
48
+ cleanup: () => fs.rm(root, { recursive: true, force: true }),
49
+ };
50
+ }
51
+
52
+ /** 把目标 mtime 拨到指定毫秒(Windows 精度足够,测试用秒级偏移) */
53
+ async function touch(p: string, mtimeMs: number): Promise<void> {
54
+ const st = await fs.stat(p);
55
+ await fs.utimes(p, st.atimeMs ? new Date(st.atimeMs) : new Date(), new Date(mtimeMs));
56
+ }
57
+
58
+ test('tmp:过期 .zip 删、新 .zip 留、非 zip 文件留', async () => {
59
+ const d = await makeDataDir();
60
+ try {
61
+ const now = Date.now();
62
+ const oldZip = path.join(d.tmpDir, 'upload-old.zip');
63
+ const newZip = path.join(d.tmpDir, 'upload-new.zip');
64
+ const txt = path.join(d.tmpDir, 'notes.txt');
65
+ await fs.writeFile(oldZip, Buffer.alloc(10));
66
+ await fs.writeFile(newZip, Buffer.alloc(20));
67
+ await fs.writeFile(txt, 'keep me');
68
+ await touch(oldZip, now - TMP_RETENTION_DEFAULT_MS - 1000); // 超期 1s
69
+ await touch(newZip, now - 1000); // 新
70
+
71
+ const report = await cleanupCaches({
72
+ tmpDir: d.tmpDir,
73
+ exportsDir: d.exportsDir,
74
+ marketCacheRoot: d.marketCacheRoot,
75
+ marketWorkRoot: d.marketWorkRoot,
76
+ now: () => now,
77
+ });
78
+
79
+ assert.equal(report.removed, 1, '只删超期 zip');
80
+ assert.equal(report.freedBytes, 10, '释放字节 = 被删文件 size');
81
+ assert.equal(await fs.readFile(newZip).then((b) => b.length), 20, '新 zip 保留');
82
+ assert.equal(await fs.readFile(txt, 'utf8'), 'keep me', '非 zip 文件保留');
83
+ await assert.rejects(() => fs.stat(oldZip), '超期 zip 已删');
84
+ assert.ok(report.detail.some((s) => s.includes('upload-old.zip')), 'detail 含删除记录');
85
+ } finally {
86
+ await d.cleanup();
87
+ }
88
+ });
89
+
90
+ test('tmp:dsh-sync-pull-* 临时目录超期删、新目录留', async () => {
91
+ const d = await makeDataDir();
92
+ try {
93
+ const now = Date.now();
94
+ const oldDir = path.join(d.tmpDir, 'dsh-sync-pull-abc');
95
+ const newDir = path.join(d.tmpDir, 'dsh-sync-pull-def');
96
+ const otherDir = path.join(d.tmpDir, 'keep-dir');
97
+ await fs.mkdir(path.join(oldDir, 'inner'), { recursive: true });
98
+ await fs.mkdir(path.join(newDir, 'inner'), { recursive: true });
99
+ await fs.mkdir(otherDir, { recursive: true });
100
+ await fs.writeFile(path.join(oldDir, 'inner', 'snapshot.zip'), Buffer.alloc(5));
101
+ await touch(oldDir, now - TMP_RETENTION_DEFAULT_MS - 5000);
102
+ await touch(newDir, now - 1000);
103
+
104
+ const report = await cleanupCaches({
105
+ tmpDir: d.tmpDir,
106
+ exportsDir: d.exportsDir,
107
+ marketCacheRoot: d.marketCacheRoot,
108
+ marketWorkRoot: d.marketWorkRoot,
109
+ now: () => now,
110
+ });
111
+
112
+ assert.equal(report.removed, 1, '只删超期 sync 临时目录');
113
+ await assert.rejects(() => fs.stat(oldDir), '超期 sync 目录整棵已删');
114
+ assert.ok(await fs.stat(newDir), '新 sync 目录保留');
115
+ assert.ok(await fs.stat(otherDir), '无关目录保留');
116
+ } finally {
117
+ await d.cleanup();
118
+ }
119
+ });
120
+
121
+ test('exports:过期导出 zip 删、新导出 zip 留、非 zip 文件留', async () => {
122
+ const d = await makeDataDir();
123
+ try {
124
+ const now = Date.now();
125
+ const oldExport = path.join(d.exportsDir, 'dsh-config-old.zip');
126
+ const newExport = path.join(d.exportsDir, 'dsh-config-new.zip');
127
+ const readme = path.join(d.exportsDir, 'readme.txt');
128
+ await fs.writeFile(oldExport, Buffer.alloc(15));
129
+ await fs.writeFile(newExport, Buffer.alloc(25));
130
+ await fs.writeFile(readme, 'keep');
131
+ await touch(oldExport, now - EXPORTS_RETENTION_DEFAULT_MS - 1000); // 超期 1s
132
+ await touch(newExport, now - 1000); // 新
133
+
134
+ const report = await cleanupCaches({
135
+ tmpDir: d.tmpDir,
136
+ exportsDir: d.exportsDir,
137
+ marketCacheRoot: d.marketCacheRoot,
138
+ marketWorkRoot: d.marketWorkRoot,
139
+ now: () => now,
140
+ });
141
+
142
+ assert.equal(report.removed, 1, '只删超期导出 zip');
143
+ assert.equal(report.freedBytes, 15, '释放字节 = 被删文件 size');
144
+ await assert.rejects(() => fs.stat(oldExport), '超期导出 zip 已删');
145
+ assert.ok(await fs.stat(newExport), '新导出 zip 保留');
146
+ assert.equal(await fs.readFile(readme, 'utf8'), 'keep', '非 zip 文件保留');
147
+ assert.ok(report.detail.some((s) => s.includes('exports/')), 'detail 含 exports 删除记录');
148
+ } finally {
149
+ await d.cleanup();
150
+ }
151
+ });
152
+
153
+ test('market/cache:全过期 → 条目、items 目录、hash 目录依次回收', async () => {
154
+ const d = await makeDataDir();
155
+ try {
156
+ const now = Date.now();
157
+ const hashDir = path.join(d.marketCacheRoot, 'hash1');
158
+ const itemsDir = path.join(hashDir, 'items');
159
+ const oldItem = path.join(itemsDir, 'old-item');
160
+ const index = path.join(hashDir, 'index.json');
161
+ await fs.mkdir(oldItem, { recursive: true });
162
+ await fs.writeFile(path.join(oldItem, 'config.zip'), Buffer.alloc(7));
163
+ await fs.writeFile(index, '{}');
164
+ await touch(oldItem, now - MARKET_RETENTION_DEFAULT_MS - 1000);
165
+ await touch(index, now - MARKET_RETENTION_DEFAULT_MS - 1000);
166
+
167
+ const report = await cleanupCaches({
168
+ tmpDir: d.tmpDir,
169
+ exportsDir: d.exportsDir,
170
+ marketCacheRoot: d.marketCacheRoot,
171
+ marketWorkRoot: d.marketWorkRoot,
172
+ now: () => now,
173
+ });
174
+
175
+ // 删除:index.json + 条目 + 空 items 目录 + 空 hash 目录
176
+ assert.equal(report.removed, 4, 'index、条目、items 目录、hash 目录依次回收');
177
+ await assert.rejects(() => fs.stat(oldItem), '过期条目已删');
178
+ await assert.rejects(() => fs.stat(index), '过期 index.json 已删');
179
+ await assert.rejects(() => fs.stat(itemsDir), 'items 目录删空后已回收');
180
+ await assert.rejects(() => fs.stat(hashDir), 'hash 目录删空后已回收');
181
+ assert.ok(await fs.stat(d.marketCacheRoot), 'market/cache 根保留');
182
+ } finally {
183
+ await d.cleanup();
184
+ }
185
+ });
186
+
187
+ test('market/cache:有过期条目但 index.json 保留(未过期)时 hash 目录不被回收', async () => {
188
+ const d = await makeDataDir();
189
+ try {
190
+ const now = Date.now();
191
+ const hashDir = path.join(d.marketCacheRoot, 'hash2');
192
+ const itemsDir = path.join(hashDir, 'items');
193
+ const oldItem = path.join(itemsDir, 'old-item');
194
+ await fs.mkdir(oldItem, { recursive: true });
195
+ await fs.writeFile(path.join(hashDir, 'index.json'), '{}');
196
+ await touch(oldItem, now - MARKET_RETENTION_DEFAULT_MS - 1000);
197
+
198
+ const report = await cleanupCaches({
199
+ tmpDir: d.tmpDir,
200
+ exportsDir: d.exportsDir,
201
+ marketCacheRoot: d.marketCacheRoot,
202
+ marketWorkRoot: d.marketWorkRoot,
203
+ now: () => now,
204
+ });
205
+
206
+ assert.equal(report.removed, 2, '条目 + 空 items 目录被回收');
207
+ assert.ok(await fs.stat(hashDir), 'index.json 未过期 → hash 目录保留');
208
+ assert.ok(await fs.stat(path.join(hashDir, 'index.json')), 'index.json 保留');
209
+ } finally {
210
+ await d.cleanup();
211
+ }
212
+ });
213
+
214
+ test('market/cache:有新条目时 items/hash 目录不被回收,新条目保留', async () => {
215
+ const d = await makeDataDir();
216
+ try {
217
+ const now = Date.now();
218
+ const hashDir = path.join(d.marketCacheRoot, 'hash3');
219
+ const itemsDir = path.join(hashDir, 'items');
220
+ const oldItem = path.join(itemsDir, 'old-item');
221
+ const newItem = path.join(itemsDir, 'new-item');
222
+ await fs.mkdir(oldItem, { recursive: true });
223
+ await fs.mkdir(newItem, { recursive: true });
224
+ await touch(oldItem, now - MARKET_RETENTION_DEFAULT_MS - 1000);
225
+
226
+ const report = await cleanupCaches({
227
+ tmpDir: d.tmpDir,
228
+ exportsDir: d.exportsDir,
229
+ marketCacheRoot: d.marketCacheRoot,
230
+ marketWorkRoot: d.marketWorkRoot,
231
+ now: () => now,
232
+ });
233
+
234
+ assert.equal(report.removed, 1, '只删过期条目,items/hash 目录因新条目保留');
235
+ await assert.rejects(() => fs.stat(oldItem), '过期条目已删');
236
+ assert.ok(await fs.stat(newItem), '新条目保留');
237
+ assert.ok(await fs.stat(itemsDir), 'items 目录保留(仍有新条目)');
238
+ assert.ok(await fs.stat(hashDir), 'hash 目录保留');
239
+ } finally {
240
+ await d.cleanup();
241
+ }
242
+ });
243
+
244
+ test('market/work:过期 git 副本删、新副本留', async () => {
245
+ const d = await makeDataDir();
246
+ try {
247
+ const now = Date.now();
248
+ const oldWork = path.join(d.marketWorkRoot, 'oldhash');
249
+ const newWork = path.join(d.marketWorkRoot, 'newhash');
250
+ await fs.mkdir(path.join(oldWork, '.git'), { recursive: true });
251
+ await fs.mkdir(path.join(newWork, '.git'), { recursive: true });
252
+ await touch(oldWork, now - MARKET_RETENTION_DEFAULT_MS - 1000);
253
+ await touch(newWork, now - 1000);
254
+
255
+ const report = await cleanupCaches({
256
+ tmpDir: d.tmpDir,
257
+ exportsDir: d.exportsDir,
258
+ marketCacheRoot: d.marketCacheRoot,
259
+ marketWorkRoot: d.marketWorkRoot,
260
+ now: () => now,
261
+ });
262
+
263
+ assert.equal(report.removed, 1, '只删过期 git 副本');
264
+ await assert.rejects(() => fs.stat(oldWork), '过期副本整棵已删');
265
+ assert.ok(await fs.stat(newWork), '新副本保留');
266
+ } finally {
267
+ await d.cleanup();
268
+ }
269
+ });
270
+
271
+ test('保留期边界:恰好等于保留期 → 不删(超期判定为严格大于)', async () => {
272
+ const d = await makeDataDir();
273
+ try {
274
+ const now = Date.now();
275
+ const zip = path.join(d.tmpDir, 'upload-boundary.zip');
276
+ await fs.writeFile(zip, Buffer.alloc(3));
277
+ await touch(zip, now - TMP_RETENTION_DEFAULT_MS); // 恰好 = 保留期
278
+
279
+ const report = await cleanupCaches({
280
+ tmpDir: d.tmpDir,
281
+ exportsDir: d.exportsDir,
282
+ marketCacheRoot: d.marketCacheRoot,
283
+ marketWorkRoot: d.marketWorkRoot,
284
+ now: () => now,
285
+ });
286
+
287
+ assert.equal(report.removed, 0, '边界文件不删');
288
+ assert.ok(await fs.stat(zip), '边界 zip 保留');
289
+ } finally {
290
+ await d.cleanup();
291
+ }
292
+ });
293
+
294
+ test('容错:目录不存在 → 不抛错、removed=0', async () => {
295
+ const d = await makeDataDir();
296
+ try {
297
+ const report = await cleanupCaches({
298
+ tmpDir: path.join(d.root, 'no-such-tmp'),
299
+ exportsDir: path.join(d.root, 'no-such-exports'),
300
+ marketCacheRoot: path.join(d.root, 'no-such-cache'),
301
+ marketWorkRoot: path.join(d.root, 'no-such-work'),
302
+ });
303
+ assert.equal(report.removed, 0);
304
+ assert.equal(report.errors, 4, '四个缺失目录各记一次尽力而为跳过');
305
+ } finally {
306
+ await d.cleanup();
307
+ }
308
+ });
309
+
310
+ test('自定义保留期生效(market 保留期缩短 → 较早条目被删)', async () => {
311
+ const d = await makeDataDir();
312
+ try {
313
+ const now = Date.now();
314
+ const hashDir = path.join(d.marketCacheRoot, 'hash3');
315
+ const itemDir = path.join(hashDir, 'items', 'x');
316
+ await fs.mkdir(itemDir, { recursive: true });
317
+ await touch(itemDir, now - 2 * 60 * 1000); // 2 分钟前
318
+
319
+ const report = await cleanupCaches({
320
+ tmpDir: d.tmpDir,
321
+ exportsDir: d.exportsDir,
322
+ marketCacheRoot: d.marketCacheRoot,
323
+ marketWorkRoot: d.marketWorkRoot,
324
+ marketRetentionMs: 60 * 1000, // 1 分钟保留期
325
+ now: () => now,
326
+ });
327
+
328
+ assert.equal(report.removed, 3, '条目 + items + hash 目录全部回收');
329
+ await assert.rejects(() => fs.stat(hashDir));
330
+ } finally {
331
+ await d.cleanup();
332
+ }
333
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 缓存自动清理(cache-cleaner)
3
+ *
4
+ * 只清理「可重建 / 一次性」的缓存与临时文件,绝不触碰用户数据与安全网:
5
+ * - tmpDir 下过期的 `.zip`(upload-* / market-* / publish-* / decrypted-* /
6
+ * export-plain-* 等导入/导出/市场暂存,以及 SyncEngine 遗留的临时目录
7
+ * dsh-sync-pull-*)——保留期内文件不删(供「刷新恢复导入」等跨请求流程继续消费);
8
+ * - exportsDir 下过期的导出产物 `.zip`(导出时用户已通过浏览器下载/另存到本地,
9
+ * host 端只是暂存副本,按保留期回收);
10
+ * - market/cache/<url-hash>/(市场 index 缓存与条目缓存,refresh/download 可重建);
11
+ * - market/work/<url-hash>/(市场 git 只读工作副本,readIndex 时自动重新 clone)。
12
+ *
13
+ * 保留不动(属用户数据/安全网,由各自 UI 或业务逻辑管理):
14
+ * - snapshots/(导入前强制快照)、sync/(同步配置/历史/git 工作副本)。
15
+ *
16
+ * 实现:与 DSH 运行时解耦的纯函数(node:fs/promises),保留期与时间源参数化,
17
+ * 任何单项失败均不阻断其余清理(尽力而为),返回清理报告供宿主日志。
18
+ * 健康性:删除前按 mtime 判定超期(now - mtime > retention),stat 失败保守不删。
19
+ */
20
+ import fs from 'node:fs/promises'
21
+ import path from 'node:path'
22
+
23
+ /** 临时文件缺省保留期:24 小时(覆盖跨会话「刷新恢复导入」窗口,昨天的残留自动清) */
24
+ export const TMP_RETENTION_DEFAULT_MS = 24 * 60 * 60 * 1000
25
+
26
+ /** 导出产物缺省保留期:7 天(导出时用户已通过浏览器下载/另存到本地,host 端副本按周回收) */
27
+ export const EXPORTS_RETENTION_DEFAULT_MS = 7 * 24 * 60 * 60 * 1000
28
+
29
+ /** 市场缓存/工作副本缺省保留期:7 天(重建成本 = 一次网络拉取) */
30
+ export const MARKET_RETENTION_DEFAULT_MS = 7 * 24 * 60 * 60 * 1000
31
+
32
+ /** SyncEngine 在 zipDir(即 tmpDir)下 mkdtemp 的目录前缀(用完即删,崩溃残留由清理兜底) */
33
+ const SYNC_TMP_DIR_PREFIX = 'dsh-sync-pull-'
34
+
35
+ export interface CacheCleanupOptions {
36
+ /** 临时目录($DSH_HOME/dsh-config-manager/tmp) */
37
+ tmpDir: string
38
+ /** 导出产物目录($DSH_HOME/dsh-config-manager/exports) */
39
+ exportsDir: string
40
+ /** 市场缓存根($DSH_HOME/dsh-config-manager/market/cache) */
41
+ marketCacheRoot: string
42
+ /** 市场工作副本根($DSH_HOME/dsh-config-manager/market/work) */
43
+ marketWorkRoot: string
44
+ /** 临时文件保留期(缺省 24h) */
45
+ tmpRetentionMs?: number
46
+ /** 导出产物保留期(缺省 7 天) */
47
+ exportsRetentionMs?: number
48
+ /** 市场缓存/工作副本保留期(缺省 7 天) */
49
+ marketRetentionMs?: number
50
+ /** 时间源(测试注入;缺省 Date.now) */
51
+ now?: () => number
52
+ }
53
+
54
+ export interface CacheCleanupResult {
55
+ /** 删除条目数(文件 + 目录) */
56
+ removed: number
57
+ /** 释放字节数(仅被删文件的 size 累计;目录删除统计为 0) */
58
+ freedBytes: number
59
+ /** 单项失败数(不影响主流程) */
60
+ errors: number
61
+ /** 每条删除记录(相对 dataDir 的描述 + 字节数),供日志/审计 */
62
+ detail: string[]
63
+ }
64
+
65
+ /** 目录是否超期(stat 失败保守视为未超期 → 不删) */
66
+ async function isExpired(target: string, retentionMs: number, nowMs: number): Promise<boolean> {
67
+ try {
68
+ const st = await fs.stat(target)
69
+ return nowMs - st.mtimeMs > retentionMs
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** 删除一个文件/目录并计入报告;目录的 freedBytes 统计为 0(递归统计不划算,用途仅是日志) */
76
+ async function removeEntry(target: string, label: string, result: CacheCleanupResult): Promise<void> {
77
+ try {
78
+ let size = 0
79
+ try {
80
+ const st = await fs.stat(target)
81
+ size = st.isFile() ? st.size : 0
82
+ } catch {
83
+ /* stat 失败仍尝试删除(rm 自己会兜底不存在) */
84
+ }
85
+ await fs.rm(target, { recursive: true, force: true })
86
+ result.removed += 1
87
+ result.freedBytes += size
88
+ result.detail.push(`${label} (${size} bytes)`)
89
+ } catch {
90
+ result.errors += 1
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 执行一次缓存清理(幂等;可重复调用)。
96
+ * 只清理超期(超过保留期)的缓存/临时条目,其余一律保留。
97
+ */
98
+ export async function cleanupCaches(opts: CacheCleanupOptions): Promise<CacheCleanupResult> {
99
+ const nowMs = (opts.now ?? Date.now)()
100
+ const tmpRetentionMs = opts.tmpRetentionMs ?? TMP_RETENTION_DEFAULT_MS
101
+ const exportsRetentionMs = opts.exportsRetentionMs ?? EXPORTS_RETENTION_DEFAULT_MS
102
+ const marketRetentionMs = opts.marketRetentionMs ?? MARKET_RETENTION_DEFAULT_MS
103
+ const result: CacheCleanupResult = { removed: 0, freedBytes: 0, errors: 0, detail: [] }
104
+
105
+ // 1) tmpDir:过期 .zip(导入/导出/市场/解密暂存)与 SyncEngine 遗留的 dsh-sync-pull-* 目录
106
+ try {
107
+ const entries = await fs.readdir(opts.tmpDir, { withFileTypes: true })
108
+ for (const entry of entries) {
109
+ const target = path.join(opts.tmpDir, entry.name)
110
+ const isTmpish =
111
+ (entry.isFile() && entry.name.endsWith('.zip')) ||
112
+ (entry.isDirectory() && entry.name.startsWith(SYNC_TMP_DIR_PREFIX))
113
+ if (!isTmpish) continue
114
+ if (await isExpired(target, tmpRetentionMs, nowMs)) {
115
+ await removeEntry(target, `tmp/${entry.name}`, result)
116
+ }
117
+ }
118
+ } catch {
119
+ // tmpDir 不存在/不可读 → 跳过(尽力而为)
120
+ result.errors += 1
121
+ }
122
+
123
+ // 2) exportsDir:过期的导出产物 .zip(导出时已下载/另存到本地,host 端副本按保留期回收)
124
+ try {
125
+ const entries = await fs.readdir(opts.exportsDir, { withFileTypes: true })
126
+ for (const entry of entries) {
127
+ if (!entry.isFile() || !entry.name.endsWith('.zip')) continue
128
+ const target = path.join(opts.exportsDir, entry.name)
129
+ if (await isExpired(target, exportsRetentionMs, nowMs)) {
130
+ await removeEntry(target, `exports/${entry.name}`, result)
131
+ }
132
+ }
133
+ } catch {
134
+ // exportsDir 不存在/不可读 → 跳过
135
+ result.errors += 1
136
+ }
137
+
138
+ // 3) market/cache:过期 index.json 与 items/<itemId> 条目缓存;删空后回收 hash 目录
139
+ try {
140
+ const hashes = await fs.readdir(opts.marketCacheRoot)
141
+ for (const hash of hashes) {
142
+ const hashDir = path.join(opts.marketCacheRoot, hash)
143
+ let st
144
+ try {
145
+ st = await fs.stat(hashDir)
146
+ } catch {
147
+ continue // 竞态删除/不可读 → 跳过该 hash
148
+ }
149
+ if (!st.isDirectory()) continue
150
+
151
+ const indexFile = path.join(hashDir, 'index.json')
152
+ if (await isExpired(indexFile, marketRetentionMs, nowMs)) {
153
+ await removeEntry(indexFile, `market/cache/${hash}/index.json`, result)
154
+ }
155
+
156
+ const itemsDir = path.join(hashDir, 'items')
157
+ const itemDirs = await fs.readdir(itemsDir).catch(() => [] as string[])
158
+ for (const itemId of itemDirs) {
159
+ const itemDir = path.join(itemsDir, itemId)
160
+ if (await isExpired(itemDir, marketRetentionMs, nowMs)) {
161
+ await removeEntry(itemDir, `market/cache/${hash}/items/${itemId}`, result)
162
+ }
163
+ }
164
+ // items 子目录删空后回收;hash 目录删空后回收
165
+ const itemsLeft = await fs.readdir(itemsDir).catch(() => [] as string[])
166
+ if (itemsLeft.length === 0) {
167
+ await removeEntry(itemsDir, `market/cache/${hash}/items`, result)
168
+ }
169
+ const remaining = await fs.readdir(hashDir).catch(() => [] as string[])
170
+ if (remaining.length === 0) {
171
+ await removeEntry(hashDir, `market/cache/${hash}`, result)
172
+ }
173
+ }
174
+ } catch {
175
+ // marketCacheRoot 不存在/不可读 → 跳过
176
+ result.errors += 1
177
+ }
178
+
179
+ // 3) market/work:过期 git 只读工作副本(readIndex 时会按需重新 clone)
180
+ try {
181
+ const hashes = await fs.readdir(opts.marketWorkRoot)
182
+ for (const hash of hashes) {
183
+ const workDir = path.join(opts.marketWorkRoot, hash)
184
+ const st = await fs.stat(workDir).catch(() => null)
185
+ if (st === null || !st.isDirectory()) continue
186
+ if (await isExpired(workDir, marketRetentionMs, nowMs)) {
187
+ await removeEntry(workDir, `market/work/${hash}`, result)
188
+ }
189
+ }
190
+ } catch {
191
+ // marketWorkRoot 不存在/不可读 → 跳过
192
+ result.errors += 1
193
+ }
194
+
195
+ return result
196
+ }