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.
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -4
- package/README.zh.md +5 -4
- package/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +362 -74
- package/lib/types/client/locales/settings.d.ts +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/protocol/config.d.ts +1 -1
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/routes/deps.d.ts +8 -1
- package/lib/types/routes/helpers.d.ts +11 -6
- package/lib/types/routes/http.d.ts +27 -2
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/routes.d.ts +29 -4
- package/lib/types/skillfs/scan.d.ts +26 -0
- package/lib/types/skillfs.d.ts +9 -1
- package/lib/types/store/migrate.d.ts +1 -1
- package/lib/types/store/store.d.ts +10 -5
- package/package.json +1 -1
- package/src/client/locales/settings.ts +2 -2
- package/src/index.ts +26 -4
- package/src/protocol/config.ts +1 -1
- package/src/protocol/sources.ts +5 -0
- package/src/reconcile.test.ts +74 -1
- package/src/reconcile.ts +50 -1
- package/src/routes/catalog.ts +50 -22
- package/src/routes/deps.ts +8 -2
- package/src/routes/helpers.ts +18 -7
- package/src/routes/http.ts +31 -2
- package/src/routes/repo-import.ts +16 -26
- package/src/routes/route-state.test.ts +4 -4
- package/src/routes/route-state.ts +45 -10
- package/src/routes/sources.ts +79 -23
- package/src/routes.test.ts +302 -4
- package/src/routes.ts +49 -5
- package/src/skillfs/scan.ts +91 -2
- package/src/skillfs.ts +14 -2
- package/src/store/migrate.ts +6 -4
- package/src/store/store.ts +21 -11
- 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
|
+
}
|
package/src/routes/catalog.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
-
import { dirname
|
|
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,
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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: ' +
|
|
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
|
-
//
|
|
279
|
-
// frontmatter
|
|
280
|
-
//
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
writeError(res, 409, 'skill
|
|
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))
|
package/src/routes/deps.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* routes 共享层 · 依赖视图:路由依赖接口、配置解析与可写技能解析(HTTP
|
|
3
|
-
* 围栏在 ./http.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. */
|
package/src/routes/helpers.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* routes
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
package/src/routes/http.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* routes 共享层 · HTTP
|
|
3
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
|
|
47
|
+
* 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
|
|
48
|
+
* 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
|
|
49
|
+
* 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
|
|
50
|
+
* 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
52
|
+
* 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
|
|
54
53
|
*/
|
|
55
54
|
async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
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,
|
|
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('
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
93
|
-
//
|
|
94
|
-
console.warn('[dsh-skill-hub] restoring ' +
|
|
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
|
+
}
|