dsh-skill-hub 0.3.18 → 0.3.20
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 +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +14 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +187 -102
- package/lib/types/client/api.d.ts +3 -3
- package/lib/types/client/locales/market.d.ts +1 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/protocol/config.d.ts +17 -11
- package/lib/types/protocol/market.d.ts +11 -0
- package/lib/types/protocol/repo.d.ts +6 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/repo/github-client.d.ts +11 -4
- package/lib/types/repo.d.ts +1 -1
- package/lib/types/routes/catalog-data.d.ts +11 -2
- package/lib/types/routes/deps.d.ts +6 -1
- package/lib/types/routes/route-state.d.ts +5 -0
- package/lib/types/skillfs/scan.d.ts +6 -5
- package/lib/types/skillfs.d.ts +25 -1
- package/package.json +22 -20
- package/src/client/api.ts +4 -3
- package/src/client/locales/market.ts +2 -0
- package/src/client/panel/RepoScanCard.tsx +2 -1
- package/src/client/panel/hooks/useMarketFlow.ts +4 -2
- package/src/client/panel/panel.module.css +10 -2
- package/src/client/settings-card.tsx +2 -1
- package/src/client/settings-form.ts +6 -3
- package/src/index.ts +1 -1
- package/src/protocol/config.ts +27 -24
- package/src/protocol/market.ts +12 -0
- package/src/protocol/repo.ts +6 -0
- package/src/protocol.ts +1 -0
- package/src/repo/github-client.ts +26 -14
- package/src/repo/install.ts +5 -2
- package/src/repo.test.ts +40 -1
- package/src/repo.ts +1 -1
- package/src/routes/catalog-data.ts +20 -6
- package/src/routes/catalog.ts +4 -9
- package/src/routes/deps.ts +7 -2
- package/src/routes/market.ts +13 -2
- package/src/routes/repo-import.ts +49 -8
- package/src/routes/route-state.test.ts +1 -0
- package/src/routes/route-state.ts +5 -0
- package/src/routes.test.ts +187 -0
- package/src/skillfs/scan.ts +30 -17
- package/src/skillfs.test.ts +64 -3
- package/src/skillfs.ts +39 -8
- package/src/update.ts +6 -6
package/src/repo.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
|
|
5
|
+
import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, rateLimitResetAt, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
|
|
6
6
|
import type { RepoTreeItem } from './repo.ts'
|
|
7
7
|
import type { RepoSkillEntry } from './protocol.ts'
|
|
8
8
|
|
|
@@ -366,6 +366,26 @@ describe('downloadGitHubFile', () => {
|
|
|
366
366
|
expect(buffer.toString('utf8')).toContain('name: demo')
|
|
367
367
|
expect(calls.length).toBe(2)
|
|
368
368
|
expect(calls[1]).toContain('api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
|
|
369
|
+
expect(calls[1]).toContain('ref=main')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// issue #15: the fallback dropped the ref, so /contents/ served the default
|
|
373
|
+
// branch and a tag/branch-pinned import silently mixed main-branch content.
|
|
374
|
+
it('keeps the requested ref on the api fallback (never the default branch)', async () => {
|
|
375
|
+
const calls: string[] = []
|
|
376
|
+
const ref = 'andibrae/create-top-level-namespace'
|
|
377
|
+
const fetchImpl = async (url: string) => {
|
|
378
|
+
calls.push(url)
|
|
379
|
+
if (url.startsWith('https://raw.githubusercontent.com/')) throw new Error('raw blocked')
|
|
380
|
+
if (url.includes('/contents/')) return new Response('---\nname: demo\ndescription: x\n---\n\nref body', { status: 200 })
|
|
381
|
+
return new Response('nope', { status: 599 })
|
|
382
|
+
}
|
|
383
|
+
await downloadGitHubFile('example/repo', ref, 'skills/demo/SKILL.md', fetchImpl as typeof fetch)
|
|
384
|
+
expect(calls[1]).toBe(
|
|
385
|
+
'https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md?ref=' + encodeURIComponent(ref),
|
|
386
|
+
)
|
|
387
|
+
// The un-ref'd form is exactly what served default-branch content.
|
|
388
|
+
expect(calls[1]).not.toBe('https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
|
|
369
389
|
})
|
|
370
390
|
|
|
371
391
|
it('reports the original error when both hosts fail', async () => {
|
|
@@ -429,3 +449,22 @@ describe('GitHub 请求头', () => {
|
|
|
429
449
|
expect(apiHeaders().accept).toBe('application/vnd.github+json')
|
|
430
450
|
})
|
|
431
451
|
})
|
|
452
|
+
|
|
453
|
+
describe('rateLimitResetAt', () => {
|
|
454
|
+
const response = (status: number, headers: Record<string, string>): Response => new Response('', { status, headers })
|
|
455
|
+
const exhausted = { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1700000000' }
|
|
456
|
+
|
|
457
|
+
it('reports the reset instant for an exhausted 403 or 429', () => {
|
|
458
|
+
expect(rateLimitResetAt(response(403, exhausted))?.getTime()).toBe(1700000000 * 1000)
|
|
459
|
+
expect(rateLimitResetAt(response(429, exhausted))?.getTime()).toBe(1700000000 * 1000)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('is undefined for anything that is not an exhausted rate limit', () => {
|
|
463
|
+
expect(rateLimitResetAt(response(500, exhausted))).toBeUndefined()
|
|
464
|
+
expect(rateLimitResetAt(response(404, exhausted))).toBeUndefined()
|
|
465
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '57', 'x-ratelimit-reset': '1700000000' }))).toBeUndefined()
|
|
466
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0' }))).toBeUndefined()
|
|
467
|
+
// 头部存在但不是数字:宁可退回普通 HTTP 文案,也不要报 "Invalid Date"。
|
|
468
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': 'nope' }))).toBeUndefined()
|
|
469
|
+
})
|
|
470
|
+
})
|
package/src/repo.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// 后向兼容:老 `from './repo.ts'` 写法继续可用,新代码可直引 github-client。
|
|
17
|
-
export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
|
|
17
|
+
export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, rateLimitResetAt, setGithubToken } from './repo/github-client.ts'
|
|
18
18
|
|
|
19
19
|
export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts'
|
|
20
20
|
|
|
@@ -17,10 +17,21 @@ import { rootPath, readSkillInterface, scanDiagnostics, type SkillInterface } fr
|
|
|
17
17
|
import { CURRENT_VERSION } from '../update.ts'
|
|
18
18
|
import { homeOf, isWritableSource, type SkillHubRouteDeps } from './deps.ts'
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
|
|
22
|
+
* 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
|
|
23
|
+
*
|
|
24
|
+
* 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
|
|
25
|
+
* 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
|
|
26
|
+
* (issue #16)。
|
|
27
|
+
*/
|
|
28
|
+
export async function knownSkillNames(
|
|
29
|
+
deps: SkillHubRouteDeps,
|
|
30
|
+
options?: { includeDisabled?: boolean },
|
|
31
|
+
): Promise<Set<string>> {
|
|
22
32
|
const snapshot = await deps.skills.snapshot()
|
|
23
33
|
const names = new Set(snapshot.skills.map((skill) => skill.name))
|
|
34
|
+
if (options?.includeDisabled === false) return names
|
|
24
35
|
for (const disabled of await deps.store.listDisabled()) names.add(disabled.name)
|
|
25
36
|
return names
|
|
26
37
|
}
|
|
@@ -188,10 +199,13 @@ export async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promi
|
|
|
188
199
|
if (iface !== undefined) applyInterface(row, iface)
|
|
189
200
|
return row
|
|
190
201
|
})
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
202
|
+
// 两个 root 的诊断扫描互不依赖:面板每 5 秒轮询一次 /catalog,串行会让响应
|
|
203
|
+
// 随技能数量线性变慢。扫描内部的逐条读盘也已是并发的(见 scanDiagnostics)。
|
|
204
|
+
const [userDshDiagnostics, userAgentsDiagnostics] = await Promise.all([
|
|
205
|
+
scanDiagnostics('user-dsh', home),
|
|
206
|
+
scanDiagnostics('user-agents', home),
|
|
207
|
+
])
|
|
208
|
+
const diagnostics = [...userDshDiagnostics, ...userAgentsDiagnostics]
|
|
195
209
|
return {
|
|
196
210
|
ok: true,
|
|
197
211
|
pluginVersion: CURRENT_VERSION,
|
package/src/routes/catalog.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { dirname, join } 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, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
21
|
+
import { createSkill, disableSkill, enableSkill, moveIntoTrash, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
22
22
|
import { errorText } from '../error-text.ts'
|
|
23
23
|
import {
|
|
24
24
|
applyInterface,
|
|
@@ -156,12 +156,7 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
156
156
|
}
|
|
157
157
|
// 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
|
|
158
158
|
const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
|
|
159
|
-
|
|
160
|
-
const trashDir = join(dirname(sourceToTrash), '.trash')
|
|
161
|
-
await mkdir(trashDir, { recursive: true })
|
|
162
|
-
const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
|
|
163
|
-
await rename(sourceToTrash, target)
|
|
164
|
-
trashResult = { path: target, source: sourceToTrash }
|
|
159
|
+
trashResult = await moveIntoTrash(isBundleDisabled ? dirname(disabled.path) : disabled.path)
|
|
165
160
|
} else {
|
|
166
161
|
writeError(res, resolved.status, resolved.error); return
|
|
167
162
|
}
|
package/src/routes/deps.ts
CHANGED
|
@@ -43,10 +43,15 @@ export interface SkillHubRouteDeps {
|
|
|
43
43
|
|
|
44
44
|
/** The resolved hub config a route sees (the shared resolver fills defaults). */
|
|
45
45
|
export function configOf(deps: SkillHubRouteDeps): HubConfig {
|
|
46
|
-
return resolveHubConfig(
|
|
46
|
+
return resolveHubConfig(deps.config?.() ?? {})
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The raw saved config layer as persisted in the sidecar, reported for display
|
|
51
|
+
* only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
|
|
52
|
+
* is legacy data: nothing writes it any more and the effective config above
|
|
53
|
+
* never merges it.
|
|
54
|
+
*/
|
|
50
55
|
export function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig> {
|
|
51
56
|
return deps.saved?.() ?? {}
|
|
52
57
|
}
|
package/src/routes/market.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {
|
|
8
8
|
SKILL_HUB_API,
|
|
9
9
|
type MarketCheckResponse,
|
|
10
|
+
type MarketSourceAddResponse,
|
|
10
11
|
type MarketSourceResponse,
|
|
11
12
|
type MarketSourcesResponse,
|
|
12
13
|
type MarketSourceVersionsResponse,
|
|
@@ -60,6 +61,13 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
60
61
|
}
|
|
61
62
|
try {
|
|
62
63
|
const latestTag = await getLatestReleaseTag(source.repo)
|
|
64
|
+
// Book the attempt as soon as the GitHub call has been spent, before
|
|
65
|
+
// the unpinned branch below can `continue`: otherwise an unpinned
|
|
66
|
+
// source never records a timestamp and the 5-minute throttle promised
|
|
67
|
+
// above never applies to it (every check re-hits /releases/latest).
|
|
68
|
+
// A call that throws is deliberately not booked, so a failing repo is
|
|
69
|
+
// retried on the next check — same as the source check in sources.ts.
|
|
70
|
+
lastMarketCheck.set(source.repo, now)
|
|
63
71
|
if (source.ref === undefined) {
|
|
64
72
|
// Not pinned yet: report the latest release so the UI can
|
|
65
73
|
// suggest pinning, but never claim an update.
|
|
@@ -67,7 +75,6 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
67
75
|
continue
|
|
68
76
|
}
|
|
69
77
|
const latest = await getLatestCommit(source.repo, source.ref)
|
|
70
|
-
lastMarketCheck.set(source.repo, now)
|
|
71
78
|
const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
|
|
72
79
|
const newRelease = latestTag !== undefined && latestTag !== source.ref
|
|
73
80
|
results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
|
|
@@ -99,7 +106,11 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
99
106
|
// An explicit @ref pins the source immediately (e.g. repo@v1.2.3);
|
|
100
107
|
// otherwise the first scan resolves latest-release / branch choice.
|
|
101
108
|
const ref = parsed.ref
|
|
102
|
-
|
|
109
|
+
const slug = repoSlug(parsed)
|
|
110
|
+
// Return the normalized slug explicitly: the client scans it right
|
|
111
|
+
// away, and `repos` order carries no "this one" signal (re-adding an
|
|
112
|
+
// existing repo only rewrites its ref in place).
|
|
113
|
+
writeJson(res, 200, { ok: true, repo: slug, repos: await deps.store.addMarketSource(slug, ref) } satisfies MarketSourceAddResponse)
|
|
103
114
|
},
|
|
104
115
|
},
|
|
105
116
|
{
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { mkdir } from 'node:fs/promises'
|
|
7
|
-
import { join } from 'node:path'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
8
|
import { randomUUID } from 'node:crypto'
|
|
9
9
|
import {
|
|
10
10
|
SKILL_HUB_API,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
skillManifest,
|
|
29
29
|
skillPathIn,
|
|
30
30
|
} from '../repo.ts'
|
|
31
|
-
import { rootPath } from '../skillfs.ts'
|
|
31
|
+
import { moveIntoTrash, rootPath } from '../skillfs.ts'
|
|
32
32
|
import { errorText } from '../error-text.ts'
|
|
33
33
|
import {
|
|
34
34
|
homeOf,
|
|
@@ -44,6 +44,29 @@ import {
|
|
|
44
44
|
} from './helpers.ts'
|
|
45
45
|
import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
|
|
49
|
+
* 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
|
|
50
|
+
* 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
|
|
51
|
+
*
|
|
52
|
+
* 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
|
|
53
|
+
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
54
|
+
*/
|
|
55
|
+
async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
|
|
56
|
+
const record = await deps.store.getDisabled(name)
|
|
57
|
+
if (record === undefined) return false
|
|
58
|
+
const dir = join(targetRoot, name)
|
|
59
|
+
const sourceToTrash = record.path.endsWith('SKILL.md.disabled') ? dirname(record.path) : record.path
|
|
60
|
+
if (sourceToTrash !== dir) return false
|
|
61
|
+
// 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
|
|
62
|
+
if (await pathExists(join(dir, 'SKILL.md'))) return false
|
|
63
|
+
// 与 skill/delete 走同一个回收站落盘规则(moveIntoTrash),恢复流程依赖它。
|
|
64
|
+
const { path: target } = await moveIntoTrash(sourceToTrash)
|
|
65
|
+
await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
|
|
66
|
+
await deps.store.removeDisabled(name)
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
48
71
|
export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
49
72
|
return [
|
|
@@ -86,7 +109,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
86
109
|
}
|
|
87
110
|
}
|
|
88
111
|
const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref)
|
|
89
|
-
|
|
112
|
+
// 发现态也只看「已启用」:否则已禁用占位会把真技能标成「已存在」并禁掉勾选(issue #16)。
|
|
113
|
+
const existing = await knownSkillNames(deps, { includeDisabled: false })
|
|
90
114
|
const entries = discoverRepoEntries(tree, repo, existing)
|
|
91
115
|
writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries, ...(truncated ? { truncated: true } : {}) } satisfies RepoDiscoverResponse)
|
|
92
116
|
},
|
|
@@ -108,7 +132,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
108
132
|
const requestedRef = readString(body, 'ref')
|
|
109
133
|
const ref = requestedRef !== '' ? requestedRef : parsed.ref
|
|
110
134
|
const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
|
|
111
|
-
|
|
135
|
+
// 导入去重同 discover:已禁用不算「已存在」(issue #16)。
|
|
136
|
+
const existing = await knownSkillNames(deps, { includeDisabled: false })
|
|
112
137
|
const entries = discoverRepoEntries(tree, repo, existing)
|
|
113
138
|
// truncated tree is still usable for the selected paths the user already picked from the prior discover response
|
|
114
139
|
const byPath = new Map(entries.map((entry) => [entry.path, entry]))
|
|
@@ -139,6 +164,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
139
164
|
imported: [],
|
|
140
165
|
skipped: [],
|
|
141
166
|
failed: [],
|
|
167
|
+
replaced: [],
|
|
142
168
|
status: 'running',
|
|
143
169
|
controller,
|
|
144
170
|
createdAt: now,
|
|
@@ -166,11 +192,25 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
166
192
|
job.done += 1
|
|
167
193
|
continue
|
|
168
194
|
}
|
|
195
|
+
// 磁盘上有同名目录。若它只是一个已禁用的占位(目录里只剩
|
|
196
|
+
// SKILL.md.disabled),先把旧目录移入回收站再继续导入;移不动或
|
|
197
|
+
// 不是这种形态时退回原本的「跳过」(issue #16)。
|
|
169
198
|
if (await pathExists(join(targetRoot, entry.name))) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
let cleared = false
|
|
200
|
+
try {
|
|
201
|
+
cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
|
|
202
|
+
} catch {
|
|
203
|
+
cleared = false
|
|
204
|
+
}
|
|
205
|
+
if (!cleared) {
|
|
206
|
+
job.skipped.push({ name: entry.name, reason: 'exists' })
|
|
207
|
+
job.downloadedBytes += entry.totalBytes
|
|
208
|
+
job.done += 1
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
// 旧目录已经进了回收站(可恢复)。即使随后的下载失败也必须如实
|
|
212
|
+
// 上报,否则用户不知道自己的占位被挪走了。
|
|
213
|
+
job.replaced.push(entry.name)
|
|
174
214
|
}
|
|
175
215
|
if (controller.signal.aborted) break
|
|
176
216
|
const files = collectRepoSkillFiles(tree, entry.dir)
|
|
@@ -241,6 +281,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
241
281
|
imported: [...job.imported],
|
|
242
282
|
skipped: [...job.skipped],
|
|
243
283
|
failed: [...job.failed],
|
|
284
|
+
...(job.replaced.length > 0 ? { replaced: [...job.replaced] } : {}),
|
|
244
285
|
...(job.error !== undefined ? { error: job.error } : {}),
|
|
245
286
|
} satisfies RepoImportProgressResponse)
|
|
246
287
|
},
|
|
@@ -51,6 +51,11 @@ export interface ImportJob {
|
|
|
51
51
|
imported: Array<{ name: string; origin: string; path: string }>
|
|
52
52
|
skipped: Array<{ name: string; reason: 'exists' }>
|
|
53
53
|
failed: Array<{ name: string; error: string }>
|
|
54
|
+
/**
|
|
55
|
+
* 导入时被移入回收站让位的「已禁用同名占位」名字(issue #16)。占位已挪走
|
|
56
|
+
* 即计入,即使随后的下载失败也在列 —— 否则用户不知道自己的占位不见了。
|
|
57
|
+
*/
|
|
58
|
+
replaced: string[]
|
|
54
59
|
status: 'running' | 'done' | 'cancelled' | 'error'
|
|
55
60
|
error?: string
|
|
56
61
|
controller: AbortController
|
package/src/routes.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
|
|
8
|
+
import { lastMarketCheck } from './routes/route-state.ts'
|
|
8
9
|
import { SkillHubStore, statePath } from './store.ts'
|
|
9
10
|
import { SKILL_HUB_API, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
|
|
10
11
|
|
|
@@ -645,6 +646,73 @@ describe('skill-hub routes', () => {
|
|
|
645
646
|
expect((del.json() as import('./protocol.ts').MarketSourceResponse).repos).toEqual([{ repo: 'other/repo', ref: 'v2.0.0' }])
|
|
646
647
|
})
|
|
647
648
|
|
|
649
|
+
// 回归:重复添加一个已存在的源只改它的 ref、不重排列表,所以响应必须回传
|
|
650
|
+
// 服务端归一化的 slug —— 客户端不能靠 `repos[repos.length - 1]` 猜操作对象。
|
|
651
|
+
it('returns the resolved slug of the added market source, not a list position', async () => {
|
|
652
|
+
const add = async (repo: string): Promise<import('./protocol.ts').MarketSourceAddResponse> => {
|
|
653
|
+
const res = new FakeResponse()
|
|
654
|
+
await routeFor(SKILL_HUB_API.marketSource).handler(fakeReq('POST', SKILL_HUB_API.marketSource, { repo }), res as never)
|
|
655
|
+
expect(res.status).toBe(200)
|
|
656
|
+
return res.json() as import('./protocol.ts').MarketSourceAddResponse
|
|
657
|
+
}
|
|
658
|
+
await add('first/repo')
|
|
659
|
+
await add('second/repo')
|
|
660
|
+
const again = await add('first/repo@v9.9.9')
|
|
661
|
+
expect(again.repo).toBe('first/repo')
|
|
662
|
+
// 本次操作对象并不在末位:按位置猜会拿到 second/repo。
|
|
663
|
+
expect(again.repos[again.repos.length - 1].repo).toBe('second/repo')
|
|
664
|
+
expect(again.repos).toEqual([{ repo: 'first/repo', ref: 'v9.9.9' }, { repo: 'second/repo' }])
|
|
665
|
+
// URL 输入也归一到同一个 slug。
|
|
666
|
+
expect((await add('https://github.com/third/repo')).repo).toBe('third/repo')
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
// 回归:未定版的市场源也必须吃 5 分钟节流。曾经把节流时间戳写在「未定版
|
|
670
|
+
// 早退」之后,于是未定版的源每次检查都重新打 GitHub /releases/latest。
|
|
671
|
+
it('throttles the market check for an unpinned source as well', async () => {
|
|
672
|
+
lastMarketCheck.clear()
|
|
673
|
+
await store.addMarketSource('unpinned/throttle-target')
|
|
674
|
+
let releaseLookups = 0
|
|
675
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
|
676
|
+
if (url.includes('/releases/latest')) {
|
|
677
|
+
releaseLookups += 1
|
|
678
|
+
return jsonResponse({ tag_name: 'v3.1.4' })
|
|
679
|
+
}
|
|
680
|
+
return new Response('unexpected url: ' + url, { status: 599 })
|
|
681
|
+
}))
|
|
682
|
+
try {
|
|
683
|
+
const first = new FakeResponse()
|
|
684
|
+
await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), first as never)
|
|
685
|
+
const firstBody = first.json() as import('./protocol.ts').MarketCheckResponse
|
|
686
|
+
expect(firstBody.results[0]).toMatchObject({ repo: 'unpinned/throttle-target', latestTag: 'v3.1.4' })
|
|
687
|
+
expect(firstBody.results[0].throttled).toBeUndefined()
|
|
688
|
+
|
|
689
|
+
const second = new FakeResponse()
|
|
690
|
+
await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), second as never)
|
|
691
|
+
const secondBody = second.json() as import('./protocol.ts').MarketCheckResponse
|
|
692
|
+
expect(secondBody.results[0].throttled).toBe(true)
|
|
693
|
+
// 第二次没有发生任何上游查询。
|
|
694
|
+
expect(releaseLookups).toBe(1)
|
|
695
|
+
} finally {
|
|
696
|
+
vi.unstubAllGlobals()
|
|
697
|
+
lastMarketCheck.clear()
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// resolveHubConfig 收敛为单参数后,越界值仍必须回落到 schema 默认,
|
|
702
|
+
// 而不是被写进有效配置。
|
|
703
|
+
it('clamps the numeric stats knobs and fills the remaining defaults', async () => {
|
|
704
|
+
const routes = makeRoutes({
|
|
705
|
+
...deps,
|
|
706
|
+
config: () => ({ enabled: true, announceToAgent: true, statsWindowDays: -5, statsScanMinutes: 0 }),
|
|
707
|
+
})
|
|
708
|
+
const res = new FakeResponse()
|
|
709
|
+
await routes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('GET', SKILL_HUB_API.config), res as never)
|
|
710
|
+
expect(res.status).toBe(200)
|
|
711
|
+
const body = res.json() as ConfigResponse
|
|
712
|
+
expect(body.config.statsWindowDays).toBe(14)
|
|
713
|
+
expect(body.config.statsScanMinutes).toBe(5)
|
|
714
|
+
})
|
|
715
|
+
|
|
648
716
|
// ------------------------------------------------------- repo import (B方案 job+轮询)
|
|
649
717
|
it('imports repo skills and records the upstream source', async () => {
|
|
650
718
|
const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
|
|
@@ -695,6 +763,125 @@ describe('skill-hub routes', () => {
|
|
|
695
763
|
}
|
|
696
764
|
})
|
|
697
765
|
|
|
766
|
+
// ------------------------------------ 导入去重 vs 已禁用占位(issue #16)
|
|
767
|
+
/** 造一个「已禁用同名占位」:目录还在,SKILL.md 被改名成 SKILL.md.disabled。 */
|
|
768
|
+
async function seedDisabledPlaceholder(name: string) {
|
|
769
|
+
const dir = join(home, 'skills', name)
|
|
770
|
+
await mkdir(dir, { recursive: true })
|
|
771
|
+
await writeFile(join(dir, 'SKILL.md.disabled'), `---\nname: ${name}\ndescription: stub\n---\n`, 'utf8')
|
|
772
|
+
await store.addDisabled({
|
|
773
|
+
name,
|
|
774
|
+
description: 'stub',
|
|
775
|
+
path: join(dir, 'SKILL.md.disabled'),
|
|
776
|
+
root: 'user-dsh',
|
|
777
|
+
disabledAt: Date.now(),
|
|
778
|
+
})
|
|
779
|
+
return dir
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
it('does not flag a hub-disabled placeholder as existing when discovering', async () => {
|
|
783
|
+
await seedDisabledPlaceholder('code-review')
|
|
784
|
+
stubFetch([
|
|
785
|
+
['repos/example/repo/releases/latest', jsonResponse({ tag_name: 'v1.0.0' })],
|
|
786
|
+
['repos/example/repo/git/trees/v1.0.0', jsonResponse({ tree: [
|
|
787
|
+
{ path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
|
|
788
|
+
] })],
|
|
789
|
+
['repos/example/repo', jsonResponse({ default_branch: 'main' })],
|
|
790
|
+
])
|
|
791
|
+
try {
|
|
792
|
+
const res = new FakeResponse()
|
|
793
|
+
await routeFor(SKILL_HUB_API.repo).handler(fakeReq('GET', `${SKILL_HUB_API.repo}?repo=example/repo`), res as never)
|
|
794
|
+
expect(res.status).toBe(200)
|
|
795
|
+
const body = res.json() as import('./protocol.ts').RepoDiscoverResponse
|
|
796
|
+
expect(body.ref).toBe('v1.0.0')
|
|
797
|
+
// 禁用只该影响启用态;被算成 existing 会让勾选框直接 disabled,用户无从下手。
|
|
798
|
+
expect(body.entries).toHaveLength(1)
|
|
799
|
+
expect(body.entries[0]).toMatchObject({ name: 'code-review', existing: false })
|
|
800
|
+
} finally {
|
|
801
|
+
vi.unstubAllGlobals()
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('imports over a hub-disabled placeholder instead of skipping it', async () => {
|
|
806
|
+
const dir = await seedDisabledPlaceholder('code-review')
|
|
807
|
+
const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
|
|
808
|
+
stubFetch([
|
|
809
|
+
['repos/example/repo/git/trees/main', jsonResponse({ tree: [
|
|
810
|
+
{ path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
|
|
811
|
+
] })],
|
|
812
|
+
['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
|
|
813
|
+
['repos/example/repo', jsonResponse({ default_branch: 'main' })],
|
|
814
|
+
['raw.githubusercontent.com/example/repo/main/skills/code-review/SKILL.md', new Response(skillMd, { status: 200 })],
|
|
815
|
+
])
|
|
816
|
+
try {
|
|
817
|
+
const res = new FakeResponse()
|
|
818
|
+
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)
|
|
819
|
+
expect(res.status).toBe(200)
|
|
820
|
+
const created = res.json() as import('./protocol.ts').RepoImportResponse
|
|
821
|
+
let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
|
|
822
|
+
for (let i = 0; i < 20; i++) {
|
|
823
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
824
|
+
const progRes = new FakeResponse()
|
|
825
|
+
await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
|
|
826
|
+
prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
|
|
827
|
+
if (prog.status !== 'running') break
|
|
828
|
+
}
|
|
829
|
+
expect(prog!.status).toBe('done')
|
|
830
|
+
// 关键:不再被「已存在」跳掉,真技能落地。
|
|
831
|
+
expect(prog!.skipped).toEqual([])
|
|
832
|
+
expect(prog!.imported.map((r) => r.name)).toEqual(['code-review'])
|
|
833
|
+
expect(prog!.replaced).toEqual(['code-review'])
|
|
834
|
+
await expect(readFile(join(dir, 'SKILL.md'), 'utf8')).resolves.toContain('name: code-review')
|
|
835
|
+
// 旧占位进回收站(可恢复),禁用记录被清掉,不然目录会留个幽灵禁用项。
|
|
836
|
+
await expect(access(join(dir, 'SKILL.md.disabled'))).rejects.toThrow()
|
|
837
|
+
expect(await store.listDisabled()).toEqual([])
|
|
838
|
+
const trash = await store.listTrash()
|
|
839
|
+
expect(trash).toHaveLength(1)
|
|
840
|
+
expect(trash[0].name).toBe('code-review')
|
|
841
|
+
expect(trash[0].path.startsWith(join(home, 'skills', '.trash', 'code-review-'))).toBe(true)
|
|
842
|
+
expect(trash[0].sourcePath).toBe(dir)
|
|
843
|
+
expect(await store.listSources()).toHaveLength(1)
|
|
844
|
+
} finally {
|
|
845
|
+
vi.unstubAllGlobals()
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('still skips a same-name directory that is not a disabled placeholder', async () => {
|
|
850
|
+
// 同名目录里还有真实 SKILL.md、且没有对应的禁用记录:不是占位,不许动。
|
|
851
|
+
const dir = join(home, 'skills', 'code-review')
|
|
852
|
+
await mkdir(dir, { recursive: true })
|
|
853
|
+
await writeFile(join(dir, 'SKILL.md'), '---\nname: code-review\ndescription: real\n---\n', 'utf8')
|
|
854
|
+
stubFetch([
|
|
855
|
+
['repos/example/repo/git/trees/main', jsonResponse({ tree: [
|
|
856
|
+
{ path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
|
|
857
|
+
] })],
|
|
858
|
+
['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
|
|
859
|
+
['repos/example/repo', jsonResponse({ default_branch: 'main' })],
|
|
860
|
+
])
|
|
861
|
+
try {
|
|
862
|
+
const res = new FakeResponse()
|
|
863
|
+
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)
|
|
864
|
+
const created = res.json() as import('./protocol.ts').RepoImportResponse
|
|
865
|
+
let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
|
|
866
|
+
for (let i = 0; i < 20; i++) {
|
|
867
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
868
|
+
const progRes = new FakeResponse()
|
|
869
|
+
await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
|
|
870
|
+
prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
|
|
871
|
+
if (prog.status !== 'running') break
|
|
872
|
+
}
|
|
873
|
+
expect(prog!.status).toBe('done')
|
|
874
|
+
expect(prog!.skipped).toEqual([{ name: 'code-review', reason: 'exists' }])
|
|
875
|
+
expect(prog!.imported).toEqual([])
|
|
876
|
+
expect(prog!.replaced ?? []).toEqual([])
|
|
877
|
+
// 原目录原封不动,也没进回收站。
|
|
878
|
+
await expect(readFile(join(dir, 'SKILL.md'), 'utf8')).resolves.toContain('description: real')
|
|
879
|
+
expect(await store.listTrash()).toEqual([])
|
|
880
|
+
} finally {
|
|
881
|
+
vi.unstubAllGlobals()
|
|
882
|
+
}
|
|
883
|
+
})
|
|
884
|
+
|
|
698
885
|
// -------------------------------------------------------------- groups
|
|
699
886
|
it('serves groups: user tags + system collections + origins', async () => {
|
|
700
887
|
const tag = await store.saveTag({ name: 'web' })
|
package/src/skillfs/scan.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { load } from 'js-yaml'
|
|
|
4
4
|
import { parseFrontmatter, repairFrontmatterFileText } from './frontmatter.ts'
|
|
5
5
|
import { rootPath } from './paths.ts'
|
|
6
6
|
import { errorText } from '../error-text.ts'
|
|
7
|
+
import { mapConcurrent } from '../concurrency.ts'
|
|
7
8
|
import { dshHome } from '../store.ts'
|
|
8
|
-
import type
|
|
9
|
+
import { HEX_COLOR_RE, type DiagnosticEntry, type WritableRoot } from '../protocol.ts'
|
|
9
10
|
|
|
10
11
|
/** One discoverable skill file in a scanned root. */
|
|
11
12
|
export interface SkillEntry {
|
|
@@ -130,7 +131,9 @@ export async function readSkillInterface(directory: string): Promise<SkillInterf
|
|
|
130
131
|
const hexColor = (value: unknown): string | undefined => {
|
|
131
132
|
if (typeof value !== 'string') return undefined
|
|
132
133
|
const trimmed = value.trim()
|
|
133
|
-
|
|
134
|
+
// Shared with the settings card and the config route: one definition, so a
|
|
135
|
+
// change to the accepted color format cannot land in only some of them.
|
|
136
|
+
return HEX_COLOR_RE.test(trimmed) ? trimmed : undefined
|
|
134
137
|
}
|
|
135
138
|
const iconPath = (value: unknown): string | undefined => {
|
|
136
139
|
if (typeof value !== 'string' || value.trim() === '') return undefined
|
|
@@ -167,33 +170,40 @@ export async function findProjectRoot(cwd: string): Promise<string> {
|
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
/**
|
|
170
|
-
* Scan one writable root for files the provider ignores, so the GUI can
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
173
|
+
* Scan one writable root for files the provider ignores, so the GUI can show
|
|
174
|
+
* why a skill never appears — a missing frontmatter, a name that disagrees
|
|
175
|
+
* with the discovery path, and the like must be visible, not silent. Files the
|
|
176
|
+
* hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
|
|
177
|
+
* a defect.
|
|
174
178
|
*/
|
|
175
179
|
export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Promise<DiagnosticEntry[]> {
|
|
176
|
-
const
|
|
177
|
-
|
|
180
|
+
const entries = await listSkillEntries(root, home)
|
|
181
|
+
// Each entry is read and parsed independently, and the panel polls /catalog
|
|
182
|
+
// every 5 seconds. A serial read would make the response time grow linearly
|
|
183
|
+
// with the skill count — the same reason the per-skill `stat` collection in
|
|
184
|
+
// routes/catalog-data.ts is already parallel. Bounded, not a bare
|
|
185
|
+
// Promise.all: a root can hold hundreds of entries.
|
|
186
|
+
const perEntry = await mapConcurrent(entries, 8, async (entry): Promise<DiagnosticEntry[]> => {
|
|
187
|
+
const found: DiagnosticEntry[] = []
|
|
178
188
|
let text: string
|
|
179
189
|
try {
|
|
180
190
|
text = await readFile(entry.path, 'utf8')
|
|
181
191
|
} catch (error) {
|
|
182
192
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
183
|
-
|
|
193
|
+
found.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
|
|
184
194
|
}
|
|
185
|
-
|
|
195
|
+
return found
|
|
186
196
|
}
|
|
187
197
|
const parsed = parseFrontmatter(text)
|
|
188
198
|
if ('error' in parsed) {
|
|
189
199
|
const fixable = repairFrontmatterFileText(text) !== null
|
|
190
|
-
|
|
191
|
-
|
|
200
|
+
found.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
|
|
201
|
+
return found
|
|
192
202
|
}
|
|
193
203
|
// File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
|
|
194
204
|
const repaired = repairFrontmatterFileText(text)
|
|
195
205
|
if (repaired !== null) {
|
|
196
|
-
|
|
206
|
+
found.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
|
|
197
207
|
}
|
|
198
208
|
const { value } = parsed
|
|
199
209
|
// The provider registers a skill by its discovery path (directory name or
|
|
@@ -201,13 +211,16 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
|
|
|
201
211
|
// the skill show up under a different identity than its metadata claims.
|
|
202
212
|
const pathName = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
|
|
203
213
|
if (value.name !== pathName) {
|
|
204
|
-
|
|
214
|
+
found.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
|
|
205
215
|
}
|
|
206
216
|
// Agents decide to auto-activate a skill from its one-line description, so
|
|
207
217
|
// a too-short description leaves the skill hard to discover automatically.
|
|
208
218
|
if (value.description.length < 10) {
|
|
209
|
-
|
|
219
|
+
found.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
|
|
210
220
|
}
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
return found
|
|
222
|
+
})
|
|
223
|
+
// mapConcurrent preserves index order, so flattening keeps the previous
|
|
224
|
+
// per-entry ordering of the diagnostics.
|
|
225
|
+
return perEntry.flat()
|
|
213
226
|
}
|