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/routes/sources.ts
CHANGED
|
@@ -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,
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
164
|
+
await download()
|
|
159
165
|
}
|
|
160
|
-
if (
|
|
166
|
+
if (record !== undefined) {
|
|
161
167
|
// Preserve the disabled state: the fresh SKILL.md must not
|
|
162
|
-
// re-enter discovery.
|
|
163
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 (
|
|
215
|
-
failed.push({ name, error: 'skill
|
|
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(
|
|
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(
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
package/src/routes.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
|
|
|
4
4
|
import type { IncomingMessage } from 'node:http'
|
|
5
5
|
import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
-
import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
|
|
7
|
+
import { hostTrustFence, makeRoutes, type SkillHubRouteDeps } from './routes.ts'
|
|
8
8
|
import { lastMarketCheck } from './routes/route-state.ts'
|
|
9
9
|
import { SkillHubStore, statePath } from './store.ts'
|
|
10
10
|
import { SKILL_HUB_API, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
|
|
@@ -27,12 +27,12 @@ class FakeResponse {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** Minimal request double (loopback by default; body available for POST). */
|
|
30
|
-
function fakeReq(method: string, url = '/', body?: unknown, remoteAddress = '127.0.0.1'): IncomingMessage {
|
|
30
|
+
function fakeReq(method: string, url = '/', body?: unknown, remoteAddress = '127.0.0.1', host = '127.0.0.1:3080'): IncomingMessage {
|
|
31
31
|
const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))]
|
|
32
32
|
return {
|
|
33
33
|
method,
|
|
34
34
|
url,
|
|
35
|
-
headers: { host
|
|
35
|
+
headers: { host },
|
|
36
36
|
socket: { remoteAddress },
|
|
37
37
|
async *[Symbol.asyncIterator]() {
|
|
38
38
|
for (const chunk of chunks) yield chunk
|
|
@@ -105,10 +105,76 @@ describe('skill-hub routes', () => {
|
|
|
105
105
|
return route
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
it('rejects non-loopback requests', async () => {
|
|
108
|
+
it('rejects non-loopback requests with the literal fallback when no host fence is wired', async () => {
|
|
109
|
+
// deps 里没有 fence(非 web 宿主、单测)⇒ 退回历史的字面回环判定。
|
|
109
110
|
const res = new FakeResponse()
|
|
110
111
|
await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog, undefined, '10.0.0.5'), res as never)
|
|
111
112
|
expect(res.status).toBe(403)
|
|
113
|
+
expect(res.json()).toEqual({ error: 'forbidden: untrusted authority' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('delegates to the host fence and passes its verdict through verbatim (issue #17)', async () => {
|
|
117
|
+
// 反代形态:socket 是网关地址、Host 是公开域名。宿主栅栏认它,本插件不许再挡一道。
|
|
118
|
+
const seen: string[] = []
|
|
119
|
+
deps.fence = (request) => {
|
|
120
|
+
seen.push(String(request.headers.host))
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
const ok = new FakeResponse()
|
|
124
|
+
await routeFor(SKILL_HUB_API.catalog)
|
|
125
|
+
.handler(fakeReq('GET', SKILL_HUB_API.catalog, undefined, '172.19.0.1', 'dsh.example.com'), ok as never)
|
|
126
|
+
expect(seen).toEqual(['dsh.example.com'])
|
|
127
|
+
expect(ok.status).toBe(200)
|
|
128
|
+
|
|
129
|
+
// 宿主判定「栅栏过了但没有浏览器会话」时,401 同样原样透传。
|
|
130
|
+
deps.fence = () => 401
|
|
131
|
+
const unauth = new FakeResponse()
|
|
132
|
+
await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), unauth as never)
|
|
133
|
+
expect(unauth.status).toBe(401)
|
|
134
|
+
expect(unauth.json()).toEqual({ error: 'unauthorized: browser session required' })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('hostTrustFence: literal fallback without Connection, delegation with it, fail closed when it throws', () => {
|
|
138
|
+
const remote = fakeReq('GET', '/', undefined, '10.0.0.5')
|
|
139
|
+
expect(hostTrustFence(() => undefined)(remote)).toBe(403)
|
|
140
|
+
expect(hostTrustFence(() => undefined)(fakeReq('GET', '/'))).toBeUndefined()
|
|
141
|
+
|
|
142
|
+
let calls = 0
|
|
143
|
+
const delegated = hostTrustFence(() => ({
|
|
144
|
+
requestRejection: () => { calls += 1; return 401 },
|
|
145
|
+
} as never))
|
|
146
|
+
expect(delegated(remote)).toBe(401)
|
|
147
|
+
expect(calls).toBe(1)
|
|
148
|
+
|
|
149
|
+
const broken = hostTrustFence(() => ({
|
|
150
|
+
requestRejection: () => { throw new Error('fence unavailable') },
|
|
151
|
+
} as never))
|
|
152
|
+
expect(broken(remote)).toBe(403)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('hostTrustFence: a service appearing later un-latches the fallback, and onMissing fires once (issue #17)', () => {
|
|
156
|
+
const remote = fakeReq('GET', '/', undefined, '10.0.0.5')
|
|
157
|
+
let connection: import('@deepseek-ai/dsh-client-connection').HostConnectionHandle | undefined
|
|
158
|
+
let misses = 0
|
|
159
|
+
const fence = hostTrustFence(
|
|
160
|
+
() => connection,
|
|
161
|
+
() => { misses += 1 },
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// 启动早于 Connection:前几次请求走降级,且只报一次缺失。
|
|
165
|
+
expect(fence(remote)).toBe(403)
|
|
166
|
+
expect(fence(remote)).toBe(403)
|
|
167
|
+
expect(misses).toBe(1)
|
|
168
|
+
|
|
169
|
+
// Connection 上线后同一 fence 立即切换,不锁死在降级分支。
|
|
170
|
+
connection = { requestRejection: () => undefined } as unknown as import('@deepseek-ai/dsh-client-connection').HostConnectionHandle
|
|
171
|
+
expect(fence(remote)).toBeUndefined()
|
|
172
|
+
expect(misses).toBe(1)
|
|
173
|
+
|
|
174
|
+
// 服务再次消失(理论上不该发生)不重复报警,判定回到降级。
|
|
175
|
+
connection = undefined
|
|
176
|
+
expect(fence(remote)).toBe(403)
|
|
177
|
+
expect(misses).toBe(1)
|
|
112
178
|
})
|
|
113
179
|
|
|
114
180
|
it('reports stats unavailable without a reader, or when all usage displays are off', async () => {
|
|
@@ -1117,6 +1183,238 @@ describe('skill-hub routes', () => {
|
|
|
1117
1183
|
expect(invalidate).toHaveBeenCalledTimes(1)
|
|
1118
1184
|
})
|
|
1119
1185
|
|
|
1186
|
+
// --------------------------- 同名双形态 / 陈旧禁用记录(issue #16 类,实测 G5/G7/G8)
|
|
1187
|
+
it('self-heals a stale disabled record by pointing it at the surviving placeholder', async () => {
|
|
1188
|
+
// 记录指目录形态,磁盘上只剩扁平占位 —— 导入让位/L2 迁移后的典型残留。
|
|
1189
|
+
await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
|
|
1190
|
+
await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
1191
|
+
const res = new FakeResponse()
|
|
1192
|
+
await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'code-review', enabled: true }), res as never)
|
|
1193
|
+
expect(res.status).toBe(200)
|
|
1194
|
+
await expect(access(join(home, 'skills', 'code-review.md'))).resolves.toBeUndefined()
|
|
1195
|
+
expect(await store.getDisabled('code-review')).toBeUndefined()
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it('drops a fully stale disabled record so the name becomes usable again', async () => {
|
|
1199
|
+
await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
1200
|
+
// 记录还在时,新建同名技能被 409 挡住。
|
|
1201
|
+
const blocked = new FakeResponse()
|
|
1202
|
+
await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'ghost', description: 'x' }), blocked as never)
|
|
1203
|
+
expect(blocked.status).toBe(409)
|
|
1204
|
+
// 启用报「记录已失效」并把记录清掉,而不是留下一条谁都动不了的死记录。
|
|
1205
|
+
const enable = new FakeResponse()
|
|
1206
|
+
await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'ghost', enabled: true }), enable as never)
|
|
1207
|
+
expect(enable.status).toBe(409)
|
|
1208
|
+
expect(String((enable.json() as { error: string }).error)).toContain('stale')
|
|
1209
|
+
expect(await store.getDisabled('ghost')).toBeUndefined()
|
|
1210
|
+
// 名字已释放:新建成功,再次启用则报「不是禁用态」。
|
|
1211
|
+
const created = new FakeResponse()
|
|
1212
|
+
await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'ghost', description: 'x' }), created as never)
|
|
1213
|
+
expect(created.status).toBe(201)
|
|
1214
|
+
await expect(access(join(home, 'skills', 'ghost', 'SKILL.md'))).resolves.toBeUndefined()
|
|
1215
|
+
const again = new FakeResponse()
|
|
1216
|
+
await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'ghost', enabled: true }), again as never)
|
|
1217
|
+
expect(again.status).toBe(404)
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it('deletes a stale disabled record instead of failing on the missing file', async () => {
|
|
1221
|
+
const tag = await store.saveTag({ name: 'scene-x' })
|
|
1222
|
+
await store.setTagMembers(tag.id, ['ghost'])
|
|
1223
|
+
await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
1224
|
+
const res = new FakeResponse()
|
|
1225
|
+
await routeFor(SKILL_HUB_API.skillDelete).handler(fakeReq('POST', SKILL_HUB_API.skillDelete, { name: 'ghost' }), res as never)
|
|
1226
|
+
expect(res.status).toBe(200)
|
|
1227
|
+
expect(await store.getDisabled('ghost')).toBeUndefined()
|
|
1228
|
+
expect((await store.getTag(tag.id))?.skillNames).toEqual([])
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
it('flags two discovery forms occupying the same skill name in one root', async () => {
|
|
1232
|
+
// 目录包与扁平文件同名:注册表只给一个,另一个静默不可达 → 必须报诊断。
|
|
1233
|
+
await writeFile(join(home, 'skills', 'dup-skill.md'), '---\nname: dup-skill\ndescription: flat one\n---', 'utf8')
|
|
1234
|
+
await mkdir(join(home, 'skills', 'dup-skill'), { recursive: true })
|
|
1235
|
+
await writeFile(join(home, 'skills', 'dup-skill', 'SKILL.md'), '---\nname: dup-skill\ndescription: bundle one\n---', 'utf8')
|
|
1236
|
+
const res = new FakeResponse()
|
|
1237
|
+
await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), res as never)
|
|
1238
|
+
expect(res.status).toBe(200)
|
|
1239
|
+
const body = res.json() as CatalogResponse
|
|
1240
|
+
const collisions = body.diagnostics.filter((d) => d.reason.includes('registers the same name'))
|
|
1241
|
+
expect(collisions.map((d) => d.path).sort()).toEqual([
|
|
1242
|
+
join(home, 'skills', 'dup-skill.md'),
|
|
1243
|
+
join(home, 'skills', 'dup-skill', 'SKILL.md'),
|
|
1244
|
+
].sort())
|
|
1245
|
+
expect(collisions[0].reason).toContain('"dup-skill"')
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
it('reclaims unreferenced trash directories when clearing the trash', async () => {
|
|
1249
|
+
const trash = join(home, 'skills', '.trash')
|
|
1250
|
+
await mkdir(join(trash, 'foo-1'), { recursive: true })
|
|
1251
|
+
await mkdir(join(trash, 'foo-2'), { recursive: true })
|
|
1252
|
+
// 名字索引只剩最新那条;foo-1 已经没有任何记录指得到它。
|
|
1253
|
+
await store.addTrash({ name: 'foo', path: join(trash, 'foo-2'), movedAt: 2, sourcePath: join(home, 'skills', 'foo') })
|
|
1254
|
+
const res = new FakeResponse()
|
|
1255
|
+
await routeFor(SKILL_HUB_API.sourceTrashClear).handler(fakeReq('POST', SKILL_HUB_API.sourceTrashClear), res as never)
|
|
1256
|
+
expect(res.status).toBe(200)
|
|
1257
|
+
const body = res.json() as import('./protocol.ts').SourceTrashClearResponse
|
|
1258
|
+
expect(body.deleted).toEqual(['foo'])
|
|
1259
|
+
expect(body.orphans).toEqual([join(trash, 'foo-1')])
|
|
1260
|
+
await expect(access(join(trash, 'foo-1'))).rejects.toThrow()
|
|
1261
|
+
await expect(access(join(trash, 'foo-2'))).rejects.toThrow()
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
// ------------------------- 导入/同步/删除/新建 遇到扁平形态(issue #16 类,实测 G1–G4)
|
|
1265
|
+
/** 造一个「扁平已禁用占位」:`<name>.md.disabled` + 指向它的 sidecar 记录。 */
|
|
1266
|
+
async function seedFlatDisabledPlaceholder(name: string) {
|
|
1267
|
+
const flat = join(home, 'skills', name + '.md.disabled')
|
|
1268
|
+
await writeFile(flat, `---\nname: ${name}\ndescription: stub\n---\n`, 'utf8')
|
|
1269
|
+
await store.addDisabled({ name, description: 'stub', path: flat, root: 'user-dsh', disabledAt: Date.now() })
|
|
1270
|
+
return flat
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
it('imports over a flat disabled placeholder instead of leaving it behind', async () => {
|
|
1274
|
+
const flat = await seedFlatDisabledPlaceholder('code-review')
|
|
1275
|
+
const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
|
|
1276
|
+
stubFetch([
|
|
1277
|
+
['repos/example/repo/git/trees/main', jsonResponse({ tree: [
|
|
1278
|
+
{ path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
|
|
1279
|
+
] })],
|
|
1280
|
+
['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
|
|
1281
|
+
['repos/example/repo', jsonResponse({ default_branch: 'main' })],
|
|
1282
|
+
['raw.githubusercontent.com/example/repo/main/skills/code-review/SKILL.md', new Response(skillMd, { status: 200 })],
|
|
1283
|
+
])
|
|
1284
|
+
try {
|
|
1285
|
+
const res = new FakeResponse()
|
|
1286
|
+
await routeFor(SKILL_HUB_API.repoImport).handler(fakeReq('POST', SKILL_HUB_API.repoImport, { repo: 'example/repo', paths: ['skills/code-review/SKILL.md'] }), res as never)
|
|
1287
|
+
const created = res.json() as import('./protocol.ts').RepoImportResponse
|
|
1288
|
+
let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
|
|
1289
|
+
for (let i = 0; i < 30; i++) {
|
|
1290
|
+
await new Promise((r) => setTimeout(r, 40))
|
|
1291
|
+
const p = new FakeResponse()
|
|
1292
|
+
await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), p as never)
|
|
1293
|
+
prog = p.json() as import('./protocol.ts').RepoImportProgressResponse
|
|
1294
|
+
if (prog.status !== 'running') break
|
|
1295
|
+
}
|
|
1296
|
+
expect(prog!.status).toBe('done')
|
|
1297
|
+
expect(prog!.skipped).toEqual([])
|
|
1298
|
+
expect(prog!.imported.map((r) => r.name)).toEqual(['code-review'])
|
|
1299
|
+
// 关键差异:扁平占位不再「看不见」——它被移入回收站、如实上报,真技能落地。
|
|
1300
|
+
expect(prog!.replaced).toEqual(['code-review'])
|
|
1301
|
+
await expect(access(flat)).rejects.toThrow()
|
|
1302
|
+
await expect(readFile(join(home, 'skills', 'code-review', 'SKILL.md'), 'utf8')).resolves.toContain('name: code-review')
|
|
1303
|
+
expect(await store.listDisabled()).toEqual([])
|
|
1304
|
+
expect((await store.listTrash()).map((entry) => entry.name)).toEqual(['code-review'])
|
|
1305
|
+
} finally {
|
|
1306
|
+
vi.unstubAllGlobals()
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
/** 同步一个已跟踪技能所需的 fetch 桩(tree + commit + raw)。 */
|
|
1311
|
+
function stubSyncFetch(): void {
|
|
1312
|
+
stubFetch([
|
|
1313
|
+
['repos/example/repo/git/trees/tree1', jsonResponse({ tree: [
|
|
1314
|
+
{ path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
|
|
1315
|
+
] })],
|
|
1316
|
+
['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
|
|
1317
|
+
['raw.githubusercontent.com/example/repo/abc123/skills/code-review/SKILL.md', new Response('---\nname: code-review\ndescription: upstream\n---\n', { status: 200 })],
|
|
1318
|
+
])
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
it('syncs a flat-stored skill into the bundle slot instead of leaving both', async () => {
|
|
1322
|
+
await writeFile(join(home, 'skills', 'code-review.md'), '---\nname: code-review\ndescription: flat\n---\n', 'utf8')
|
|
1323
|
+
await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
|
|
1324
|
+
stubSyncFetch()
|
|
1325
|
+
try {
|
|
1326
|
+
const res = new FakeResponse()
|
|
1327
|
+
await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
|
|
1328
|
+
expect(res.status).toBe(200)
|
|
1329
|
+
expect((res.json() as import('./protocol.ts').SourceSyncResponse).synced).toEqual(['code-review'])
|
|
1330
|
+
// 旧扁平文件必须先让开,否则同名两形态并存(注册表只会给一个)。
|
|
1331
|
+
await expect(access(join(home, 'skills', 'code-review.md'))).rejects.toThrow()
|
|
1332
|
+
await expect(readFile(join(home, 'skills', 'code-review', 'SKILL.md'), 'utf8')).resolves.toContain('description: upstream')
|
|
1333
|
+
} finally {
|
|
1334
|
+
vi.unstubAllGlobals()
|
|
1335
|
+
}
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
it('syncs a flat hub-disabled skill and repoints its record at the new bundle', async () => {
|
|
1339
|
+
const flat = await seedFlatDisabledPlaceholder('code-review')
|
|
1340
|
+
await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
|
|
1341
|
+
stubSyncFetch()
|
|
1342
|
+
try {
|
|
1343
|
+
const res = new FakeResponse()
|
|
1344
|
+
await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
|
|
1345
|
+
expect(res.status).toBe(200)
|
|
1346
|
+
// 禁用态要保持,但记录必须改指新形态 —— 否则「启用」会去动那个已被替换掉的旧文件。
|
|
1347
|
+
const bundleDisabled = join(home, 'skills', 'code-review', 'SKILL.md.disabled')
|
|
1348
|
+
await expect(access(bundleDisabled)).resolves.toBeUndefined()
|
|
1349
|
+
await expect(access(flat)).rejects.toThrow()
|
|
1350
|
+
expect((await store.getDisabled('code-review'))?.path).toBe(bundleDisabled)
|
|
1351
|
+
} finally {
|
|
1352
|
+
vi.unstubAllGlobals()
|
|
1353
|
+
}
|
|
1354
|
+
})
|
|
1355
|
+
|
|
1356
|
+
it('keeps a bundle-disabled skill disabled after syncing it again', async () => {
|
|
1357
|
+
// 记录本来就指向目录包形态(`<name>/SKILL.md.disabled`):让位步骤会把记录删掉,
|
|
1358
|
+
// 若只在「path 变了」时写回,这里就会连禁用状态和记录一起丢掉(活体实测抓到过)。
|
|
1359
|
+
const dir = join(home, 'skills', 'code-review')
|
|
1360
|
+
await mkdir(dir, { recursive: true })
|
|
1361
|
+
const bundleDisabled = join(dir, 'SKILL.md.disabled')
|
|
1362
|
+
await writeFile(bundleDisabled, '---\nname: code-review\ndescription: old\n---\n', 'utf8')
|
|
1363
|
+
await store.addDisabled({ name: 'code-review', description: 'old', path: bundleDisabled, root: 'user-dsh', disabledAt: 1 })
|
|
1364
|
+
await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
|
|
1365
|
+
stubSyncFetch()
|
|
1366
|
+
try {
|
|
1367
|
+
const res = new FakeResponse()
|
|
1368
|
+
await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
|
|
1369
|
+
expect((res.json() as import('./protocol.ts').SourceSyncResponse).synced).toEqual(['code-review'])
|
|
1370
|
+
await expect(access(bundleDisabled)).resolves.toBeUndefined()
|
|
1371
|
+
expect((await store.getDisabled('code-review'))?.path).toBe(bundleDisabled)
|
|
1372
|
+
} finally {
|
|
1373
|
+
vi.unstubAllGlobals()
|
|
1374
|
+
}
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
it('follows an upstream delete for a flat-stored skill', async () => {
|
|
1378
|
+
const flat = join(home, 'skills', 'code-review.md')
|
|
1379
|
+
await writeFile(flat, '---\nname: code-review\ndescription: flat\n---\n', 'utf8')
|
|
1380
|
+
await store.addSourceSkill('example/repo', 'skills', 'abc', undefined, 'code-review')
|
|
1381
|
+
const res = new FakeResponse()
|
|
1382
|
+
await routeFor(SKILL_HUB_API.sourceDelete).handler(fakeReq('POST', SKILL_HUB_API.sourceDelete, { repo: 'example/repo', skills: ['code-review'] }), res as never)
|
|
1383
|
+
expect(res.status).toBe(200)
|
|
1384
|
+
const body = res.json() as import('./protocol.ts').SourceDeleteResponse
|
|
1385
|
+
expect(body.failed).toEqual([])
|
|
1386
|
+
expect(body.trashed).toEqual(['code-review'])
|
|
1387
|
+
await expect(access(flat)).rejects.toThrow()
|
|
1388
|
+
expect((await store.listTrash())[0]?.sourcePath).toBe(flat)
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
it('refuses to create a skill whose name is taken by a flat file on disk', async () => {
|
|
1392
|
+
// frontmatter 坏 → 注册表看不见它,但名字确实被占了;建目录会立刻造出同名两形态。
|
|
1393
|
+
await writeFile(join(home, 'skills', 'foo.md'), 'not a frontmatter file', 'utf8')
|
|
1394
|
+
const res = new FakeResponse()
|
|
1395
|
+
await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'foo', description: 'x' }), res as never)
|
|
1396
|
+
expect(res.status).toBe(409)
|
|
1397
|
+
expect(String((res.json() as { error: string }).error)).toContain('occupies this name on disk')
|
|
1398
|
+
await expect(access(join(home, 'skills', 'foo'))).rejects.toThrow()
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
it('refuses to create a skill whose name is held by a disabled placeholder', async () => {
|
|
1402
|
+
// 有 sidecar 记录时走既有的「先重新启用」提示;没有记录(记录丢失/手改过)
|
|
1403
|
+
// 时以前会直接建档造出同名两形态,现在由磁盘占位检查兜住。
|
|
1404
|
+
await writeFile(join(home, 'skills', 'foo.md.disabled'), '---\nname: foo\ndescription: stub\n---\n', 'utf8')
|
|
1405
|
+
const res = new FakeResponse()
|
|
1406
|
+
await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'foo', description: 'x' }), res as never)
|
|
1407
|
+
expect(res.status).toBe(409)
|
|
1408
|
+
expect(String((res.json() as { error: string }).error)).toContain('disabled placeholder')
|
|
1409
|
+
await expect(access(join(home, 'skills', 'foo'))).rejects.toThrow()
|
|
1410
|
+
// 有记录时保持原有提示口径。
|
|
1411
|
+
await seedFlatDisabledPlaceholder('bar')
|
|
1412
|
+
const withRecord = new FakeResponse()
|
|
1413
|
+
await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'bar', description: 'x' }), withRecord as never)
|
|
1414
|
+
expect(withRecord.status).toBe(409)
|
|
1415
|
+
expect(String((withRecord.json() as { error: string }).error)).toContain('re-enable it from the disabled list first')
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1120
1418
|
it('patches display toggles on the config route', async () => {
|
|
1121
1419
|
const res = new FakeResponse()
|
|
1122
1420
|
await routeFor(SKILL_HUB_API.config).handler(fakeReq('POST', SKILL_HUB_API.config, { showUseCount: false }), res as never)
|
package/src/routes.ts
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
* official registry + hub-disabled skills + discovery diagnostics), skill
|
|
4
4
|
* detail, enable/disable toggle, new-skill scaffold, user groups (tags +
|
|
5
5
|
* origin collections), and upstream source tracking (check/sync/follow
|
|
6
|
-
* upstream deletion into a restorable trash). Every route carries
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* upstream deletion into a restorable trash). Every route carries the host's
|
|
7
|
+
* own `/api` trust fence (loopback, the listener's bind address, each
|
|
8
|
+
* `--trusted-host` authority, then the browser session) — these endpoints
|
|
9
|
+
* rename files under the user's skill roots, so they must never be laxer than
|
|
10
|
+
* the GUI around them (issue #17).
|
|
9
11
|
*
|
|
10
12
|
* 按域拆分后的聚合入口:各域 handler 在 ./routes/<domain>.ts,共享围栏在
|
|
11
13
|
* ./routes/helpers.ts,节流/任务状态在 ./routes/route-state.ts。
|
|
12
14
|
* 老 `from './routes.ts'`(makeRoutes + SkillHubRouteDeps)保持可用。
|
|
13
15
|
*/
|
|
14
16
|
|
|
17
|
+
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
|
|
15
18
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
16
19
|
import { SKILL_HUB_API_ROOT } from './protocol.ts'
|
|
17
20
|
import { createRoute, writeError, type SkillHubRouteDeps } from './routes/helpers.ts'
|
|
21
|
+
import { loopbackOnlyFence, type RouteFence } from './routes/http.ts'
|
|
18
22
|
import { catalogRoutes } from './routes/catalog.ts'
|
|
19
23
|
import { configRoutes } from './routes/config.ts'
|
|
20
24
|
import { marketRoutes } from './routes/market.ts'
|
|
@@ -23,14 +27,54 @@ import { groupRoutes } from './routes/groups.ts'
|
|
|
23
27
|
import { sourceRoutes } from './routes/sources.ts'
|
|
24
28
|
|
|
25
29
|
export type { SkillHubRouteDeps, SkillLookupLike } from './routes/helpers.ts'
|
|
30
|
+
export type { RouteFence } from './routes/http.ts'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the family's trust fence from the host's `/api` model.
|
|
34
|
+
*
|
|
35
|
+
* `ctx.connection.requestRejection` is the documented hook for fencing another
|
|
36
|
+
* Web route; with no Connection service (a webserver-only composition, or a
|
|
37
|
+
* unit test) the literal-loopback check stands in, so the fallback is the old
|
|
38
|
+
* behaviour rather than an open route family.
|
|
39
|
+
*
|
|
40
|
+
* The service is read **per request**, not once at mount: plugins start in
|
|
41
|
+
* composition order, so a one-shot read at `apply()` time would latch the
|
|
42
|
+
* fallback whenever this plugin happens to come up before Connection. A
|
|
43
|
+
* persistent absence is almost certainly an upstream composition change rather
|
|
44
|
+
* than a transient startup order, so the first miss fires `onMissing` once —
|
|
45
|
+
* without that signal the fallback would silently reproduce issue #17 after
|
|
46
|
+
* any future upstream rename of the service.
|
|
47
|
+
* @param read - resolves `ctx.connection`, or `undefined` when absent.
|
|
48
|
+
* @param onMissing - called once, the first time the service reads absent.
|
|
49
|
+
* @returns the fence every route in this family runs first.
|
|
50
|
+
*/
|
|
51
|
+
export function hostTrustFence(read: () => HostConnectionHandle | undefined, onMissing?: () => void): RouteFence {
|
|
52
|
+
let missingReported = false
|
|
53
|
+
return (request) => {
|
|
54
|
+
const connection = read()
|
|
55
|
+
if (connection === undefined) {
|
|
56
|
+
if (!missingReported) {
|
|
57
|
+
missingReported = true
|
|
58
|
+
onMissing?.()
|
|
59
|
+
}
|
|
60
|
+
return loopbackOnlyFence(request)
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return connection.requestRejection(request)
|
|
64
|
+
} catch {
|
|
65
|
+
// Fail closed: a broken fence must never open file-mutating routes.
|
|
66
|
+
return 403
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
26
70
|
|
|
27
71
|
/**
|
|
28
72
|
* Build every /api/skill-hub route.
|
|
29
|
-
* @param deps - skill registry view + sidecar store.
|
|
73
|
+
* @param deps - skill registry view + sidecar store + trust fence.
|
|
30
74
|
* @returns the exact-path routes plus the family's 404 catch-all.
|
|
31
75
|
*/
|
|
32
76
|
export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
33
|
-
// 各域返回裸 spec
|
|
77
|
+
// 各域返回裸 spec,统一在这里包上请求围栏(信任栅栏 → 方法 → 总开关
|
|
34
78
|
// → JSON 体 → 统一错误映射),新路由不会漏掉围栏。
|
|
35
79
|
const specs = [
|
|
36
80
|
...catalogRoutes(deps),
|