dsh-skill-hub 0.3.19 → 0.3.21

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 (51) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/lib/client.js +10 -6
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +428 -156
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/protocol/config.d.ts +17 -11
  9. package/lib/types/protocol/market.d.ts +11 -0
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/protocol.d.ts +1 -1
  12. package/lib/types/reconcile.d.ts +24 -0
  13. package/lib/types/repo/github-client.d.ts +11 -4
  14. package/lib/types/repo.d.ts +1 -1
  15. package/lib/types/routes/deps.d.ts +6 -1
  16. package/lib/types/routes/route-state.d.ts +22 -2
  17. package/lib/types/skillfs/scan.d.ts +32 -5
  18. package/lib/types/skillfs.d.ts +34 -2
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +21 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  24. package/src/client/settings-card.tsx +2 -1
  25. package/src/client/settings-form.ts +6 -3
  26. package/src/index.ts +12 -2
  27. package/src/protocol/config.ts +27 -24
  28. package/src/protocol/market.ts +12 -0
  29. package/src/protocol/sources.ts +5 -0
  30. package/src/protocol.ts +1 -0
  31. package/src/reconcile.test.ts +74 -1
  32. package/src/reconcile.ts +50 -1
  33. package/src/repo/github-client.ts +26 -14
  34. package/src/repo.test.ts +20 -1
  35. package/src/repo.ts +1 -1
  36. package/src/routes/catalog-data.ts +7 -4
  37. package/src/routes/catalog.ts +51 -28
  38. package/src/routes/deps.ts +7 -2
  39. package/src/routes/market.ts +13 -2
  40. package/src/routes/repo-import.ts +17 -30
  41. package/src/routes/route-state.test.ts +4 -4
  42. package/src/routes/route-state.ts +45 -10
  43. package/src/routes/sources.ts +79 -23
  44. package/src/routes.test.ts +300 -0
  45. package/src/skillfs/scan.ts +119 -17
  46. package/src/skillfs.test.ts +64 -3
  47. package/src/skillfs.ts +51 -8
  48. package/src/store/migrate.ts +6 -4
  49. package/src/store/store.ts +21 -11
  50. package/src/store.test.ts +17 -1
  51. package/src/update.ts +6 -6
@@ -3,8 +3,7 @@
3
3
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
4
4
  */
5
5
 
6
- import { mkdir, rename } from 'node:fs/promises'
7
- import { dirname, join } from 'node:path'
6
+ import { mkdir } from 'node:fs/promises'
8
7
  import { randomUUID } from 'node:crypto'
9
8
  import {
10
9
  SKILL_HUB_API,
@@ -28,12 +27,11 @@ import {
28
27
  skillManifest,
29
28
  skillPathIn,
30
29
  } from '../repo.ts'
31
- import { rootPath } from '../skillfs.ts'
30
+ import { occupancyOf, rootPath } from '../skillfs.ts'
32
31
  import { errorText } from '../error-text.ts'
33
32
  import {
34
33
  homeOf,
35
34
  knownSkillNames,
36
- pathExists,
37
35
  queryParam,
38
36
  readString,
39
37
  readStrings,
@@ -42,32 +40,20 @@ import {
42
40
  type RouteSpec,
43
41
  type SkillHubRouteDeps,
44
42
  } from './helpers.ts'
45
- import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
43
+ import { gcImportJobs, importJobs, retireNamePlaceholders, type ImportJob } from './route-state.ts'
46
44
 
47
45
  /**
48
- * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
49
- * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
50
- * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
46
+ * 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
47
+ * 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
48
+ * 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
49
+ * 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
50
+ * 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
51
51
  *
52
- * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
53
- * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
52
+ * 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
54
53
  */
55
54
  async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
56
- const record = await deps.store.getDisabled(name)
57
- if (record === undefined) return false
58
- const dir = join(targetRoot, name)
59
- const sourceToTrash = record.path.endsWith('SKILL.md.disabled') ? dirname(record.path) : record.path
60
- if (sourceToTrash !== dir) return false
61
- // 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
62
- if (await pathExists(join(dir, 'SKILL.md'))) return false
63
- // 与 skill/delete 的回收站语义一致:同级 .trash/ 下按「名字-时间戳」落盘,可恢复。
64
- const trashDir = join(targetRoot, '.trash')
65
- await mkdir(trashDir, { recursive: true })
66
- const target = join(trashDir, name + '-' + Date.now())
67
- await rename(sourceToTrash, target)
68
- await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
69
- await deps.store.removeDisabled(name)
70
- return true
55
+ if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false
56
+ return (await retireNamePlaceholders(deps, targetRoot, name)) !== undefined
71
57
  }
72
58
 
73
59
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
@@ -195,10 +181,11 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
195
181
  job.done += 1
196
182
  continue
197
183
  }
198
- // 磁盘上有同名目录。若它只是一个已禁用的占位(目录里只剩
199
- // SKILL.md.disabled),先把旧目录移入回收站再继续导入;移不动或
200
- // 不是这种形态时退回原本的「跳过」(issue #16)。
201
- if (await pathExists(join(targetRoot, entry.name))) {
184
+ // 磁盘上有同名占位。若它只是一个已禁用的占位(目录包或扁平)或一个空壳
185
+ // 目录,先把旧东西移入回收站再继续导入;有启用的同名技能时退回「跳过」
186
+ // (issue #16)。
187
+ const occupancy = await occupancyOf(targetRoot, entry.name)
188
+ if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
202
189
  let cleared = false
203
190
  try {
204
191
  cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
@@ -211,7 +198,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
211
198
  job.done += 1
212
199
  continue
213
200
  }
214
- // 旧目录已经进了回收站(可恢复)。即使随后的下载失败也必须如实
201
+ // 占位已经进了回收站(可恢复)。即使随后的下载失败也必须如实
215
202
  // 上报,否则用户不知道自己的占位被挪走了。
216
203
  job.replaced.push(entry.name)
217
204
  }
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, describe, expect, it } from 'vitest'
5
- import { IMPORT_JOB_MAX, gcImportJobs, importJobs, replaceSkillDir, type ImportJob } from './route-state.ts'
5
+ import { IMPORT_JOB_MAX, gcImportJobs, importJobs, replaceInPlace, type ImportJob } from './route-state.ts'
6
6
 
7
7
  /** One import job with sane defaults; override what the test cares about. */
8
8
  function job(jobId: string, createdAt: number, status: ImportJob['status'] = 'done'): ImportJob {
@@ -50,7 +50,7 @@ describe('gcImportJobs', () => {
50
50
  })
51
51
  })
52
52
 
53
- describe('replaceSkillDir', () => {
53
+ describe('replaceInPlace', () => {
54
54
  let dir: string
55
55
 
56
56
  afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
@@ -60,7 +60,7 @@ describe('replaceSkillDir', () => {
60
60
  const target = join(dir, 'skill')
61
61
  await mkdir(target, { recursive: true })
62
62
  await writeFile(join(target, 'old.txt'), 'old', 'utf8')
63
- await replaceSkillDir(target, async () => {
63
+ await replaceInPlace(target, async () => {
64
64
  // 下载方负责重建目标目录(downloadRepoSkill 就是这么做的)。
65
65
  await mkdir(target, { recursive: true })
66
66
  await writeFile(join(target, 'new.txt'), 'new', 'utf8')
@@ -76,7 +76,7 @@ describe('replaceSkillDir', () => {
76
76
  const target = join(dir, 'skill')
77
77
  await mkdir(target, { recursive: true })
78
78
  await writeFile(join(target, 'old.txt'), 'old', 'utf8')
79
- await expect(replaceSkillDir(target, async () => { throw new Error('download failed') })).rejects.toThrow('download failed')
79
+ await expect(replaceInPlace(target, async () => { throw new Error('download failed') })).rejects.toThrow('download failed')
80
80
  expect(await readFile(join(target, 'old.txt'), 'utf8')).toBe('old')
81
81
  expect((await readdir(dir)).filter((name) => name.startsWith('.'))).toEqual([])
82
82
  })
@@ -6,6 +6,7 @@
6
6
  import { rm, rename } from 'node:fs/promises'
7
7
  import { basename, dirname, join } from 'node:path'
8
8
  import { errorText } from '../error-text.ts'
9
+ import { moveIntoTrash, occupancyOf, trashSourceOf } from '../skillfs.ts'
9
10
  import type { SkillHubRouteDeps } from './helpers.ts'
10
11
 
11
12
  /** Minimum interval between network update checks per source (GitHub rate limits). */
@@ -79,21 +80,55 @@ export function gcImportJobs(): void {
79
80
  }
80
81
  }
81
82
 
82
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
83
- export async function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void> {
84
- // 点前缀:发现扫描跳过点开头的目录(与导入临时目录 .<name>.import- 同一
85
- // 约定),备份即使删除失败残留下来,也不会混进目录/发现诊断,更不会
86
- // 造成同名技能重复注册。
87
- const backup = join(dirname(targetDir), '.' + basename(targetDir) + '.sync-bak-' + Date.now())
88
- await rename(targetDir, backup)
83
+ /**
84
+ * Replace whatever currently occupies a skill slot with a fresh download;
85
+ * restores the previous state on failure.
86
+ *
87
+ * `target` is the entry to move aside — a bundle **directory** or a flat
88
+ * `<name>.md` file, whichever the download is replacing (the rename/rm below
89
+ * are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
90
+ * so even a failed cleanup can never register as a duplicate skill.
91
+ */
92
+ export async function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void> {
93
+ const backup = join(dirname(target), '.' + basename(target) + '.sync-bak-' + Date.now())
94
+ await rename(target, backup)
89
95
  try {
90
96
  await download()
91
97
  } catch (error) {
92
- await rename(backup, targetDir).catch((rollbackError) => {
93
- // 回滚失败会让技能只剩点前缀的备份目录(发现扫描会跳过它):必须留痕。
94
- console.warn('[dsh-skill-hub] restoring ' + targetDir + ' from ' + backup + ' failed:', errorText(rollbackError))
98
+ await rename(backup, target).catch((rollbackError) => {
99
+ // 回滚失败会让技能只剩点前缀的备份:必须留痕。
100
+ console.warn('[dsh-skill-hub] restoring ' + target + ' from ' + backup + ' failed:', errorText(rollbackError))
95
101
  })
96
102
  throw error
97
103
  }
98
104
  await rm(backup, { recursive: true, force: true })
99
105
  }
106
+
107
+ /**
108
+ * 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
109
+ * 只动这两类,**启用的技能一律不碰**:
110
+ * - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
111
+ * - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
112
+ *
113
+ * 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
114
+ * 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
115
+ *
116
+ * @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
117
+ */
118
+ export async function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined> {
119
+ const occupancy = await occupancyOf(base, name)
120
+ // trashSourceOf 让「目录包禁用件」与「空壳目录」自然去重(同一个目录只搬一次)。
121
+ const sources = [...new Set([
122
+ ...occupancy.disabled.map((path) => trashSourceOf(path)),
123
+ ...(occupancy.bareDir !== undefined ? [occupancy.bareDir] : []),
124
+ ])]
125
+ if (sources.length === 0) return undefined
126
+ const trashed: string[] = []
127
+ for (const source of sources) {
128
+ const moved = await moveIntoTrash(source)
129
+ await deps.store.addTrash({ name, path: moved.path, movedAt: Date.now(), sourcePath: moved.source })
130
+ trashed.push(moved.path)
131
+ }
132
+ await deps.store.removeDisabled(name)
133
+ return trashed
134
+ }
@@ -4,7 +4,7 @@
4
4
  * handler 逻辑不变。
5
5
  */
6
6
 
7
- import { mkdir, rename } from 'node:fs/promises'
7
+ import { mkdir, readdir, rename, rm } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
9
  import { isSkillName } from '@deepseek-ai/dsh-skill'
10
10
  import {
@@ -30,7 +30,7 @@ import {
30
30
  skillFileAt,
31
31
  skillManifest,
32
32
  } from '../repo.ts'
33
- import { clearTrash, restoreSkill, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
33
+ import { clearTrash, occupancyOf, restoreSkill, rootOfPath, rootPath, trashSkill, trashSourceOf, WRITABLE_ROOTS } from '../skillfs.ts'
34
34
  import { errorText } from '../error-text.ts'
35
35
  import {
36
36
  buildCollections,
@@ -43,7 +43,7 @@ import {
43
43
  type RouteSpec,
44
44
  type SkillHubRouteDeps,
45
45
  } from './helpers.ts'
46
- import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceSkillDir } from './route-state.ts'
46
+ import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceInPlace, retireNamePlaceholders } from './route-state.ts'
47
47
 
48
48
  /** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
49
49
  export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
@@ -149,18 +149,28 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
149
149
  const files = collectRepoSkillFiles(tree, entry.dir)
150
150
  if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
151
151
  const targetDir = join(targetRoot, name)
152
- const wasDisabled = (await deps.store.getDisabled(name)) !== undefined
153
- if (await pathExists(targetDir)) {
154
- await replaceSkillDir(targetDir, async () => {
155
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
156
- })
152
+ const record = await deps.store.getDisabled(name)
153
+ const occupancy = await occupancyOf(targetRoot, name)
154
+ // 落盘位置永远是目录包,所以本地无论是目录包还是扁平文件(启用或禁用)
155
+ // 都得先让开,否则同名两形态会并存(issue #16 类)。
156
+ const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
157
+ // 1) 禁用件与空壳让位(顺带清掉 sidecar 禁用记录)。
158
+ await retireNamePlaceholders(deps, targetRoot, name)
159
+ // 2) 启用件按「替换」语义处理:先挪到点前缀备份,下载失败则原样恢复。
160
+ const enabled = occupancy.enabled[0]
161
+ if (enabled !== undefined) {
162
+ await replaceInPlace(trashSourceOf(enabled), download)
157
163
  } else {
158
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
164
+ await download()
159
165
  }
160
- if (wasDisabled) {
166
+ if (record !== undefined) {
161
167
  // Preserve the disabled state: the fresh SKILL.md must not
162
- // re-enter discovery.
163
- await rename(join(targetDir, 'SKILL.md'), join(targetDir, 'SKILL.md.disabled'))
168
+ // re-enter discovery. 记录必须**无条件写回** —— 上面的
169
+ // retireNamePlaceholders 已经把它删过一遍,只按「path 变了才写」
170
+ // 判断会让「同步一个已禁用的技能」丢掉禁用状态与记录(活体实测过)。
171
+ const disabledPath = join(targetDir, 'SKILL.md.disabled')
172
+ await rename(join(targetDir, 'SKILL.md'), disabledPath)
173
+ await deps.store.addDisabled({ ...record, path: disabledPath })
164
174
  } else {
165
175
  await deps.store.removeDisabled(name)
166
176
  }
@@ -206,24 +216,29 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
206
216
  failed.push({ name, error: 'skill is not tracked by this source' })
207
217
  continue
208
218
  }
209
- const sourcePath = join(rootPath('user-dsh', home), name)
210
- if (rootOfPath(sourcePath, home) === undefined) {
211
- failed.push({ name, error: 'skill path is outside the hub writable roots' })
219
+ // Resolve the on-disk occupation instead of assuming a `<root>/<name>`
220
+ // directory: the tracked skill can equally be a flat `<name>.md` file
221
+ // or a hub-disabled placeholder (issue #16 类).
222
+ const base = rootPath('user-dsh', home)
223
+ const occupancy = await occupancyOf(base, name)
224
+ const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir
225
+ if (existing === undefined) {
226
+ failed.push({ name, error: 'skill not found on disk' })
212
227
  continue
213
228
  }
214
- if (!await pathExists(sourcePath)) {
215
- failed.push({ name, error: 'skill directory not found' })
229
+ if (rootOfPath(existing, home) === undefined) {
230
+ failed.push({ name, error: 'skill path is outside the hub writable roots' })
216
231
  continue
217
232
  }
218
233
  try {
219
234
  // 入回收站前快照来源与场景归属,恢复时挂回(见 sourceRestore)。
220
235
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
221
- const { path } = await trashSkill(sourcePath)
236
+ const { path, source: trashSource } = await trashSkill(existing)
222
237
  await deps.store.addTrash({
223
238
  name,
224
239
  path,
225
240
  movedAt: Date.now(),
226
- sourcePath,
241
+ sourcePath: trashSource,
227
242
  origin: { repo: source.repo, root: source.root, ...(source.ref !== undefined && source.ref !== '' ? { ref: source.ref } : {}), commitSha: source.commitSha },
228
243
  ...(tagIds.length > 0 ? { tagIds } : {}),
229
244
  })
@@ -267,7 +282,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
267
282
  for (const tagId of entry.tagIds ?? []) {
268
283
  await deps.store.addSkillToTag(tagId, name)
269
284
  }
270
- await deps.store.removeTrash(name)
285
+ await deps.store.removeTrash(entry.path)
271
286
  deps.invalidate?.()
272
287
  writeJson(res, 200, { ok: true, name, path } satisfies SourceRestoreResponse)
273
288
  },
@@ -281,6 +296,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
281
296
  const home = homeOf(deps)
282
297
  const entries = await deps.store.listTrash()
283
298
  const deleted: string[] = []
299
+ const removedPaths: string[] = []
284
300
  const failed: Array<{ name: string; error: string }> = []
285
301
  for (const entry of entries) {
286
302
  try {
@@ -288,14 +304,54 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
288
304
  // A permanently deleted skill must not linger in user groups.
289
305
  await deps.store.removeSkillFromTags(entry.name)
290
306
  deleted.push(entry.name)
307
+ removedPaths.push(entry.path)
291
308
  } catch (error) {
292
309
  failed.push({ name: entry.name, error: errorText(error) })
293
310
  }
294
311
  }
295
- for (const name of deleted) await deps.store.removeTrash(name)
296
- if (deleted.length > 0) deps.invalidate?.()
297
- writeJson(res, 200, { ok: true, deleted, failed } satisfies SourceTrashClearResponse)
312
+ for (const path of removedPaths) await deps.store.removeTrash(path)
313
+ // 回收站记录按名字索引:同名技能先后两次入站时旧记录被覆盖,但它的
314
+ // `<name>-<时间戳>` 目录还留在盘上,任何记录都指不到它 → 永久孤儿。
315
+ // 「清空回收站」正是它的回收时机,顺手扫掉并如实上报。
316
+ const orphans = await sweepOrphanTrash(deps, home, failed)
317
+ if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.()
318
+ writeJson(res, 200, {
319
+ ok: true,
320
+ deleted,
321
+ failed,
322
+ ...(orphans.length > 0 ? { orphans } : {}),
323
+ } satisfies SourceTrashClearResponse)
298
324
  },
299
325
  },
300
326
  ]
301
327
  }
328
+
329
+ /**
330
+ * 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
331
+ * 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
332
+ */
333
+ async function sweepOrphanTrash(deps: SkillHubRouteDeps, home: string, failed: Array<{ name: string; error: string }>): Promise<string[]> {
334
+ const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path))
335
+ const orphans: string[] = []
336
+ for (const root of WRITABLE_ROOTS) {
337
+ const trashDir = join(rootPath(root, home), '.trash')
338
+ let names: string[]
339
+ try {
340
+ names = await readdir(trashDir)
341
+ } catch {
342
+ continue // 该根没有回收站目录
343
+ }
344
+ for (const name of names) {
345
+ if (name.startsWith('.')) continue
346
+ const full = join(trashDir, name)
347
+ if (referenced.has(full)) continue
348
+ try {
349
+ await rm(full, { recursive: true, force: true })
350
+ orphans.push(full)
351
+ } catch (error) {
352
+ failed.push({ name, error: errorText(error) })
353
+ }
354
+ }
355
+ }
356
+ return orphans
357
+ }