dsh-skill-hub 0.3.20 → 0.3.22

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 (42) hide show
  1. package/CONTRIBUTING.md +2 -2
  2. package/README.md +5 -4
  3. package/README.zh.md +5 -4
  4. package/lib/client.js +2 -2
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +362 -74
  7. package/lib/types/client/locales/settings.d.ts +1 -1
  8. package/lib/types/client/locales.d.ts +1 -1
  9. package/lib/types/protocol/config.d.ts +1 -1
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/reconcile.d.ts +24 -0
  12. package/lib/types/routes/deps.d.ts +8 -1
  13. package/lib/types/routes/helpers.d.ts +11 -6
  14. package/lib/types/routes/http.d.ts +27 -2
  15. package/lib/types/routes/route-state.d.ts +22 -2
  16. package/lib/types/routes.d.ts +29 -4
  17. package/lib/types/skillfs/scan.d.ts +26 -0
  18. package/lib/types/skillfs.d.ts +9 -1
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +1 -1
  22. package/src/client/locales/settings.ts +2 -2
  23. package/src/index.ts +26 -4
  24. package/src/protocol/config.ts +1 -1
  25. package/src/protocol/sources.ts +5 -0
  26. package/src/reconcile.test.ts +74 -1
  27. package/src/reconcile.ts +50 -1
  28. package/src/routes/catalog.ts +50 -22
  29. package/src/routes/deps.ts +8 -2
  30. package/src/routes/helpers.ts +18 -7
  31. package/src/routes/http.ts +31 -2
  32. package/src/routes/repo-import.ts +16 -26
  33. package/src/routes/route-state.test.ts +4 -4
  34. package/src/routes/route-state.ts +45 -10
  35. package/src/routes/sources.ts +79 -23
  36. package/src/routes.test.ts +302 -4
  37. package/src/routes.ts +49 -5
  38. package/src/skillfs/scan.ts +91 -2
  39. package/src/skillfs.ts +14 -2
  40. package/src/store/migrate.ts +6 -4
  41. package/src/store/store.ts +21 -11
  42. package/src/store.test.ts +17 -1
package/src/reconcile.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { readFile, stat } from 'node:fs/promises'
14
14
  import type { DisabledSkill } from './protocol.ts'
15
- import { parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
15
+ import { disabledOccupancyIn, parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
16
16
  import { dshHome } from './store.ts'
17
17
 
18
18
  /** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
@@ -21,6 +21,11 @@ export interface DisabledReconcileStore {
21
21
  addDisabled(entry: DisabledSkill): Promise<void>
22
22
  }
23
23
 
24
+ /** Narrow store view used by the reverse reconcile (adds record removal). */
25
+ export interface DisabledPruneStore extends DisabledReconcileStore {
26
+ removeDisabled(name: string): Promise<void>
27
+ }
28
+
24
29
  /**
25
30
  * Add sidecar disabled records for every `.disabled` discovery file that has
26
31
  * none yet. Existing records (matched by path or by name) win, so this never
@@ -60,3 +65,47 @@ export async function reconcileDisabledSkills(store: DisabledReconcileStore, hom
60
65
  }
61
66
  return added
62
67
  }
68
+
69
+ /** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
70
+ export interface DisabledPruneResult {
71
+ /** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
72
+ repaired: DisabledSkill[]
73
+ /** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
74
+ removed: string[]
75
+ }
76
+
77
+ /**
78
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
79
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
80
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
81
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
82
+ *
83
+ * 两种处理,都不动启用态技能:
84
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
85
+ * - 一个都没有 → 删记录,把名字还给用户。
86
+ *
87
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
88
+ */
89
+ export async function pruneStaleDisabledSkills(store: DisabledPruneStore, home = dshHome()): Promise<DisabledPruneResult> {
90
+ const result: DisabledPruneResult = { repaired: [], removed: [] }
91
+ for (const record of await store.listDisabled()) {
92
+ let exists = false
93
+ try {
94
+ exists = (await stat(record.path)).isFile()
95
+ } catch {
96
+ exists = false
97
+ }
98
+ if (exists) continue
99
+ if (!WRITABLE_ROOTS.includes(record.root)) continue
100
+ const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name)
101
+ if (occupancy === undefined) {
102
+ await store.removeDisabled(record.name)
103
+ result.removed.push(record.name)
104
+ continue
105
+ }
106
+ const repaired: DisabledSkill = { ...record, path: occupancy }
107
+ await store.addDisabled(repaired)
108
+ result.repaired.push(repaired)
109
+ }
110
+ return result
111
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readFile, stat } from 'node:fs/promises'
7
- import { dirname, join } from 'node:path'
7
+ import { dirname } from 'node:path'
8
8
  import type { SkillDefinition } from '@deepseek-ai/dsh-skill'
9
9
  import { isSkillName } from '@deepseek-ai/dsh-skill'
10
10
  import {
@@ -18,7 +18,7 @@ import {
18
18
  type ToggleResponse,
19
19
  type WritableRoot,
20
20
  } from '../protocol.ts'
21
- import { createSkill, disableSkill, enableSkill, moveIntoTrash, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
21
+ import { createSkill, disableSkill, disabledOccupancyIn, enableSkill, occupancyOf, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
22
22
  import { errorText } from '../error-text.ts'
23
23
  import {
24
24
  applyInterface,
@@ -146,18 +146,27 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
146
146
  // 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
147
147
  let trashResult: { path: string; source: string } | null = null
148
148
  if (!resolved.ok) {
149
- const disabled = await deps.store.getDisabled(name)
150
- if (disabled !== undefined) {
151
- // sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
152
- // 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
153
- if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
154
- writeError(res, 409, 'disabled skill path is outside the hub writable roots')
155
- return
156
- }
157
- // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
158
- const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
159
- trashResult = await moveIntoTrash(isBundleDisabled ? dirname(disabled.path) : disabled.path)
160
- } else {
149
+ const disabled = await deps.store.getDisabled(name)
150
+ if (disabled !== undefined) {
151
+ // sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
152
+ // 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
153
+ if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
154
+ writeError(res, 409, 'disabled skill path is outside the hub writable roots')
155
+ return
156
+ }
157
+ // 记录还在、文件已经不在(手工删了 / 从备份恢复过 sidecar):这条记录
158
+ // 只是残留,清掉即算删除完成,否则它会永久占住这个名字。
159
+ if (!await pathExists(disabled.path)) {
160
+ await deps.store.removeDisabled(name)
161
+ await deps.store.removeSkillFromTags(name)
162
+ deps.invalidate?.()
163
+ writeJson(res, 200, { ok: true, name, path: disabled.path } satisfies SkillDeleteResponse)
164
+ return
165
+ }
166
+ // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
167
+ // (trashSkill 认两种发现文件名,目录包按整目录搬)。
168
+ trashResult = await trashSkill(disabled.path)
169
+ } else {
161
170
  writeError(res, resolved.status, resolved.error); return
162
171
  }
163
172
  }
@@ -196,13 +205,28 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
196
205
  if (body.enabled === true) {
197
206
  const record = await deps.store.getDisabled(name)
198
207
  if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
208
+ // 记录里的路径可能已经失效(文件被手工删/搬走)。先在同 root 下找现存的
209
+ // 禁用占位:找得到就把记录指向它,找不到说明这条记录是残留 —— 清掉并把
210
+ // 名字还给用户,而不是让「启用」永远失败把名字锁死。
211
+ let disabledPath = record.path
212
+ if (!await pathExists(disabledPath)) {
213
+ // root 从记录路径反推,不直接信 sidecar 里的 root 字段(可能是损坏数据)。
214
+ const root = rootOfPath(disabledPath, homeOf(deps))
215
+ const occupancy = root === undefined ? undefined : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name)
216
+ if (occupancy === undefined) { await deps.store.removeDisabled(name)
217
+ deps.invalidate?.()
218
+ writeError(res, 409, 'disabled record was stale (its file no longer exists); the record has been removed — retry now')
219
+ return
220
+ }
221
+ disabledPath = occupancy
222
+ }
199
223
  try {
200
- await enableSkill(record.path)
224
+ await enableSkill(disabledPath)
201
225
  } catch (error) {
202
226
  // The renamed file may have vanished (external cleanup); report
203
227
  // precisely instead of a generic 500, like toggle-batch does.
204
228
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
205
- writeError(res, 409, 'disabled skill file is missing on disk: ' + record.path)
229
+ writeError(res, 409, 'disabled skill file is missing on disk: ' + disabledPath)
206
230
  return
207
231
  }
208
232
  throw error
@@ -275,12 +299,16 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
275
299
  const existing = await deps.skills.get(name)
276
300
  if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
277
301
  if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
278
- // A directory may exist without producing a registry entry (invalid
279
- // frontmatter — exactly what the diagnostics section reports).
280
- // Refuse to overwrite it instead of silently truncating its SKILL.md.
281
- const target = join(rootPath(root, homeOf(deps)), name)
282
- if (await pathExists(target)) {
283
- writeError(res, 409, 'skill directory already exists on disk: ' + name + ' (check the discovery diagnostics)')
302
+ // 建档前先看磁盘:三种占位都会让新目录与旧文件并存成同名两形态 ——
303
+ // 已启用的发现文件(frontmatter 坏了因此注册表看不见)、已禁用的占位
304
+ // (目录包或扁平)、以及没有 SKILL.md 的空壳目录(正是发现诊断在报的东西)。
305
+ const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name)
306
+ if (occupancy.enabled.length > 0) {
307
+ writeError(res, 409, 'a skill file already occupies this name on disk: ' + name + ' (check the discovery diagnostics)')
308
+ return
309
+ }
310
+ if (occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
311
+ writeError(res, 409, 'name is occupied by a disabled placeholder or an empty directory on disk: ' + name + ' (check the discovery diagnostics)')
284
312
  return
285
313
  }
286
314
  const path = await createSkill(root, name, readString(body, 'description'), homeOf(deps))
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * routes 共享层 · 依赖视图:路由依赖接口、配置解析与可写技能解析(HTTP
3
- * 围栏在 ./http.ts)。从 helpers.ts 原样搬出,行为不变。
3
+ * 围栏在 ./http.ts)。
4
4
  */
5
5
 
6
6
  import type { ServerResponse } from 'node:http'
@@ -8,7 +8,7 @@ import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
8
8
  import { resolveHubConfig, type HubConfig, type WritableRoot } from '../protocol.ts'
9
9
  import { rootOfPath } from '../skillfs.ts'
10
10
  import { dshHome, type SkillHubStore } from '../store.ts'
11
- import { writeError } from './http.ts'
11
+ import { writeError, type RouteFence } from './http.ts'
12
12
 
13
13
  /** Sources the hub may toggle (the user-level filesystem roots). */
14
14
  export function isWritableSource(source: string): source is WritableRoot {
@@ -27,6 +27,12 @@ export interface SkillHubRouteDeps {
27
27
  get(name: string, options?: SkillLookupLike): Promise<SkillDefinition | undefined>
28
28
  }
29
29
  store: SkillHubStore
30
+ /**
31
+ * Request fence for the whole family: returns the rejection status (401/403)
32
+ * or `undefined` to proceed. Production passes the host's own `/api` fence
33
+ * (`ctx.connection.requestRejection`); absent falls back to literal loopback.
34
+ */
35
+ fence?: RouteFence
30
36
  /** DSH home override (tests isolate the writable roots; defaults to ~/.dsh). */
31
37
  home?: string
32
38
  /** Invalidate the registry catalog cache after hub-driven mutations. */
@@ -1,8 +1,7 @@
1
1
  /**
2
- * routes 共享层:请求围栏(回环信任/方法/总开关/JSON 体)、统一错误映射、
3
- * 可写技能解析、目录/分组数据装配。handler 只写业务,从这里 import。
4
- * 从 routes.ts 原样搬出,行为不变。
5
- * 本文件是聚合出口(barrel):实现按域拆到 ./http.ts(围栏/错误映射/读体)、
2
+ * routes 共享层:请求栅栏(宿主 /api 信任模型 → 方法 → 总开关 → JSON 体)、
3
+ * 统一错误映射、可写技能解析、目录/分组数据装配。handler 只写业务,从这里 import。
4
+ * 本文件是聚合出口(barrel):实现按域拆到 ./http.ts(栅栏/错误映射/读体)、
6
5
  * ./deps.ts(依赖视图/可写解析)、./collection.ts(集合装配)、
7
6
  * ./catalog-data.ts(目录装配);对外导出面保持不变。
8
7
  */
@@ -10,12 +9,13 @@
10
9
  import type { IncomingMessage, ServerResponse } from 'node:http'
11
10
  import type { WebRoute, WebRouteKind } from '@deepseek-ai/dsh-host-webserver'
12
11
  import { disabledGate, type SkillHubRouteDeps } from './deps.ts'
13
- import { isLoopbackRequest, readJsonBody, writeError, writeRouteError } from './http.ts'
12
+ import { loopbackOnlyFence, readJsonBody, writeError, writeRouteError } from './http.ts'
14
13
 
15
14
  export {
16
15
  MAX_JSON_BODY_BYTES,
17
16
  STORE_ERROR_STATUS,
18
17
  isLoopbackRequest,
18
+ loopbackOnlyFence,
19
19
  pathExists,
20
20
  queryParam,
21
21
  readJsonBody,
@@ -25,6 +25,7 @@ export {
25
25
  writeJson,
26
26
  writeRouteError,
27
27
  } from './http.ts'
28
+ export type { RouteFence } from './http.ts'
28
29
  export {
29
30
  configOf,
30
31
  disabledGate,
@@ -83,16 +84,26 @@ export interface RouteSpec {
83
84
 
84
85
  /**
85
86
  * Wrap one handler in the shared request fences. Order matches the
86
- * original per-handler prologue: loopback trust → HTTP method → master
87
+ * original per-handler prologue: trust fence → HTTP method → master
87
88
  * switch → JSON body. The outer catch maps errors onto the JSON error
88
89
  * body (preserving RepoFetchError status codes).
90
+ *
91
+ * The trust fence is the host's `/api` model (`deps.fence`) whenever the
92
+ * composition mounts Connection, so a reverse-proxied deployment is judged by
93
+ * the same authorities dsh already trusts; without that service the historical
94
+ * literal-loopback check stands in.
89
95
  */
90
96
  export function createRoute(deps: SkillHubRouteDeps, spec: RouteSpec): WebRoute {
97
+ const fence = deps.fence ?? loopbackOnlyFence
91
98
  return {
92
99
  kind: spec.kind ?? 'exact',
93
100
  path: spec.path,
94
101
  handler: async (req, res) => {
95
- if (!isLoopbackRequest(req)) { writeError(res, 403, 'forbidden: loopback-only'); return }
102
+ const rejection = fence(req)
103
+ if (rejection !== undefined) {
104
+ writeError(res, rejection, rejection === 401 ? 'unauthorized: browser session required' : 'forbidden: untrusted authority')
105
+ return
106
+ }
96
107
  if (!spec.methods.includes(req.method as 'GET' | 'POST')) {
97
108
  writeError(res, 405, 'method not allowed: ' + (req.method ?? ''))
98
109
  return
@@ -1,6 +1,6 @@
1
1
  /**
2
- * routes 共享层 · HTTP 围栏:回环信任检查、JSON 响应与错误映射、请求体读取、
3
- * 查询/字段读取与文件存在性检查。从 helpers.ts 原样搬出,行为不变。
2
+ * routes 共享层 · HTTP 围栏:请求信任栅栏(复用宿主 `/api` 模型,见 RouteFence)、
3
+ * JSON 响应与错误映射、请求体读取、查询/字段读取与文件存在性检查。
4
4
  */
5
5
 
6
6
  import { stat } from 'node:fs/promises'
@@ -35,6 +35,35 @@ export function isLoopbackRequest(request: IncomingMessage): boolean {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * The route family's request fence: the HTTP status a request is rejected
40
+ * with (`401` unauthenticated / `403` untrusted authority), or `undefined`
41
+ * when it may reach the handler.
42
+ *
43
+ * dsh owns **one** browser-trust model for the whole `/api` tree — loopback,
44
+ * the listener's own bind address, every `--trusted-host` authority, then the
45
+ * browser session — and publishes it as `ctx.connection.requestRejection`,
46
+ * whose contract is written for exactly this case ("apply Connection's
47
+ * Host/Origin checks ... to another Web route"). Our paths are exact routes
48
+ * registered beside the shared `/api` prefix, so the host's own fence never
49
+ * sees them; asking its entry point instead of re-deriving the rule keeps a
50
+ * single trust model rather than a second, stricter one. Issue #17 is what the
51
+ * second one cost: behind a reverse proxy the literals checkable in this file
52
+ * are the gateway address and the public domain, so every route answered 403
53
+ * while the rest of the GUI worked.
54
+ */
55
+ export type RouteFence = (request: IncomingMessage) => number | undefined
56
+
57
+ /**
58
+ * Fallback fence for a host that mounts no Connection service (unit tests, and
59
+ * any composition serving the webserver alone): the historical literal-loopback
60
+ * check, so a missing service degrades to the previous behaviour instead of an
61
+ * open or a dead route family.
62
+ */
63
+ export function loopbackOnlyFence(request: IncomingMessage): number | undefined {
64
+ return isLoopbackRequest(request) ? undefined : 403
65
+ }
66
+
38
67
  /** One JSON response. */
39
68
  export function writeJson(res: ServerResponse, status: number, body: unknown): void {
40
69
  const payload = JSON.stringify(body)
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import { mkdir } from 'node:fs/promises'
7
- import { dirname, join } from 'node:path'
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 { moveIntoTrash, 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,29 +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 走同一个回收站落盘规则(moveIntoTrash),恢复流程依赖它。
64
- const { path: target } = await moveIntoTrash(sourceToTrash)
65
- await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
66
- await deps.store.removeDisabled(name)
67
- return true
55
+ if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false
56
+ return (await retireNamePlaceholders(deps, targetRoot, name)) !== undefined
68
57
  }
69
58
 
70
59
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
@@ -192,10 +181,11 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
192
181
  job.done += 1
193
182
  continue
194
183
  }
195
- // 磁盘上有同名目录。若它只是一个已禁用的占位(目录里只剩
196
- // SKILL.md.disabled),先把旧目录移入回收站再继续导入;移不动或
197
- // 不是这种形态时退回原本的「跳过」(issue #16)。
198
- 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) {
199
189
  let cleared = false
200
190
  try {
201
191
  cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
@@ -208,7 +198,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
208
198
  job.done += 1
209
199
  continue
210
200
  }
211
- // 旧目录已经进了回收站(可恢复)。即使随后的下载失败也必须如实
201
+ // 占位已经进了回收站(可恢复)。即使随后的下载失败也必须如实
212
202
  // 上报,否则用户不知道自己的占位被挪走了。
213
203
  job.replaced.push(entry.name)
214
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
+ }