dsh-skill-hub 0.3.13 → 0.3.15
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 +6 -4
- package/README.md +9 -5
- package/README.zh.md +9 -5
- package/lib/client.js +55 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +529 -315
- package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
- package/lib/types/client/icons.d.ts +4 -5
- package/lib/types/client/index.d.ts +13 -12
- package/lib/types/client/locales/market.d.ts +1 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/settings-form.d.ts +9 -3
- package/lib/types/client/slash-dots.d.ts +20 -11
- package/lib/types/index.d.ts +59 -24
- package/lib/types/protocol/api.d.ts +22 -2
- package/lib/types/protocol/config.d.ts +30 -4
- package/lib/types/protocol/market.d.ts +1 -1
- package/lib/types/protocol/repo.d.ts +18 -1
- package/lib/types/protocol.d.ts +3 -3
- package/lib/types/repo/discovery.d.ts +59 -3
- package/lib/types/repo/github-client.d.ts +16 -0
- package/lib/types/repo.d.ts +6 -3
- package/lib/types/routes/helpers.d.ts +8 -1
- package/lib/types/routes/market.d.ts +2 -2
- package/lib/types/routes.d.ts +1 -1
- package/lib/types/store/store.d.ts +4 -0
- package/package.json +31 -41
- package/src/client/SkillHubSettingsCard.tsx +5 -3
- package/src/client/icons.tsx +4 -5
- package/src/client/index.tsx +33 -32
- package/src/client/locales/market.ts +2 -0
- package/src/client/panel/RepoScanCard.tsx +6 -3
- package/src/client/panel/SkillHubPanel.tsx +1 -1
- package/src/client/panel/SourcesView.tsx +1 -1
- package/src/client/panel/hooks/useGroupFlow.ts +1 -8
- package/src/client/settings-form.ts +9 -4
- package/src/client/slash-dots.test.ts +19 -36
- package/src/client/slash-dots.tsx +30 -60
- package/src/index.ts +145 -82
- package/src/protocol/api.ts +24 -2
- package/src/protocol/config.ts +35 -4
- package/src/protocol/market.ts +1 -1
- package/src/protocol/repo.ts +22 -1
- package/src/protocol.ts +3 -3
- package/src/repo/discovery.ts +120 -33
- package/src/repo/github-client.ts +18 -1
- package/src/repo/install.ts +8 -7
- package/src/repo.test.ts +158 -4
- package/src/repo.ts +10 -2
- package/src/routes/config.ts +19 -2
- package/src/routes/helpers.ts +9 -2
- package/src/routes/market.ts +49 -37
- package/src/routes/repo-import.ts +3 -2
- package/src/routes/sources.ts +2 -1
- package/src/routes.test.ts +107 -1
- package/src/routes.ts +21 -3
- package/src/store/migrate.ts +3 -2
- package/src/store/store.ts +7 -2
- package/src/store.test.ts +50 -0
- package/src/update.ts +4 -2
package/src/routes/helpers.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
|
-
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
11
|
+
import type { WebRoute, WebRouteKind } from '@deepseek-ai/dsh-host-webserver'
|
|
12
12
|
import { disabledGate, type SkillHubRouteDeps } from './deps.ts'
|
|
13
13
|
import { isLoopbackRequest, readJsonBody, writeError, writeRouteError } from './http.ts'
|
|
14
14
|
|
|
@@ -65,6 +65,13 @@ export type RouteHandler = (context: RouteContext) => Promise<void>
|
|
|
65
65
|
/** One declarative route: path + accepted methods + the business handler. */
|
|
66
66
|
export interface RouteSpec {
|
|
67
67
|
path: string
|
|
68
|
+
/**
|
|
69
|
+
* Match kind: 'exact' (default) matches the pathname verbatim; 'prefix'
|
|
70
|
+
* matches the path and everything beneath it. The webserver consults the
|
|
71
|
+
* exact table first and then resolves prefixes longest-first, so a prefix
|
|
72
|
+
* route is a catch-all that can never shadow a real route.
|
|
73
|
+
*/
|
|
74
|
+
kind?: WebRouteKind
|
|
68
75
|
/** Accepted HTTP methods; anything else answers 405. */
|
|
69
76
|
methods: readonly ('GET' | 'POST')[]
|
|
70
77
|
/** POST requests must carry a JSON body (400 when missing/unparseable). */
|
|
@@ -82,7 +89,7 @@ export interface RouteSpec {
|
|
|
82
89
|
*/
|
|
83
90
|
export function createRoute(deps: SkillHubRouteDeps, spec: RouteSpec): WebRoute {
|
|
84
91
|
return {
|
|
85
|
-
kind: 'exact',
|
|
92
|
+
kind: spec.kind ?? 'exact',
|
|
86
93
|
path: spec.path,
|
|
87
94
|
handler: async (req, res) => {
|
|
88
95
|
if (!isLoopbackRequest(req)) { writeError(res, 403, 'forbidden: loopback-only'); return }
|
package/src/routes/market.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 市场域路由:market 列表 / source 增删 / ref 锁定 / versions 选择器 /
|
|
3
|
-
* check 更新检查 / stats 星标统计 / sync
|
|
4
|
-
* handler 逻辑不变。
|
|
3
|
+
* source/check 更新检查 / stats 星标统计 / source/sync 对齐版本。
|
|
4
|
+
* 从 routes.ts 原样搬出,handler 逻辑不变。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
SKILL_HUB_API,
|
|
9
|
+
SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
|
|
9
10
|
type MarketCheckResponse,
|
|
10
11
|
type MarketSourceResponse,
|
|
11
12
|
type MarketSourcesResponse,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
readString,
|
|
29
30
|
writeError,
|
|
30
31
|
writeJson,
|
|
32
|
+
type RouteHandler,
|
|
31
33
|
type RouteSpec,
|
|
32
34
|
type SkillHubRouteDeps,
|
|
33
35
|
} from './helpers.ts'
|
|
@@ -42,6 +44,41 @@ import {
|
|
|
42
44
|
|
|
43
45
|
/** 市场域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
44
46
|
export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
47
|
+
// Update check over every market source: compares the pinned ref's commit
|
|
48
|
+
// against the recorded baseline and surfaces newer releases. 5-minute
|
|
49
|
+
// throttle mirrors the source check. Shared by the canonical route and its
|
|
50
|
+
// deprecated alias, so the two can never drift apart.
|
|
51
|
+
const checkMarketSources: RouteHandler = async ({ res }) => {
|
|
52
|
+
const sources = await deps.store.listMarketSources()
|
|
53
|
+
if (lastMarketCheck.size > 500) lastMarketCheck.clear()
|
|
54
|
+
const results: MarketCheckResponse['results'] = []
|
|
55
|
+
for (const source of sources) {
|
|
56
|
+
const base = { repo: source.repo, ...(source.ref !== undefined ? { ref: source.ref } : {}) }
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
const last = lastMarketCheck.get(source.repo) ?? 0
|
|
59
|
+
if (now - last < MIN_CHECK_INTERVAL_MS) {
|
|
60
|
+
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', throttled: true })
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const latestTag = await getLatestReleaseTag(source.repo)
|
|
65
|
+
if (source.ref === undefined) {
|
|
66
|
+
// Not pinned yet: report the latest release so the UI can
|
|
67
|
+
// suggest pinning, but never claim an update.
|
|
68
|
+
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', ...(latestTag !== undefined ? { latestTag } : {}) })
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const latest = await getLatestCommit(source.repo, source.ref)
|
|
72
|
+
lastMarketCheck.set(source.repo, now)
|
|
73
|
+
const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
|
|
74
|
+
const newRelease = latestTag !== undefined && latestTag !== source.ref
|
|
75
|
+
results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
|
|
76
|
+
} catch (error) {
|
|
77
|
+
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', error: errorText(error) })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
writeJson(res, 200, { ok: true, results } satisfies MarketCheckResponse)
|
|
81
|
+
}
|
|
45
82
|
return [
|
|
46
83
|
// --------------------------------------------------------------- market
|
|
47
84
|
// Market sources: the user adds repo slugs; each source can
|
|
@@ -117,44 +154,19 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
117
154
|
} satisfies MarketSourceVersionsResponse)
|
|
118
155
|
},
|
|
119
156
|
},
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// commit against the recorded baseline and surfaces newer releases.
|
|
123
|
-
// 5-minute throttle mirrors the source check.
|
|
157
|
+
// ------------------------------------------------- market/source/check
|
|
158
|
+
// Canonical path; the sibling sync route lives at market/source/sync.
|
|
124
159
|
{
|
|
125
160
|
path: SKILL_HUB_API.marketCheck,
|
|
126
161
|
methods: ['GET'],
|
|
127
|
-
handler:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (now - last < MIN_CHECK_INTERVAL_MS) {
|
|
136
|
-
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', throttled: true })
|
|
137
|
-
continue
|
|
138
|
-
}
|
|
139
|
-
try {
|
|
140
|
-
const latestTag = await getLatestReleaseTag(source.repo)
|
|
141
|
-
if (source.ref === undefined) {
|
|
142
|
-
// Not pinned yet: report the latest release so the UI can
|
|
143
|
-
// suggest pinning, but never claim an update.
|
|
144
|
-
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', ...(latestTag !== undefined ? { latestTag } : {}) })
|
|
145
|
-
continue
|
|
146
|
-
}
|
|
147
|
-
const latest = await getLatestCommit(source.repo, source.ref)
|
|
148
|
-
lastMarketCheck.set(source.repo, now)
|
|
149
|
-
const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
|
|
150
|
-
const newRelease = latestTag !== undefined && latestTag !== source.ref
|
|
151
|
-
results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
|
|
152
|
-
} catch (error) {
|
|
153
|
-
results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', error: errorText(error) })
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
writeJson(res, 200, { ok: true, results } satisfies MarketCheckResponse)
|
|
157
|
-
},
|
|
162
|
+
handler: checkMarketSources,
|
|
163
|
+
},
|
|
164
|
+
// Deprecated alias, kept for one release: a browser tab still running the
|
|
165
|
+
// previous client bundle calls the old path until it reloads.
|
|
166
|
+
{
|
|
167
|
+
path: SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
|
|
168
|
+
methods: ['GET'],
|
|
169
|
+
handler: checkMarketSources,
|
|
158
170
|
},
|
|
159
171
|
// ------------------------------------------------------- market/stats
|
|
160
172
|
// Stars + release-asset downloads per market source. Stale-while-revalidate:
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
normalizeRepoInput,
|
|
27
27
|
repoSlug,
|
|
28
28
|
skillManifest,
|
|
29
|
+
skillPathIn,
|
|
29
30
|
} from '../repo.ts'
|
|
30
31
|
import { rootPath } from '../skillfs.ts'
|
|
31
32
|
import { errorText } from '../error-text.ts'
|
|
@@ -158,7 +159,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
158
159
|
for (const entry of selected) {
|
|
159
160
|
if (controller.signal.aborted) break
|
|
160
161
|
job.current = entry.name
|
|
161
|
-
job.currentFile = entry.
|
|
162
|
+
job.currentFile = entry.path
|
|
162
163
|
if (entry.existing) {
|
|
163
164
|
job.skipped.push({ name: entry.name, reason: 'exists' })
|
|
164
165
|
job.downloadedBytes += entry.totalBytes
|
|
@@ -176,7 +177,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
176
177
|
try {
|
|
177
178
|
const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
|
|
178
179
|
job.downloadedBytes += bytes
|
|
179
|
-
job.currentFile = entry.dir
|
|
180
|
+
job.currentFile = skillPathIn(entry.dir, file)
|
|
180
181
|
})
|
|
181
182
|
await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
|
|
182
183
|
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
|
package/src/routes/sources.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
repoSkillEntry,
|
|
28
28
|
repoSlug,
|
|
29
29
|
skillDirOf,
|
|
30
|
+
skillFileAt,
|
|
30
31
|
skillManifest,
|
|
31
32
|
} from '../repo.ts'
|
|
32
33
|
import { clearTrash, restoreSkill, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
@@ -144,7 +145,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
144
145
|
// 上游可能用分类子目录(skills/engineering/<name>/)且目录会移动:
|
|
145
146
|
// 先在上游 tree 里搜真实位置,manifest 兜底,否则嵌套技能会 404。
|
|
146
147
|
entry.dir = skillDirOf(source, name, tree.map((item) => item.path))
|
|
147
|
-
entry.path = entry.dir
|
|
148
|
+
entry.path = skillFileAt(entry.dir)
|
|
148
149
|
const files = collectRepoSkillFiles(tree, entry.dir)
|
|
149
150
|
if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
|
|
150
151
|
const targetDir = join(targetRoot, name)
|
package/src/routes.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
7
|
import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
|
|
8
8
|
import { SkillHubStore, statePath } from './store.ts'
|
|
9
|
-
import { SKILL_HUB_API, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
|
|
9
|
+
import { SKILL_HUB_API, SKILL_HUB_API_DEPRECATED_MARKET_CHECK, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
|
|
10
10
|
|
|
11
11
|
/** Minimal response double recording status/headers/body. */
|
|
12
12
|
class FakeResponse {
|
|
@@ -495,6 +495,49 @@ describe('skill-hub routes', () => {
|
|
|
495
495
|
expect(body.saved).toEqual({ enabled: false })
|
|
496
496
|
})
|
|
497
497
|
|
|
498
|
+
it('never echoes the github token back from the config route', async () => {
|
|
499
|
+
const token = 'ghp_secretsecret123abc'
|
|
500
|
+
// GET with a token in effect: the flag says yes, no payload field carries it.
|
|
501
|
+
const getRoutes = makeRoutes({
|
|
502
|
+
...deps,
|
|
503
|
+
config: () => ({ enabled: true, announceToAgent: true, githubToken: token }),
|
|
504
|
+
saved: () => ({ githubToken: token }),
|
|
505
|
+
})
|
|
506
|
+
const get = new FakeResponse()
|
|
507
|
+
await getRoutes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('GET', SKILL_HUB_API.config), get as never)
|
|
508
|
+
expect(get.status).toBe(200)
|
|
509
|
+
const body = get.json() as ConfigResponse
|
|
510
|
+
expect(body.githubTokenSet).toBe(true)
|
|
511
|
+
expect(body.config).not.toHaveProperty('githubToken')
|
|
512
|
+
expect(body.saved).not.toHaveProperty('githubToken')
|
|
513
|
+
expect(get.body).not.toContain(token)
|
|
514
|
+
|
|
515
|
+
// POST, local merge path (no owner updateConfig).
|
|
516
|
+
const merged = new FakeResponse()
|
|
517
|
+
await getRoutes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('POST', SKILL_HUB_API.config, { enabled: true }), merged as never)
|
|
518
|
+
expect(merged.status).toBe(200)
|
|
519
|
+
expect((merged.json() as ConfigResponse).config).not.toHaveProperty('githubToken')
|
|
520
|
+
expect(merged.body).not.toContain(token)
|
|
521
|
+
|
|
522
|
+
// POST, owner updateConfig path.
|
|
523
|
+
const updateConfig = async (): Promise<HubConfig> => ({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, githubToken: token })
|
|
524
|
+
const postRoutes = makeRoutes({ ...deps, updateConfig })
|
|
525
|
+
const posted = new FakeResponse()
|
|
526
|
+
await postRoutes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('POST', SKILL_HUB_API.config, { enabled: true }), posted as never)
|
|
527
|
+
expect(posted.status).toBe(200)
|
|
528
|
+
const postBody = posted.json() as ConfigResponse
|
|
529
|
+
expect(postBody.githubTokenSet).toBe(true)
|
|
530
|
+
expect(postBody.config).not.toHaveProperty('githubToken')
|
|
531
|
+
expect(posted.body).not.toContain(token)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('reports githubTokenSet false when no token is configured', async () => {
|
|
535
|
+
const res = new FakeResponse()
|
|
536
|
+
await routeFor(SKILL_HUB_API.config).handler(fakeReq('GET', SKILL_HUB_API.config), res as never)
|
|
537
|
+
const body = res.json() as ConfigResponse
|
|
538
|
+
expect(body.githubTokenSet).toBe(false)
|
|
539
|
+
})
|
|
540
|
+
|
|
498
541
|
it('patches the hub config through the owner updateConfig', async () => {
|
|
499
542
|
const patches: Array<Partial<HubConfig>> = []
|
|
500
543
|
const updateConfig = async (patch: Partial<HubConfig>): Promise<HubConfig> => {
|
|
@@ -970,3 +1013,66 @@ describe('skill-hub routes', () => {
|
|
|
970
1013
|
})
|
|
971
1014
|
|
|
972
1015
|
})
|
|
1016
|
+
|
|
1017
|
+
describe('skill-hub API surface', () => {
|
|
1018
|
+
let dir: string
|
|
1019
|
+
let home: string
|
|
1020
|
+
let store: SkillHubStore
|
|
1021
|
+
let deps: SkillHubRouteDeps
|
|
1022
|
+
|
|
1023
|
+
beforeEach(async () => {
|
|
1024
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-api-'))
|
|
1025
|
+
home = join(dir, 'home')
|
|
1026
|
+
await mkdir(join(home, 'skills'), { recursive: true })
|
|
1027
|
+
store = new SkillHubStore(statePath(home))
|
|
1028
|
+
deps = { skills: { snapshot: async () => ({ skills: [], complete: true }), get: async () => undefined }, store, home }
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
afterEach(async () => {
|
|
1032
|
+
await rm(dir, { recursive: true, force: true })
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
it('keeps every path inside the family root and registered', () => {
|
|
1036
|
+
const routes = makeRoutes(deps)
|
|
1037
|
+
const registered = new Set(routes.filter((route) => route.kind === 'exact').map((route) => route.path))
|
|
1038
|
+
const declared = Object.values(SKILL_HUB_API)
|
|
1039
|
+
for (const path of declared) expect(path.startsWith(SKILL_HUB_API_ROOT + '/')).toBe(true)
|
|
1040
|
+
// 常量与注册表互为子集:一个声明了没注册(客户端必然 404),或注册了没声明
|
|
1041
|
+
// (客户端永远调不到),都是漂移。唯一的豁免是显式弃用别名。
|
|
1042
|
+
expect([...registered].sort()).toEqual([...declared, SKILL_HUB_API_DEPRECATED_MARKET_CHECK].sort())
|
|
1043
|
+
expect(registered.has(SKILL_HUB_API_DEPRECATED_MARKET_CHECK)).toBe(true)
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
it('pairs the market update check with the sync that acts on it', () => {
|
|
1047
|
+
// 曾经的坑:check 在 /market/check,sync 在 /market/source/sync,
|
|
1048
|
+
// 写客户端时很容易把同步写成 /market/sync(未命中路径还会回 401)。
|
|
1049
|
+
expect(SKILL_HUB_API.marketCheck).toBe('/api/skill-hub/market/source/check')
|
|
1050
|
+
for (const path of [SKILL_HUB_API.marketCheck, SKILL_HUB_API.marketSync]) {
|
|
1051
|
+
expect(path.startsWith('/api/skill-hub/market/source/')).toBe(true)
|
|
1052
|
+
}
|
|
1053
|
+
expect(SKILL_HUB_API_DEPRECATED_MARKET_CHECK).not.toBe(SKILL_HUB_API.marketCheck)
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
it('answers the deprecated market-check alias instead of 404', async () => {
|
|
1057
|
+
const routes = makeRoutes(deps)
|
|
1058
|
+
const route = routes.find((r) => r.path === SKILL_HUB_API_DEPRECATED_MARKET_CHECK)
|
|
1059
|
+
expect(route).toBeDefined()
|
|
1060
|
+
const res = new FakeResponse()
|
|
1061
|
+
await route?.handler(fakeReq('GET', SKILL_HUB_API_DEPRECATED_MARKET_CHECK), res as never)
|
|
1062
|
+
// Same handler as the canonical route: a list of per-source results.
|
|
1063
|
+
expect(res.status).toBe(200)
|
|
1064
|
+
expect((res.json() as { results: unknown[] }).results).toEqual([])
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
it('registers one 404 catch-all covering the whole family', async () => {
|
|
1068
|
+
const routes = makeRoutes(deps)
|
|
1069
|
+
const catchAll = routes.filter((route) => route.kind === 'prefix')
|
|
1070
|
+
expect(catchAll).toHaveLength(1)
|
|
1071
|
+
expect(catchAll[0].path).toBe(SKILL_HUB_API_ROOT)
|
|
1072
|
+
// 未知路径给出写明路径的 404,而不是让请求落到宿主 fallback 的 401。
|
|
1073
|
+
const res = new FakeResponse()
|
|
1074
|
+
await catchAll[0].handler(fakeReq('GET', '/api/skill-hub/market/sync'), res as never)
|
|
1075
|
+
expect(res.status).toBe(404)
|
|
1076
|
+
expect((res.json() as ErrorResponse).error).toContain('/api/skill-hub/market/sync')
|
|
1077
|
+
})
|
|
1078
|
+
})
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
16
|
-
import {
|
|
16
|
+
import { SKILL_HUB_API_ROOT } from './protocol.ts'
|
|
17
|
+
import { createRoute, writeError, type SkillHubRouteDeps } from './routes/helpers.ts'
|
|
17
18
|
import { catalogRoutes } from './routes/catalog.ts'
|
|
18
19
|
import { configRoutes } from './routes/config.ts'
|
|
19
20
|
import { marketRoutes } from './routes/market.ts'
|
|
@@ -26,7 +27,7 @@ export type { SkillHubRouteDeps, SkillLookupLike } from './routes/helpers.ts'
|
|
|
26
27
|
/**
|
|
27
28
|
* Build every /api/skill-hub route.
|
|
28
29
|
* @param deps - skill registry view + sidecar store.
|
|
29
|
-
* @returns the exact-path routes.
|
|
30
|
+
* @returns the exact-path routes plus the family's 404 catch-all.
|
|
30
31
|
*/
|
|
31
32
|
export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
32
33
|
// 各域返回裸 spec,统一在这里包上请求围栏(回环信任 → 方法 → 总开关
|
|
@@ -39,5 +40,22 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
39
40
|
...groupRoutes(deps),
|
|
40
41
|
...sourceRoutes(deps),
|
|
41
42
|
]
|
|
42
|
-
return
|
|
43
|
+
return [
|
|
44
|
+
...specs.map((spec) => createRoute(deps, spec)),
|
|
45
|
+
// 兜底:本族的未知路径。插件只注册精确路径,未命中的请求会落到宿主的
|
|
46
|
+
// SPA fallback,那里对 /api/* 统一回 401,排查时极易被误判成鉴权/令牌
|
|
47
|
+
// 问题,而真实原因是路径写错。这个 prefix 路由把整族纳入命名路由表,
|
|
48
|
+
// 于是未知路径在宿主侧变成「已命中」→ 由我们回一个写明路径的 404。
|
|
49
|
+
// 匹配顺序保证安全:宿主先查精确表、再按最长前缀命中,本路由不可能
|
|
50
|
+
// 抢走任何已注册的精确路由。
|
|
51
|
+
createRoute(deps, {
|
|
52
|
+
kind: 'prefix',
|
|
53
|
+
path: SKILL_HUB_API_ROOT,
|
|
54
|
+
methods: ['GET', 'POST'],
|
|
55
|
+
skipGate: true,
|
|
56
|
+
handler: async ({ res, url }) => {
|
|
57
|
+
writeError(res, 404, 'unknown skill-hub route: ' + url.pathname + ' (see SKILL_HUB_API in src/protocol/api.ts)')
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
]
|
|
43
61
|
}
|
package/src/store/migrate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DisabledSkill, HubConfig, MarketSourceRecord, MarketStatsSnapshot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from '../protocol.ts'
|
|
2
|
+
import { isValidRepoRoot } from '../protocol/repo.ts'
|
|
2
3
|
import { STORE_VERSION } from './paths.ts'
|
|
3
4
|
|
|
4
5
|
/** Normalized raw sidecar document after schema migration (fields still unvalidated). */
|
|
@@ -149,7 +150,7 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
|
|
|
149
150
|
const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
|
|
150
151
|
if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
|
|
151
152
|
const manifest = source.manifest as Record<string, unknown> | undefined
|
|
152
|
-
const rawRoot =
|
|
153
|
+
const rawRoot = isValidRepoRoot(source.root) ? source.root : 'skills'
|
|
153
154
|
sourcesByRepo.set(source.repo, {
|
|
154
155
|
repo: source.repo,
|
|
155
156
|
...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
|
|
@@ -190,7 +191,7 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
|
|
|
190
191
|
path: item.path,
|
|
191
192
|
movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
|
|
192
193
|
...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
|
|
193
|
-
...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' &&
|
|
194
|
+
...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && isValidRepoRoot(origin.root)
|
|
194
195
|
? {
|
|
195
196
|
origin: {
|
|
196
197
|
repo: origin.repo,
|
package/src/store/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import type { DisabledSkill, HubConfig, MarketSourceRecord, MarketStatsSnapshot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from '../protocol.ts'
|
|
4
|
+
import { skillDirPrefix } from '../repo/discovery.ts'
|
|
4
5
|
import { StoreError } from './errors.ts'
|
|
5
6
|
import { hydrateMigratedState, migrateStore } from './migrate.ts'
|
|
6
7
|
import { DEFAULT_SCENE_NAME, STORE_VERSION, statePath, type StoreFile } from './paths.ts'
|
|
@@ -351,14 +352,18 @@ export class SkillHubStore {
|
|
|
351
352
|
* When `dir` is given, every baseline path under that skill directory is
|
|
352
353
|
* dropped first, so files the upstream removed never linger in the
|
|
353
354
|
* baseline and skew later update diffs.
|
|
355
|
+
*
|
|
356
|
+
* `dir` may be '' — a skill whose SKILL.md sits at the repo root. Its prefix
|
|
357
|
+
* is empty, so the whole baseline is replaced: that skill owns the tree, and
|
|
358
|
+
* keeping stale paths would make every later diff report "changed" forever.
|
|
354
359
|
*/
|
|
355
360
|
async mergeSourceManifest(repo: string, manifest: Record<string, number>, dir?: string): Promise<void> {
|
|
356
361
|
await this.ensureLoaded()
|
|
357
362
|
const existing = this.sourcesByRepo.get(repo)
|
|
358
363
|
if (existing === undefined || Object.keys(manifest).length === 0) return
|
|
359
364
|
const base: Record<string, number> = { ...(existing.manifest ?? {}) }
|
|
360
|
-
if (dir !== undefined
|
|
361
|
-
const prefix = dir
|
|
365
|
+
if (dir !== undefined) {
|
|
366
|
+
const prefix = skillDirPrefix(dir)
|
|
362
367
|
for (const path of Object.keys(base)) {
|
|
363
368
|
if (path.startsWith(prefix)) delete base[path]
|
|
364
369
|
}
|
package/src/store.test.ts
CHANGED
|
@@ -125,6 +125,26 @@ describe('SkillHubStore', () => {
|
|
|
125
125
|
expect(again[0].manifest).toEqual({ 'skills/docx/SKILL.md': 120 })
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
it('drops only the given skill directory from the baseline', async () => {
|
|
129
|
+
await store.addSourceSkill('a/b', 'skills', 'sha-1', undefined, 'docx')
|
|
130
|
+
await store.addSourceSkill('a/b', 'skills', 'sha-1', undefined, 'pdf')
|
|
131
|
+
await store.mergeSourceManifest('a/b', { 'skills/docx/SKILL.md': 1, 'skills/docx/gone.md': 2, 'skills/pdf/SKILL.md': 3 })
|
|
132
|
+
// Re-merging docx must drop its stale baseline without touching pdf.
|
|
133
|
+
await store.mergeSourceManifest('a/b', { 'skills/docx/SKILL.md': 1 }, 'skills/docx')
|
|
134
|
+
const sources = await store.listSources()
|
|
135
|
+
expect(sources[0].manifest).toEqual({ 'skills/pdf/SKILL.md': 3, 'skills/docx/SKILL.md': 1 })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('replaces the whole baseline for a skill at the repo root', async () => {
|
|
139
|
+
await store.addSourceSkill('blader/humanizer', '', 'sha-1', undefined, 'humanizer')
|
|
140
|
+
await store.mergeSourceManifest('blader/humanizer', { 'SKILL.md': 1, 'old.md': 2 }, '')
|
|
141
|
+
await store.mergeSourceManifest('blader/humanizer', { 'SKILL.md': 900, 'README.md': 30 }, '')
|
|
142
|
+
const sources = await store.listSources()
|
|
143
|
+
// The empty prefix covers the whole tree, so the upstream-dropped old.md
|
|
144
|
+
// must not linger — otherwise the diff would report "changed" forever.
|
|
145
|
+
expect(sources[0].manifest).toEqual({ 'SKILL.md': 900, 'README.md': 30 })
|
|
146
|
+
})
|
|
147
|
+
|
|
128
148
|
it('derives origins from sources and drops empty sources', async () => {
|
|
129
149
|
await store.addSourceSkill('repo/a', 'skills', '', undefined, 'one')
|
|
130
150
|
await store.addSourceSkill('repo/a', 'skills', '', undefined, 'two')
|
|
@@ -250,6 +270,36 @@ describe('SkillHubStore', () => {
|
|
|
250
270
|
expect(await fresh.listDisabled()).toEqual([])
|
|
251
271
|
})
|
|
252
272
|
|
|
273
|
+
it('keeps an empty root through a reload (repo-root skill), still coercing a corrupt one', async () => {
|
|
274
|
+
// 空 root 是「技能在仓库根」的合法取值:装载时把它改写成 skills 会让来源
|
|
275
|
+
// 对不上任何 blob,下一次「检查更新」就会把技能当成被上游删除 → 进回收站。
|
|
276
|
+
await writeFile(file, JSON.stringify({
|
|
277
|
+
version: 4,
|
|
278
|
+
sources: [
|
|
279
|
+
{ repo: 'blader/humanizer', root: '', commitSha: 'sha-1', skills: ['humanizer'], manifest: { 'SKILL.md': 900 } },
|
|
280
|
+
{ repo: 'a/b', root: '.hidden', commitSha: 'sha-2', skills: ['x'] },
|
|
281
|
+
],
|
|
282
|
+
}), 'utf8')
|
|
283
|
+
const sources = await store.listSources()
|
|
284
|
+
expect(sources.find((s) => s.repo === 'blader/humanizer')?.root).toBe('')
|
|
285
|
+
// 损坏的 root 仍然回落到默认根。
|
|
286
|
+
expect(sources.find((s) => s.repo === 'a/b')?.root).toBe('skills')
|
|
287
|
+
// 写盘-再读一次仍然保持空 root。
|
|
288
|
+
await store.addMarketSource('new/repo')
|
|
289
|
+
const reloaded = new SkillHubStore(file)
|
|
290
|
+
expect((await reloaded.listSources()).find((s) => s.repo === 'blader/humanizer')?.root).toBe('')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('keeps the origin of a trashed repo-root skill', async () => {
|
|
294
|
+
await writeFile(file, JSON.stringify({
|
|
295
|
+
version: 4,
|
|
296
|
+
trash: [{ name: 'humanizer', path: '/tmp/trash/humanizer', movedAt: 1, origin: { repo: 'blader/humanizer', root: '', commitSha: 'sha-1' } }],
|
|
297
|
+
}), 'utf8')
|
|
298
|
+
const entry = (await store.listTrash()).find((e) => e.name === 'humanizer')
|
|
299
|
+
// 空 root 曾被 sanitizer 丢掉整个 origin → 恢复后技能会变成「个人技能」。
|
|
300
|
+
expect(entry?.origin).toMatchObject({ repo: 'blader/humanizer', root: '' })
|
|
301
|
+
})
|
|
302
|
+
|
|
253
303
|
it('migrates a v3 file (no skillStats) and persists a checkpoint round-trip at v4', async () => {
|
|
254
304
|
// v3 旧文件:无 skillStats 字段 → 装载后检查点为空,写盘时版本升到当前。
|
|
255
305
|
await writeFile(file, JSON.stringify({
|
package/src/update.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { errorText } from './error-text.ts'
|
|
12
|
-
import {
|
|
12
|
+
import { apiHeaders } from './repo.ts'
|
|
13
13
|
import type { UpdateCheckResponse } from './protocol.ts'
|
|
14
14
|
|
|
15
15
|
/** Repository checked for releases. Keep in sync with package.json. */
|
|
@@ -51,7 +51,9 @@ export async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl: typeof f
|
|
|
51
51
|
let response: Response
|
|
52
52
|
try {
|
|
53
53
|
response = await fetchImpl(url, {
|
|
54
|
-
|
|
54
|
+
// apiHeaders() 而非手写等价物:它带上 `accept-encoding: identity`,
|
|
55
|
+
// 缺了它在「启动环境带代理」时响应会变成解不开的 gzip(见 github-client.ts)。
|
|
56
|
+
headers: apiHeaders(),
|
|
55
57
|
})
|
|
56
58
|
} catch (error) {
|
|
57
59
|
return failure(errorText(error))
|