dsh-skill-hub 0.2.6 → 0.3.0
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/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +1249 -407
- package/lib/client.js.map +1 -1
- package/lib/index.js +439 -81
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/locales.ts +26 -0
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/index.ts +18 -0
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
package/src/routes.ts
CHANGED
|
@@ -18,13 +18,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
18
18
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
19
19
|
import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
20
20
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
21
22
|
import {
|
|
23
|
+
cleanupLeftoverImportDirs,
|
|
22
24
|
collectRepoSkillFiles,
|
|
23
25
|
diffRemoteSkills,
|
|
24
26
|
discoverRepoEntries,
|
|
25
27
|
downloadRepoSkill,
|
|
26
28
|
getLatestCommit,
|
|
27
29
|
getLatestReleaseTag,
|
|
30
|
+
isAbortError,
|
|
28
31
|
listRepoBranches,
|
|
29
32
|
loadRepoTree,
|
|
30
33
|
loadRepoTreeAt,
|
|
@@ -38,13 +41,16 @@ import {
|
|
|
38
41
|
import {
|
|
39
42
|
SKILL_HUB_API,
|
|
40
43
|
type CatalogResponse,
|
|
44
|
+
type RepoImportCancelRequest,
|
|
45
|
+
type RepoImportCancelResponse,
|
|
46
|
+
type RepoImportProgressResponse,
|
|
47
|
+
type RepoImportRequest,
|
|
48
|
+
type RepoImportResponse,
|
|
41
49
|
type CatalogSkill,
|
|
42
50
|
type CollectionGroup,
|
|
43
51
|
type ConfigResponse,
|
|
44
52
|
type CreateResponse,
|
|
45
53
|
type RepoDiscoverResponse,
|
|
46
|
-
type RepoImportRequest,
|
|
47
|
-
type RepoImportResponse,
|
|
48
54
|
type CreateRequest,
|
|
49
55
|
type ErrorResponse,
|
|
50
56
|
type GroupsResponse,
|
|
@@ -74,6 +80,12 @@ import {
|
|
|
74
80
|
type TagDeleteResponse,
|
|
75
81
|
type TagMembersRequest,
|
|
76
82
|
type TagMembersResponse,
|
|
83
|
+
type TagReorderRequest,
|
|
84
|
+
type TagReorderResponse,
|
|
85
|
+
type CollectionReorderRequest,
|
|
86
|
+
type CollectionReorderResponse,
|
|
87
|
+
type SourceGroupReorderRequest,
|
|
88
|
+
type SourceGroupReorderResponse,
|
|
77
89
|
type TagSaveRequest,
|
|
78
90
|
type TagSaveResponse,
|
|
79
91
|
type ToggleBatchRequest,
|
|
@@ -272,17 +284,23 @@ async function resolveWritableSkill(deps: SkillHubRouteDeps, name: string, cwd?:
|
|
|
272
284
|
|
|
273
285
|
/** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
|
|
274
286
|
async function buildGroups(deps: SkillHubRouteDeps): Promise<GroupsResponse> {
|
|
275
|
-
const [tags, origins] = await Promise.all([deps.store.listTags(), deps.store.listOrigins()])
|
|
287
|
+
const [tags, origins, collectionOrder, sourceGroupOrder] = await Promise.all([deps.store.listTags(), deps.store.listOrigins(), deps.store.getCollectionOrder(), deps.store.getSourceGroupOrder()])
|
|
276
288
|
const byCollection = new Map<string, string[]>()
|
|
277
289
|
for (const [skillName, origin] of Object.entries(origins)) {
|
|
278
290
|
const list = byCollection.get(origin)
|
|
279
291
|
if (list === undefined) byCollection.set(origin, [skillName])
|
|
280
292
|
else list.push(skillName)
|
|
281
293
|
}
|
|
294
|
+
const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
|
|
282
295
|
const collections: CollectionGroup[] = [...byCollection.entries()]
|
|
283
296
|
.map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
|
|
284
|
-
.sort((a, b) =>
|
|
285
|
-
|
|
297
|
+
.sort((a, b) => {
|
|
298
|
+
const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
|
|
299
|
+
const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
|
|
300
|
+
if (ai !== bi) return ai - bi
|
|
301
|
+
return a.name.localeCompare(b.name)
|
|
302
|
+
})
|
|
303
|
+
return { ok: true, tags, collections, origins, ...(sourceGroupOrder.length > 0 ? { sourceGroupOrder } : {}), ...(collectionOrder.length > 0 ? { collectionOrder } : {}) }
|
|
286
304
|
}
|
|
287
305
|
|
|
288
306
|
/** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
|
|
@@ -434,6 +452,41 @@ const lastSourceCheck = new Map<string, number>()
|
|
|
434
452
|
/** Last successful market update check per repo (same throttle). */
|
|
435
453
|
const lastMarketCheck = new Map<string, number>()
|
|
436
454
|
|
|
455
|
+
/** Async import job (B方案:jobId + 轮询,选项2后台继续) */
|
|
456
|
+
interface ImportJob {
|
|
457
|
+
jobId: string
|
|
458
|
+
repo: string
|
|
459
|
+
ref: string
|
|
460
|
+
total: number
|
|
461
|
+
done: number
|
|
462
|
+
current?: string
|
|
463
|
+
currentFile?: string
|
|
464
|
+
totalBytes: number
|
|
465
|
+
downloadedBytes: number
|
|
466
|
+
startTime: number
|
|
467
|
+
imported: Array<{ name: string; origin: string; path: string }>
|
|
468
|
+
skipped: Array<{ name: string; reason: 'exists' }>
|
|
469
|
+
failed: Array<{ name: string; error: string }>
|
|
470
|
+
status: 'running' | 'done' | 'cancelled' | 'error'
|
|
471
|
+
error?: string
|
|
472
|
+
controller: AbortController
|
|
473
|
+
createdAt: number
|
|
474
|
+
}
|
|
475
|
+
const importJobs = new Map<string, ImportJob>()
|
|
476
|
+
const IMPORT_JOB_TTL_MS = 5 * 60_000
|
|
477
|
+
const IMPORT_JOB_MAX = 100
|
|
478
|
+
function gcImportJobs(): void {
|
|
479
|
+
if (importJobs.size > 500) importJobs.clear()
|
|
480
|
+
const now = Date.now()
|
|
481
|
+
for (const [id, job] of importJobs) {
|
|
482
|
+
if (job.status !== 'running' && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id)
|
|
483
|
+
}
|
|
484
|
+
if (importJobs.size > IMPORT_JOB_MAX) {
|
|
485
|
+
const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
486
|
+
for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0])
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
437
490
|
/** Replace one skill directory with a fresh download; restores the old dir on failure. */
|
|
438
491
|
async function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void> {
|
|
439
492
|
// 点前缀:发现扫描跳过点开头的目录(与导入临时目录 .<name>.import- 同一
|
|
@@ -566,12 +619,28 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
566
619
|
const name = typeof request.name === 'string' ? request.name : ''
|
|
567
620
|
if (name === '') { writeError(res, 400, 'name is required'); return }
|
|
568
621
|
const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
|
|
569
|
-
|
|
622
|
+
// 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
|
|
623
|
+
let trashResult: { path: string; source: string } | null = null
|
|
624
|
+
if (!resolved.ok) {
|
|
625
|
+
const disabled = await deps.store.getDisabled(name)
|
|
626
|
+
if (disabled !== undefined) {
|
|
627
|
+
// 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
|
|
628
|
+
const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
|
|
629
|
+
const sourceToTrash = isBundleDisabled ? dirname(disabled.path) : disabled.path
|
|
630
|
+
const trashDir = join(dirname(sourceToTrash), '.trash')
|
|
631
|
+
await mkdir(trashDir, { recursive: true })
|
|
632
|
+
const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
|
|
633
|
+
await rename(sourceToTrash, target)
|
|
634
|
+
trashResult = { path: target, source: sourceToTrash }
|
|
635
|
+
} else {
|
|
636
|
+
writeError(res, resolved.status, resolved.error); return
|
|
637
|
+
}
|
|
638
|
+
}
|
|
570
639
|
// 入回收站前快照来源归属与场景成员:恢复时把它们加回来,否则恢复
|
|
571
640
|
// 后的技能会丢失来源(变成「个人技能」)和场景分组。
|
|
572
641
|
const tracked = await deps.store.getSourceForSkill(name)
|
|
573
642
|
const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
|
|
574
|
-
const { path, source } = await trashSkill(resolved.path)
|
|
643
|
+
const { path, source } = trashResult ?? await trashSkill((resolved as { ok: true; path: string }).path)
|
|
575
644
|
await deps.store.addTrash({
|
|
576
645
|
name,
|
|
577
646
|
path,
|
|
@@ -897,7 +966,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
897
966
|
},
|
|
898
967
|
}),
|
|
899
968
|
// ---------------------------------------------------------------- repo
|
|
900
|
-
// Discover importable skills in a public GitHub repo (
|
|
969
|
+
// Discover importable skills in a public GitHub repo (any top-level root containing SKILL.md).
|
|
901
970
|
route({
|
|
902
971
|
path: SKILL_HUB_API.repo,
|
|
903
972
|
methods: ['GET'],
|
|
@@ -934,10 +1003,9 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
934
1003
|
writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries } satisfies RepoDiscoverResponse)
|
|
935
1004
|
},
|
|
936
1005
|
}),
|
|
937
|
-
// ----------------------------------------------------------- repo/import
|
|
938
|
-
// Install selected repo skills
|
|
939
|
-
//
|
|
940
|
-
// they form one tracked collection group.
|
|
1006
|
+
// ----------------------------------------------------------- repo/import B方案 Job
|
|
1007
|
+
// Install selected repo skills — now async job (B方案:轮询 + 选项2后台继续)
|
|
1008
|
+
// POST returns jobId instantly (<500ms), GET /progress polls for done.
|
|
941
1009
|
route({
|
|
942
1010
|
path: SKILL_HUB_API.repoImport,
|
|
943
1011
|
methods: ['POST'],
|
|
@@ -950,59 +1018,162 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
950
1018
|
const parsed = normalizeRepoInput(input)
|
|
951
1019
|
if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
|
|
952
1020
|
const repo = repoSlug(parsed)
|
|
953
|
-
// The ref the discovery ran against must drive this import too, so
|
|
954
|
-
// the tree, commit snapshot, and recorded source ref all agree
|
|
955
|
-
// (falls back to the input's own @ref when the client omitted it).
|
|
956
1021
|
const ref = typeof request.ref === 'string' && request.ref !== '' ? request.ref : parsed.ref
|
|
957
1022
|
const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
|
|
958
1023
|
const existing = await knownSkillNames(deps)
|
|
959
1024
|
const entries = discoverRepoEntries(tree, repo, existing)
|
|
960
1025
|
const byPath = new Map(entries.map((entry) => [entry.path, entry]))
|
|
961
1026
|
const selected = paths.map((path) => byPath.get(path)).filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
|
|
1027
|
+
if (selected.length === 0) { writeError(res, 400, 'no matching skills for selected paths'); return }
|
|
962
1028
|
if (selected.length > 500) { writeError(res, 400, 'select at most 500 skills per import'); return }
|
|
963
1029
|
const totalBytes = selected.reduce((sum, entry) => sum + entry.totalBytes, 0)
|
|
964
1030
|
if (totalBytes > 200 * 1024 * 1024) { writeError(res, 400, 'selected skills exceed the 200MB import limit'); return }
|
|
965
1031
|
|
|
966
|
-
// Snapshot the upstream commit for the source record (best-effort:
|
|
967
|
-
// a failure still imports, just with an unverified snapshot).
|
|
968
1032
|
let commitSha = ''
|
|
969
1033
|
try {
|
|
970
1034
|
const latest = await getLatestCommit(repo, resolvedRef)
|
|
971
1035
|
commitSha = latest.commitSha
|
|
972
1036
|
} catch {
|
|
973
|
-
//
|
|
1037
|
+
// empty snapshot ok
|
|
974
1038
|
}
|
|
975
1039
|
|
|
976
|
-
const
|
|
977
|
-
const
|
|
978
|
-
const
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1040
|
+
const jobId = 'imp_' + randomUUID().slice(0, 8)
|
|
1041
|
+
const controller = new AbortController()
|
|
1042
|
+
const now = Date.now()
|
|
1043
|
+
const job: ImportJob = {
|
|
1044
|
+
jobId,
|
|
1045
|
+
repo,
|
|
1046
|
+
ref: resolvedRef,
|
|
1047
|
+
total: selected.length,
|
|
1048
|
+
done: 0,
|
|
1049
|
+
totalBytes,
|
|
1050
|
+
downloadedBytes: 0,
|
|
1051
|
+
startTime: now,
|
|
1052
|
+
imported: [],
|
|
1053
|
+
skipped: [],
|
|
1054
|
+
failed: [],
|
|
1055
|
+
status: 'running',
|
|
1056
|
+
controller,
|
|
1057
|
+
createdAt: now,
|
|
1058
|
+
}
|
|
1059
|
+
gcImportJobs()
|
|
1060
|
+
importJobs.set(jobId, job)
|
|
1061
|
+
|
|
1062
|
+
// 启动时顺手清理一次残留临时目录(best-effort,不阻塞响应)
|
|
1063
|
+
const targetRootEarly = rootPath('user-dsh', homeOf(deps))
|
|
1064
|
+
void cleanupLeftoverImportDirs(targetRootEarly).catch(() => {})
|
|
1065
|
+
|
|
1066
|
+
// 后台执行,不阻塞响应(选项2:关面板也继续跑,唯有 /cancel 才 abort)
|
|
1067
|
+
void (async () => {
|
|
1068
|
+
const targetRoot = rootPath('user-dsh', homeOf(deps))
|
|
1069
|
+
await mkdir(targetRoot, { recursive: true })
|
|
1070
|
+
const defaultTag = await deps.store.getDefaultTag()
|
|
1071
|
+
let needInvalidate = false
|
|
1072
|
+
for (const entry of selected) {
|
|
1073
|
+
if (controller.signal.aborted) break
|
|
1074
|
+
job.current = entry.name
|
|
1075
|
+
job.currentFile = entry.dir + '/SKILL.md'
|
|
1076
|
+
if (entry.existing) {
|
|
1077
|
+
job.skipped.push({ name: entry.name, reason: 'exists' })
|
|
1078
|
+
job.downloadedBytes += entry.totalBytes
|
|
1079
|
+
job.done += 1
|
|
1080
|
+
continue
|
|
1081
|
+
}
|
|
1082
|
+
if (await pathExists(join(targetRoot, entry.name))) {
|
|
1083
|
+
job.skipped.push({ name: entry.name, reason: 'exists' })
|
|
1084
|
+
job.downloadedBytes += entry.totalBytes
|
|
1085
|
+
job.done += 1
|
|
1086
|
+
continue
|
|
1087
|
+
}
|
|
1088
|
+
if (controller.signal.aborted) break
|
|
1089
|
+
const files = collectRepoSkillFiles(tree, entry.dir)
|
|
1090
|
+
try {
|
|
1091
|
+
const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
|
|
1092
|
+
job.downloadedBytes += bytes
|
|
1093
|
+
job.currentFile = entry.dir + '/' + file
|
|
1094
|
+
})
|
|
1095
|
+
await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
|
|
1096
|
+
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
|
|
1097
|
+
if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
|
|
1098
|
+
job.imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
|
|
1099
|
+
needInvalidate = true
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (isAbortError(error) || controller.signal.aborted) {
|
|
1102
|
+
// 取消时不记为 failed,保留已完成的 imported/skipped,直接跳出
|
|
1103
|
+
break
|
|
1104
|
+
}
|
|
1105
|
+
job.failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
|
|
1106
|
+
} finally {
|
|
1107
|
+
job.done += 1
|
|
1108
|
+
}
|
|
992
1109
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1110
|
+
if (controller.signal.aborted) {
|
|
1111
|
+
job.status = 'cancelled'
|
|
1112
|
+
job.current = undefined
|
|
1113
|
+
job.currentFile = undefined
|
|
1114
|
+
} else {
|
|
1115
|
+
job.status = 'done'
|
|
1116
|
+
job.current = undefined
|
|
1117
|
+
job.currentFile = undefined
|
|
1118
|
+
// 确保字节进度最终对齐(避免浮点/并发尾差)
|
|
1119
|
+
job.downloadedBytes = job.totalBytes
|
|
1002
1120
|
}
|
|
1121
|
+
if (needInvalidate) deps.invalidate?.()
|
|
1122
|
+
})().catch((error) => {
|
|
1123
|
+
job.status = 'error'
|
|
1124
|
+
job.error = error instanceof Error ? error.message : String(error)
|
|
1125
|
+
job.current = undefined
|
|
1126
|
+
job.currentFile = undefined
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
writeJson(res, 200, { ok: true, jobId, total: selected.length, totalBytes } satisfies RepoImportResponse)
|
|
1130
|
+
},
|
|
1131
|
+
}),
|
|
1132
|
+
// 进度轮询(含字节级进度和速度)
|
|
1133
|
+
route({
|
|
1134
|
+
path: SKILL_HUB_API.repoImportProgress,
|
|
1135
|
+
methods: ['GET'],
|
|
1136
|
+
handler: async ({ res, url }) => {
|
|
1137
|
+
const jobId = queryParam(url, 'jobId') ?? ''
|
|
1138
|
+
if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
|
|
1139
|
+
const job = importJobs.get(jobId)
|
|
1140
|
+
if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
|
|
1141
|
+
const elapsedSec = Math.max(0.5, (Date.now() - job.startTime) / 1000)
|
|
1142
|
+
const bytesPerSecond = job.status === 'running' ? Math.round(job.downloadedBytes / elapsedSec) : undefined
|
|
1143
|
+
writeJson(res, 200, {
|
|
1144
|
+
ok: true,
|
|
1145
|
+
jobId: job.jobId,
|
|
1146
|
+
status: job.status,
|
|
1147
|
+
total: job.total,
|
|
1148
|
+
done: job.done,
|
|
1149
|
+
...(job.current !== undefined ? { current: job.current } : {}),
|
|
1150
|
+
...(job.currentFile !== undefined ? { currentFile: job.currentFile } : {}),
|
|
1151
|
+
totalBytes: job.totalBytes,
|
|
1152
|
+
downloadedBytes: job.downloadedBytes,
|
|
1153
|
+
...(bytesPerSecond !== undefined ? { bytesPerSecond } : {}),
|
|
1154
|
+
imported: [...job.imported],
|
|
1155
|
+
skipped: [...job.skipped],
|
|
1156
|
+
failed: [...job.failed],
|
|
1157
|
+
...(job.error !== undefined ? { error: job.error } : {}),
|
|
1158
|
+
} satisfies RepoImportProgressResponse)
|
|
1159
|
+
},
|
|
1160
|
+
}),
|
|
1161
|
+
// 取消任务(选项2:唯有点取消才停)
|
|
1162
|
+
route({
|
|
1163
|
+
path: SKILL_HUB_API.repoImportCancel,
|
|
1164
|
+
methods: ['POST'],
|
|
1165
|
+
jsonBody: true,
|
|
1166
|
+
handler: async ({ res, body }) => {
|
|
1167
|
+
const request = body as unknown as RepoImportCancelRequest
|
|
1168
|
+
const jobId = typeof request.jobId === 'string' ? request.jobId : ''
|
|
1169
|
+
if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
|
|
1170
|
+
const job = importJobs.get(jobId)
|
|
1171
|
+
if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
|
|
1172
|
+
if (job.status === 'running') {
|
|
1173
|
+
job.controller.abort()
|
|
1174
|
+
job.status = 'cancelled'
|
|
1003
1175
|
}
|
|
1004
|
-
|
|
1005
|
-
writeJson(res, 200, { ok: true, imported, skipped, failed } satisfies RepoImportResponse)
|
|
1176
|
+
writeJson(res, 200, { ok: true, jobId: job.jobId, status: job.status as 'cancelled' | 'done' } satisfies RepoImportCancelResponse)
|
|
1006
1177
|
},
|
|
1007
1178
|
}),
|
|
1008
1179
|
// -------------------------------------------------------------- update
|
|
@@ -1068,22 +1239,73 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
1068
1239
|
writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagMembersResponse)
|
|
1069
1240
|
},
|
|
1070
1241
|
}),
|
|
1242
|
+
// ---------------------------------------------------------- tag/reorder
|
|
1243
|
+
// 拖拽重排场景分组顺序
|
|
1244
|
+
route({
|
|
1245
|
+
path: SKILL_HUB_API.tagReorder,
|
|
1246
|
+
methods: ['POST'],
|
|
1247
|
+
jsonBody: true,
|
|
1248
|
+
handler: async ({ res, body }) => {
|
|
1249
|
+
const request = body as unknown as TagReorderRequest
|
|
1250
|
+
const orderedIds = Array.isArray(request.orderedIds) ? request.orderedIds.filter((id): id is string => typeof id === 'string' && id !== '') : []
|
|
1251
|
+
try {
|
|
1252
|
+
const tags = await deps.store.reorderTags(orderedIds)
|
|
1253
|
+
writeJson(res, 200, { ok: true, tags } satisfies TagReorderResponse)
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
if (error instanceof StoreError) { writeError(res, error.kind === 'validation' ? 400 : error.kind === 'not-found' ? 404 : 409, error.message); return }
|
|
1256
|
+
throw error
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
}),
|
|
1260
|
+
// ------------------------------------------------- collection/reorder
|
|
1261
|
+
// 拖拽重排来源集合顺序
|
|
1262
|
+
route({
|
|
1263
|
+
path: SKILL_HUB_API.collectionReorder,
|
|
1264
|
+
methods: ['POST'],
|
|
1265
|
+
jsonBody: true,
|
|
1266
|
+
handler: async ({ res, body }) => {
|
|
1267
|
+
const request = body as unknown as CollectionReorderRequest
|
|
1268
|
+
const orderedNames = Array.isArray(request.orderedNames) ? request.orderedNames.filter((n): n is string => typeof n === 'string' && n !== '') : []
|
|
1269
|
+
const order = await deps.store.reorderCollections(orderedNames)
|
|
1270
|
+
const groups = await buildGroups(deps)
|
|
1271
|
+
writeJson(res, 200, { ok: true, collections: groups.collections, order } satisfies CollectionReorderResponse)
|
|
1272
|
+
},
|
|
1273
|
+
}),
|
|
1274
|
+
// ------------------------------------------------ source-group/reorder
|
|
1275
|
+
// 拖拽重排来源顶层分组(project / collections / personal 统一顺序)
|
|
1276
|
+
route({
|
|
1277
|
+
path: SKILL_HUB_API.sourceGroupReorder,
|
|
1278
|
+
methods: ['POST'],
|
|
1279
|
+
jsonBody: true,
|
|
1280
|
+
handler: async ({ res, body }) => {
|
|
1281
|
+
const request = body as unknown as SourceGroupReorderRequest
|
|
1282
|
+
const orderedKeys = Array.isArray(request.orderedKeys) ? request.orderedKeys.filter((k): k is string => typeof k === 'string' && k !== '') : []
|
|
1283
|
+
const order = await deps.store.reorderSourceGroups(orderedKeys)
|
|
1284
|
+
writeJson(res, 200, { ok: true, order } satisfies SourceGroupReorderResponse)
|
|
1285
|
+
},
|
|
1286
|
+
}),
|
|
1071
1287
|
// ------------------------------------------------------------- sources
|
|
1072
1288
|
// 来源列表 + 派生 origin 映射 + 集合组 + 回收站。
|
|
1073
1289
|
route({
|
|
1074
1290
|
path: SKILL_HUB_API.sources,
|
|
1075
1291
|
methods: ['GET'],
|
|
1076
1292
|
handler: async ({ res }) => {
|
|
1077
|
-
const [sources, origins, trash] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash()])
|
|
1293
|
+
const [sources, origins, trash, collectionOrder] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash(), deps.store.getCollectionOrder()])
|
|
1078
1294
|
const byCollection = new Map<string, string[]>()
|
|
1079
1295
|
for (const [skillName, origin] of Object.entries(origins)) {
|
|
1080
1296
|
const list = byCollection.get(origin)
|
|
1081
1297
|
if (list === undefined) byCollection.set(origin, [skillName])
|
|
1082
1298
|
else list.push(skillName)
|
|
1083
1299
|
}
|
|
1300
|
+
const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
|
|
1084
1301
|
const collections: CollectionGroup[] = [...byCollection.entries()]
|
|
1085
1302
|
.map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
|
|
1086
|
-
.sort((a, b) =>
|
|
1303
|
+
.sort((a, b) => {
|
|
1304
|
+
const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
|
|
1305
|
+
const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
|
|
1306
|
+
if (ai !== bi) return ai - bi
|
|
1307
|
+
return a.name.localeCompare(b.name)
|
|
1308
|
+
})
|
|
1087
1309
|
writeJson(res, 200, { ok: true, sources, origins, collections, trash } satisfies SourcesResponse)
|
|
1088
1310
|
},
|
|
1089
1311
|
}),
|
package/src/skillfs.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { homedir } from 'node:os'
|
|
17
|
-
import { basename, dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
18
18
|
import { dump, load } from 'js-yaml'
|
|
19
19
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
20
20
|
import { dshHome } from './store.ts'
|
|
@@ -35,10 +35,23 @@ export function rootPath(root: WritableRoot, home = dshHome()): string {
|
|
|
35
35
|
|
|
36
36
|
/** Root of an absolute skill file path, or undefined when not user-owned. */
|
|
37
37
|
export function rootOfPath(path: string, home = dshHome()): WritableRoot | undefined {
|
|
38
|
-
const normalized =
|
|
38
|
+
const normalized = resolve(path)
|
|
39
39
|
for (const root of WRITABLE_ROOTS) {
|
|
40
|
-
const base = rootPath(root, home)
|
|
41
|
-
if (
|
|
40
|
+
const base = resolve(rootPath(root, home))
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
const n = normalized.toLowerCase()
|
|
43
|
+
const b = base.toLowerCase()
|
|
44
|
+
if (n === b) return root
|
|
45
|
+
if (n.startsWith(b + '/') || n.startsWith(b + '\\') || n.startsWith(b + sep)) return root
|
|
46
|
+
// 兜底:relative 判断(处理盘符、大小写、.. 段,兼容 / 与 \)
|
|
47
|
+
const rel = relative(base, normalized)
|
|
48
|
+
if (rel !== '' && !rel.startsWith('..' + '/') && !rel.startsWith('..' + '\\') && !rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel)) return root
|
|
49
|
+
} else {
|
|
50
|
+
if (normalized === base) return root
|
|
51
|
+
if (normalized.startsWith(base + sep) || normalized.startsWith(base + '/') || normalized.startsWith(base + '\\')) return root
|
|
52
|
+
const rel = relative(base, normalized)
|
|
53
|
+
if (rel !== '' && !rel.startsWith('..' + sep) && !rel.startsWith('..' + '/') && !rel.startsWith('..' + '\\') && rel !== '..' && !isAbsolute(rel)) return root
|
|
54
|
+
}
|
|
42
55
|
}
|
|
43
56
|
return undefined
|
|
44
57
|
}
|
package/src/store.ts
CHANGED
|
@@ -36,6 +36,10 @@ interface StoreFile {
|
|
|
36
36
|
trash?: TrashEntry[]
|
|
37
37
|
/** Usage-statistics incremental-scan checkpoint (frozen watermark + totals). */
|
|
38
38
|
skillStats?: SkillStatsCheckpoint
|
|
39
|
+
/** Drag-reorder: collection name order for 来源分组 */
|
|
40
|
+
collectionOrder?: string[]
|
|
41
|
+
/** Drag-reorder: 来源顶层分组整体顺序(project / col:xxx / uncategorized-source) */
|
|
42
|
+
sourceGroupOrder?: string[]
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
|
|
@@ -76,7 +80,7 @@ export class StoreError extends Error {
|
|
|
76
80
|
* Returns null when the file claims a newer schema than this plugin
|
|
77
81
|
* understands, so the caller starts empty instead of risking data loss.
|
|
78
82
|
*/
|
|
79
|
-
function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown } | null {
|
|
83
|
+
function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown; collectionOrder?: unknown; sourceGroupOrder?: unknown } | null {
|
|
80
84
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
|
|
81
85
|
const record = parsed as Record<string, unknown>
|
|
82
86
|
const version = typeof record.version === 'number' ? record.version : 0
|
|
@@ -125,6 +129,8 @@ function migrateStore(parsed: unknown): { version: number; disabled: unknown; co
|
|
|
125
129
|
// v4 (skillStats) is a pure addition — older files simply lack the field,
|
|
126
130
|
// and the loader validates its shape, so pass it through untouched.
|
|
127
131
|
...(record.skillStats !== undefined ? { skillStats: record.skillStats } : {}),
|
|
132
|
+
...(Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {}),
|
|
133
|
+
...(Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}),
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -137,6 +143,8 @@ export class SkillHubStore {
|
|
|
137
143
|
private marketSources: MarketSourceRecord[] = []
|
|
138
144
|
private trashByName = new Map<string, TrashEntry>()
|
|
139
145
|
private skillStats: SkillStatsCheckpoint | undefined = undefined
|
|
146
|
+
private collectionOrder: string[] = []
|
|
147
|
+
private sourceGroupOrder: string[] = []
|
|
140
148
|
private loaded = false
|
|
141
149
|
/** Serializes persist runs: concurrent mutators must not let an earlier
|
|
142
150
|
* snapshot overwrite a later one (rename is atomic, ordering is not). */
|
|
@@ -189,10 +197,11 @@ export class SkillHubStore {
|
|
|
189
197
|
const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
|
|
190
198
|
if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
|
|
191
199
|
const manifest = source.manifest as Record<string, unknown> | undefined
|
|
200
|
+
const rawRoot = typeof source.root === 'string' && source.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : 'skills'
|
|
192
201
|
this.sourcesByRepo.set(source.repo, {
|
|
193
202
|
repo: source.repo,
|
|
194
203
|
...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
|
|
195
|
-
root:
|
|
204
|
+
root: rawRoot,
|
|
196
205
|
commitSha: typeof source.commitSha === 'string' ? source.commitSha : '',
|
|
197
206
|
skills: source.skills.filter((n): n is string => typeof n === 'string'),
|
|
198
207
|
...(manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
|
|
@@ -225,7 +234,7 @@ export class SkillHubStore {
|
|
|
225
234
|
path: item.path,
|
|
226
235
|
movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
|
|
227
236
|
...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
|
|
228
|
-
...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' &&
|
|
237
|
+
...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && typeof origin.root === 'string' && origin.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root)
|
|
229
238
|
? {
|
|
230
239
|
origin: {
|
|
231
240
|
repo: origin.repo,
|
|
@@ -268,6 +277,14 @@ export class SkillHubStore {
|
|
|
268
277
|
lastFullReconcile: savedStats.lastFullReconcile,
|
|
269
278
|
}
|
|
270
279
|
}
|
|
280
|
+
const rawColOrder = (migrated as Record<string, unknown>).collectionOrder as unknown
|
|
281
|
+
if (Array.isArray(rawColOrder)) {
|
|
282
|
+
this.collectionOrder = rawColOrder.filter((n): n is string => typeof n === 'string' && n !== '')
|
|
283
|
+
}
|
|
284
|
+
const rawSrcOrder = (migrated as Record<string, unknown>).sourceGroupOrder as unknown
|
|
285
|
+
if (Array.isArray(rawSrcOrder)) {
|
|
286
|
+
this.sourceGroupOrder = rawSrcOrder.filter((n): n is string => typeof n === 'string' && n !== '')
|
|
287
|
+
}
|
|
271
288
|
}
|
|
272
289
|
} catch (error) {
|
|
273
290
|
// Missing or unreadable state starts empty; never crash the plugin.
|
|
@@ -421,6 +438,55 @@ export class SkillHubStore {
|
|
|
421
438
|
if (changed) await this.persist()
|
|
422
439
|
}
|
|
423
440
|
|
|
441
|
+
/** Reorder tag groups by orderedIds (drag-and-drop). */
|
|
442
|
+
async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
|
|
443
|
+
await this.ensureLoaded()
|
|
444
|
+
const currentIds = [...this.tagsById.keys()]
|
|
445
|
+
if (orderedIds.length !== currentIds.length) throw new StoreError('validation', 'orderedIds length mismatch')
|
|
446
|
+
const seen = new Set<string>()
|
|
447
|
+
for (const id of orderedIds) {
|
|
448
|
+
if (typeof id !== 'string' || id === '') throw new StoreError('validation', 'invalid tag id')
|
|
449
|
+
if (seen.has(id)) throw new StoreError('validation', 'duplicate tag id: ' + id)
|
|
450
|
+
if (!this.tagsById.has(id)) throw new StoreError('not-found', 'tag not found: ' + id)
|
|
451
|
+
seen.add(id)
|
|
452
|
+
}
|
|
453
|
+
const newMap = new Map<string, SkillTag>()
|
|
454
|
+
for (const id of orderedIds) newMap.set(id, this.tagsById.get(id)!)
|
|
455
|
+
this.tagsById = newMap
|
|
456
|
+
await this.persist()
|
|
457
|
+
return [...this.tagsById.values()]
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Collection order for 来源分组拖拽 */
|
|
461
|
+
async getCollectionOrder(): Promise<string[]> {
|
|
462
|
+
await this.ensureLoaded()
|
|
463
|
+
return [...this.collectionOrder]
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Reorder collections by orderedNames (drag-and-drop). */
|
|
467
|
+
async reorderCollections(orderedNames: string[]): Promise<string[]> {
|
|
468
|
+
await this.ensureLoaded()
|
|
469
|
+
const uniq = [...new Set(orderedNames.filter((n): n is string => typeof n === 'string' && n !== ''))]
|
|
470
|
+
this.collectionOrder = uniq
|
|
471
|
+
await this.persist()
|
|
472
|
+
return [...this.collectionOrder]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Source top-level group order for 来源分组(project / col:xxx / personal) */
|
|
476
|
+
async getSourceGroupOrder(): Promise<string[]> {
|
|
477
|
+
await this.ensureLoaded()
|
|
478
|
+
return [...this.sourceGroupOrder]
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Reorder source top-level groups by orderedKeys */
|
|
482
|
+
async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
|
|
483
|
+
await this.ensureLoaded()
|
|
484
|
+
const uniq = [...new Set(orderedKeys.filter((k): k is string => typeof k === 'string' && k !== ''))]
|
|
485
|
+
this.sourceGroupOrder = uniq
|
|
486
|
+
await this.persist()
|
|
487
|
+
return [...this.sourceGroupOrder]
|
|
488
|
+
}
|
|
489
|
+
|
|
424
490
|
// ------------------------------------------------------------ sources
|
|
425
491
|
|
|
426
492
|
/** All source records, sorted by repo. */
|
|
@@ -455,7 +521,7 @@ export class SkillHubStore {
|
|
|
455
521
|
* Upsert one skill into a source record. When the repo has no record yet a
|
|
456
522
|
* new one is created (root + commit snapshot from the caller).
|
|
457
523
|
*/
|
|
458
|
-
async addSourceSkill(repo: string, root:
|
|
524
|
+
async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
|
|
459
525
|
await this.ensureLoaded()
|
|
460
526
|
const existing = this.sourcesByRepo.get(repo)
|
|
461
527
|
if (existing === undefined) {
|
|
@@ -670,6 +736,8 @@ export class SkillHubStore {
|
|
|
670
736
|
...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
|
|
671
737
|
...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
|
|
672
738
|
...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
|
|
739
|
+
...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
|
|
740
|
+
...(this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}),
|
|
673
741
|
}
|
|
674
742
|
const tmp = this.file + '.tmp'
|
|
675
743
|
await mkdir(dirname(this.file), { recursive: true })
|