registry.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. // 技能发布平台(docs/design/18-skill-registry.md):项目用发布密钥推送技能包,管理员审核后新建技能或覆盖已有技能。
  2. // 推送只产生待审核的提交项,不改变任何技能;提交项的文件清单在推送时存入文件库且不可修改,批准的就是审核时看到的内容
  3. import crypto from 'node:crypto'
  4. import fs from 'node:fs'
  5. import os from 'node:os'
  6. import path from 'node:path'
  7. import { and, desc, eq, gte, inArray } from 'drizzle-orm'
  8. import type {
  9. RegistryItemDto, RegistryMeDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto,
  10. SkillSubmissionItemDetailDto, SkillSubmissionItemDto, SkillSubmissionStatus,
  11. } from '@agent-server/shared'
  12. import type { DB } from '../db'
  13. import { skillPublishKeys, skillSubmissionItems, skillSubmissions, type FileMap, type SkillCheck } from '../db/schema'
  14. import { AppError, badRequest, conflict, notFound } from '../lib/errors'
  15. import { newId, nowIso } from '../lib/ids'
  16. import { hashKey } from '../services/catalog'
  17. import { detectSkillDirs, extractZip, readSkillDir, type PackageSkill } from './package'
  18. import type { CustomSkillService } from './service'
  19. import { changesBetween, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath, sha256 } from './store'
  20. type KeyRow = typeof skillPublishKeys.$inferSelect
  21. type ItemRow = typeof skillSubmissionItems.$inferSelect
  22. type SubmissionRow = typeof skillSubmissions.$inferSelect
  23. export const PUBLISH_KEY_PREFIX = 'sk_pub_'
  24. /** 每个密钥每小时的推送次数、待审核的提交项数 */
  25. export const REGISTRY_LIMITS = { submissionsPerHour: 30, pendingPerKey: 50 }
  26. const SCRIPT_RE = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd|php)$/i
  27. const SECRET_PATTERNS: [RegExp, string][] = [
  28. [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, '私钥'],
  29. [/\bAKIA[0-9A-Z]{16}\b/, 'AWS Access Key'],
  30. [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, 'API Key(sk-…)'],
  31. [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'GitHub 令牌'],
  32. [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'],
  33. [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, '本平台的密钥'],
  34. [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'],
  35. [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '密码或密钥赋值'],
  36. ]
  37. const URL_RE = /\bhttps?:\/\/([a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?/gi
  38. const DONE: SkillSubmissionStatus[] = ['approved', 'rejected', 'withdrawn', 'superseded', 'invalid', 'unchanged']
  39. export class SkillRegistryService {
  40. constructor(
  41. private db: DB,
  42. private skills: CustomSkillService,
  43. private audit: (summary: string, detail?: unknown) => void,
  44. ) {}
  45. // ---- 发布密钥 ----
  46. listKeys(): SkillPublishKeyDto[] {
  47. const counts = new Map<string, { submissions: number; pending: number }>()
  48. const subs = new Map(this.db.select({ id: skillSubmissions.id, keyId: skillSubmissions.keyId }).from(skillSubmissions).all().map(s => [s.id, s.keyId]))
  49. for (const s of subs.values()) counts.set(s, { submissions: (counts.get(s)?.submissions ?? 0) + 1, pending: counts.get(s)?.pending ?? 0 })
  50. for (const i of this.db.select({ submissionId: skillSubmissionItems.submissionId }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all()) {
  51. const k = subs.get(i.submissionId)
  52. if (k && counts.get(k)) counts.get(k)!.pending++
  53. }
  54. return this.db.select().from(skillPublishKeys).orderBy(desc(skillPublishKeys.createdAt)).all().map(r => ({
  55. id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt, origin: r.origin,
  56. createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, lastUsedIp: r.lastUsedIp, ...(counts.get(r.id) ?? { submissions: 0, pending: 0 }),
  57. }))
  58. }
  59. /** 返回明文密钥,只在创建时出现一次 */
  60. createKey(input: SkillPublishKeyInput, origin: 'manual' | 'device' = 'manual'): { key: string; publishKey: SkillPublishKeyDto } {
  61. const name = input.name?.trim()
  62. if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字),它表示推送的来源,例如"fwh-app · GitHub Actions"')
  63. const days = input.expiresInDays
  64. if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 3650)) throw badRequest('有效期应在 1 ~ 3650 天之间,或选择永久')
  65. const key = `${PUBLISH_KEY_PREFIX}${crypto.randomBytes(24).toString('base64url')}`
  66. const id = newId('spk')
  67. this.db.insert(skillPublishKeys).values({
  68. id, name, description: (input.description ?? '').slice(0, 500), keyHash: hashKey(key), keyPrefix: key.slice(0, 12),
  69. expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(), origin,
  70. }).run()
  71. this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…${origin === 'device' ? ',推送工具浏览器登录' : ''})`)
  72. return { key, publishKey: this.listKeys().find(k => k.id === id)! }
  73. }
  74. updateKey(id: string, input: { name?: string; description?: string; enabled?: boolean }): SkillPublishKeyDto {
  75. const r = this.keyRow(id)
  76. const name = input.name === undefined ? r.name : input.name.trim()
  77. if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字)')
  78. const enabled = input.enabled ?? r.enabled
  79. this.db.update(skillPublishKeys).set({ name, description: (input.description ?? r.description).slice(0, 500), enabled }).where(eq(skillPublishKeys.id, id)).run()
  80. if (enabled !== r.enabled) this.audit(`${enabled ? '启用' : '停用'}技能发布密钥 ${name}`)
  81. else if (name !== r.name) this.audit(`技能发布密钥 ${r.name} 改名为 ${name}`)
  82. return this.listKeys().find(k => k.id === id)!
  83. }
  84. /** 删除密钥:已批准的技能不受影响,待审核的提交项保留 */
  85. deleteKey(id: string) {
  86. const r = this.keyRow(id)
  87. this.db.delete(skillPublishKeys).where(eq(skillPublishKeys.id, id)).run()
  88. this.audit(`删除技能发布密钥 ${r.name}(${r.keyPrefix}…)`)
  89. }
  90. /** 发布接口鉴权:返回密钥(启用且未过期),并记录使用时间与来源 IP */
  91. verify(key: string, ip: string): KeyRow | null {
  92. if (!key.startsWith(PUBLISH_KEY_PREFIX)) return null
  93. const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.keyHash, hashKey(key))).get()
  94. if (!r || !r.enabled || (r.expiresAt && Date.parse(r.expiresAt) <= Date.now())) return null
  95. this.db.update(skillPublishKeys).set({ lastUsedAt: nowIso(), lastUsedIp: ip }).where(eq(skillPublishKeys.id, r.id)).run()
  96. return r
  97. }
  98. /** 推送工具 whoami:当前密钥的信息 */
  99. me(key: KeyRow): RegistryMeDto {
  100. return { name: key.name, prefix: key.keyPrefix, expiresAt: key.expiresAt, origin: key.origin, createdAt: key.createdAt }
  101. }
  102. /** 推送工具 logout:停用当前密钥 */
  103. revokeSelf(key: KeyRow) {
  104. this.db.update(skillPublishKeys).set({ enabled: false }).where(eq(skillPublishKeys.id, key.id)).run()
  105. this.audit(`推送工具退出登录,停用技能发布密钥 ${key.name}(${key.keyPrefix}…)`)
  106. }
  107. // ---- 推送(发布接口) ----
  108. /** 推送一个技能包;dryRun 只校验不保存 */
  109. submit(key: KeyRow, pkg: { data: Buffer; message: string; meta: Record<string, string> }, dryRun: boolean): RegistrySubmissionDto {
  110. if (pkg.data.length > LIMITS.zipBytes) throw badRequest(`技能包不能超过 ${LIMITS.zipBytes / 1048576} MB`)
  111. const message = pkg.message.slice(0, 2000)
  112. const meta = Object.fromEntries(Object.entries(pkg.meta).slice(0, 20).map(([k, v]) => [k.slice(0, 40), String(v).slice(0, 300)]))
  113. if (!dryRun) {
  114. const since = new Date(Date.now() - 3600_000).toISOString()
  115. const recent = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(and(eq(skillSubmissions.keyId, key.id), gte(skillSubmissions.createdAt, since))).all().length
  116. if (recent >= REGISTRY_LIMITS.submissionsPerHour) throw new AppError(429, 'rate_limited', `每个密钥每小时最多推送 ${REGISTRY_LIMITS.submissionsPerHour} 次,请稍后再试`)
  117. }
  118. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-push-'))
  119. try {
  120. const skipped = extractZip(pkg.data, tmp)
  121. const dirs = detectSkillDirs(tmp)
  122. if (!dirs.length) throw badRequest('技能包中没有找到技能(需要包含 SKILL.md 的目录:单个技能、<名称>/SKILL.md 或 skills/<名称>/SKILL.md)')
  123. const seen = new Set<string>()
  124. const evaluated = dirs.map(d => {
  125. const sk = readSkillDir(d, skipped)
  126. if (!sk.reason && seen.has(sk.name)) sk.reason = `技能包中有多个名为 ${sk.name} 的技能`
  127. seen.add(sk.name)
  128. return this.evaluate(sk)
  129. })
  130. const newPending = evaluated.filter(e => e.status === 'pending')
  131. if (!dryRun && newPending.length) {
  132. const replaced = new Set(newPending.map(e => e.skill.name))
  133. const pending = this.pendingOfKey(key.id).filter(i => !replaced.has(i.name)).length
  134. if (pending + newPending.length > REGISTRY_LIMITS.pendingPerKey) {
  135. throw new AppError(429, 'too_many_pending', `待审核的技能最多 ${REGISTRY_LIMITS.pendingPerKey} 个,请等管理员处理后再推送`)
  136. }
  137. }
  138. const now = nowIso()
  139. if (dryRun) {
  140. return {
  141. id: '', status: newPending.length ? 'pending' : 'done', message, meta, createdAt: now, dryRun: true, skipped,
  142. items: evaluated.map(e => this.toRegistryItem({ id: '', status: e.status, reason: e.skill.reason, name: e.skill.name, declaredVersion: e.skill.declaredVersion, checks: e.checks, reviewNote: null, result: null, files: e.skill.map } as ItemRow)),
  143. }
  144. }
  145. // 文件存入文件库(按推送时的摘要核对)
  146. for (const e of evaluated) {
  147. if (e.skill.reason) continue
  148. for (const f of e.skill.files) {
  149. const stored = this.skills.store.put(fs.readFileSync(f.abs))
  150. if (stored.sha !== e.skill.map[f.rel]!.sha) throw conflict('package_changed', '技能包在处理过程中发生了变化,请重新推送')
  151. }
  152. }
  153. const id = newId('sub')
  154. this.db.transaction(tx => {
  155. tx.insert(skillSubmissions).values({ id, keyId: key.id, keyName: key.name, message, meta, packageSha: sha256(pkg.data), packageSize: pkg.data.length, createdAt: now }).run()
  156. for (const e of evaluated) {
  157. if (e.status === 'pending') {
  158. // 同一密钥推送的同名技能只审核最新的一份
  159. const old = this.pendingOfKey(key.id).filter(i => i.name === e.skill.name).map(i => i.id)
  160. if (old.length) tx.update(skillSubmissionItems).set({ status: 'superseded', reviewedAt: now }).where(inArray(skillSubmissionItems.id, old)).run()
  161. }
  162. tx.insert(skillSubmissionItems).values({
  163. id: newId('sbi'), submissionId: id, name: e.skill.name, description: e.skill.description, declaredVersion: e.skill.declaredVersion,
  164. files: e.skill.reason ? {} : e.skill.map, checks: e.checks, status: e.status, reason: e.skill.reason, createdAt: now,
  165. }).run()
  166. }
  167. })
  168. this.audit(`推送技能包(${key.name},${(pkg.data.length / 1024).toFixed(1)} KB,sha256 ${sha256(pkg.data).slice(0, 16)}…):${evaluated.map(e => `${e.skill.name}(${STATUS_TEXT[e.status]})`).join('、')}`,
  169. { submissionId: id, keyPrefix: key.keyPrefix, message, meta, skipped })
  170. return { ...this.getSubmission(key.id, id), skipped }
  171. } finally {
  172. fs.rmSync(tmp, { recursive: true, force: true })
  173. }
  174. }
  175. listSubmissions(keyId: string, opts: { status?: 'pending' | 'done'; limit?: number }): RegistrySubmissionDto[] {
  176. const subs = this.db.select().from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).orderBy(desc(skillSubmissions.createdAt)).all()
  177. const out = subs.map(s => this.toSubmission(s)).filter(s => !opts.status || s.status === opts.status)
  178. return out.slice(0, Math.min(Math.max(opts.limit ?? 20, 1), 100))
  179. }
  180. getSubmission(keyId: string, id: string): RegistrySubmissionDto {
  181. const s = this.db.select().from(skillSubmissions).where(and(eq(skillSubmissions.id, id), eq(skillSubmissions.keyId, keyId))).get()
  182. if (!s) throw notFound('提交')
  183. return this.toSubmission(s)
  184. }
  185. /** 撤回提交中仍在待审核的技能 */
  186. withdraw(keyId: string, id: string): RegistrySubmissionDto {
  187. const s = this.getSubmission(keyId, id)
  188. const ids = s.items.filter(i => i.status === 'pending').map(i => i.id)
  189. if (ids.length) {
  190. this.db.update(skillSubmissionItems).set({ status: 'withdrawn', reviewedAt: nowIso() }).where(inArray(skillSubmissionItems.id, ids)).run()
  191. this.audit(`撤回推送 ${id}:${s.items.filter(i => ids.includes(i.id)).map(i => i.name).join('、')}`)
  192. }
  193. return this.getSubmission(keyId, id)
  194. }
  195. // ---- 审核(后台) ----
  196. pendingCount(): number {
  197. return this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all().length
  198. }
  199. listItems(filter: 'pending' | 'done'): SkillSubmissionItemDto[] {
  200. const rows = this.db.select().from(skillSubmissionItems)
  201. .where(filter === 'pending' ? eq(skillSubmissionItems.status, 'pending') : inArray(skillSubmissionItems.status, DONE))
  202. .orderBy(desc(skillSubmissionItems.createdAt)).all()
  203. const subs = this.submissionsById(rows.map(r => r.submissionId))
  204. return rows.map(r => this.toItem(r, subs.get(r.submissionId)!))
  205. }
  206. item(id: string): SkillSubmissionItemDetailDto {
  207. const r = this.itemRow(id)
  208. return {
  209. ...this.toItem(r, this.submissionsById([r.submissionId]).get(r.submissionId)!),
  210. files: Object.entries(r.files).map(([p, f]) => ({ path: p, size: f.size, sha: f.sha, text: isTextPath(p) }))
  211. .sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path)),
  212. }
  213. }
  214. readFile(id: string, rawPath: string): { data: Buffer; name: string; text: boolean } {
  215. const r = this.itemRow(id)
  216. const rel = normalizeRelPath(rawPath)
  217. const f = r.files[rel]
  218. if (!f) throw notFound(`文件 ${rel}`)
  219. return { data: this.skills.store.get(f.sha), name: path.posix.basename(rel), text: isTextPath(rel) }
  220. }
  221. /** 差异:覆盖时与目标技能的最新版本对比;新建时与空技能对比。技能名与 SKILL.md 中的不同时按改写后的内容对比 */
  222. diff(id: string, opts: { targetId?: string; name?: string }): SkillSubmissionDiffDto {
  223. const r = this.itemRow(id)
  224. const target = opts.targetId ? this.skills.latest(opts.targetId) : null
  225. const name = target?.name ?? opts.name?.trim() ?? r.name
  226. const files = this.withName(r.files, name)
  227. return {
  228. target: target && { id: target.id, name: target.name, version: target.version, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt },
  229. renamed: name !== r.name,
  230. files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.version}` : '(空)', '提交'),
  231. }
  232. }
  233. approve(id: string, input: SkillApproveInput): SkillSubmissionItemDto {
  234. const r = this.itemRow(id)
  235. if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
  236. const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
  237. const name = input.action === 'create' ? input.name.trim() : this.skills.latest(input.targetId).name
  238. if (input.action === 'create' && !NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
  239. const res = this.skills.applySubmission(
  240. input.action === 'create' ? { action: 'create', name } : { action: 'overwrite', id: input.targetId, expectedVersion: input.expectedVersion },
  241. this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish,
  242. )
  243. const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, published: input.publish }
  244. this.db.update(skillSubmissionItems).set({ status: 'approved', result, reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
  245. this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.version}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id })
  246. return this.toItem(this.itemRow(id), sub)
  247. }
  248. reject(id: string, reason: string): SkillSubmissionItemDto {
  249. const r = this.itemRow(id)
  250. if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
  251. const note = reason.trim()
  252. if (!note) throw badRequest('请填写拒绝的原因(推送方可以看到)')
  253. this.db.update(skillSubmissionItems).set({ status: 'rejected', reviewNote: note.slice(0, 2000), reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
  254. const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
  255. this.audit(`拒绝推送的技能 ${r.name}(${sub.keyName}):${note}`, { itemId: id, submissionId: sub.id })
  256. return this.toItem(this.itemRow(id), sub)
  257. }
  258. /** 删除已处理的提交项:不影响已生成的技能版本;提交中没有其他项时一并删除提交 */
  259. deleteItem(id: string) {
  260. const r = this.itemRow(id)
  261. if (r.status === 'pending') throw conflict('pending', '待审核的提交项请先批准或拒绝')
  262. this.db.delete(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).run()
  263. if (!this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, r.submissionId)).get()) {
  264. this.db.delete(skillSubmissions).where(eq(skillSubmissions.id, r.submissionId)).run()
  265. }
  266. this.skills.gc()
  267. this.audit(`删除技能提交记录 ${r.name}(${STATUS_TEXT[r.status]})`)
  268. }
  269. // ---- 内部 ----
  270. /** 推送时判定:校验不通过、与同名技能当前版本相同,或待审核(附自动检查) */
  271. private evaluate(sk: PackageSkill): { skill: PackageSkill; status: SkillSubmissionStatus; checks: SkillCheck[] } {
  272. if (sk.reason) return { skill: sk, status: 'invalid', checks: [] }
  273. const existing = this.skills.byName(sk.name)
  274. const latest = existing ? this.skills.latest(existing.id) : null
  275. if (latest && mapDigest(latest.files) === mapDigest(sk.map)) return { skill: sk, status: 'unchanged', checks: [] }
  276. const checks: SkillCheck[] = sk.warnings.map(w => ({ level: 'warning' as const, code: 'skill_md' as const, message: w }))
  277. const scripts = sk.files.filter(f => SCRIPT_RE.test(f.rel)).map(f => f.rel)
  278. if (scripts.length) checks.push({ level: 'warning', code: 'script', message: `包含 ${scripts.length} 个脚本文件,请重点审核`, files: scripts })
  279. const binaries = sk.files.filter(f => !isTextPath(f.rel)).map(f => f.rel)
  280. if (binaries.length) checks.push({ level: 'info', code: 'binary', message: `包含 ${binaries.length} 个二进制文件`, files: binaries })
  281. const secrets = new Map<string, string[]>()
  282. const hosts = new Set<string>()
  283. for (const f of sk.files) {
  284. if (!isTextPath(f.rel) || f.size > 1048576) continue
  285. const text = fs.readFileSync(f.abs, 'utf8')
  286. for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.set(label, [...(secrets.get(label) ?? []), f.rel])
  287. for (const m of text.matchAll(URL_RE)) hosts.add(m[1]!.toLowerCase())
  288. }
  289. for (const [label, files] of secrets) checks.push({ level: 'danger', code: 'secret', message: `疑似包含${label}`, files })
  290. if (hosts.size) checks.push({ level: 'info', code: 'url', message: `引用的外部地址:${[...hosts].slice(0, 20).join('、')}${hosts.size > 20 ? ` 等 ${hosts.size} 个` : ''}` })
  291. if (latest && sk.declaredVersion && sk.declaredVersion === latest.declaredVersion) {
  292. checks.push({ level: 'warning', code: 'version', message: `声明版本号仍为 ${sk.declaredVersion},但内容有变化` })
  293. } else if (latest && (sk.declaredVersion || latest.declaredVersion)) {
  294. checks.push({ level: 'info', code: 'version', message: `声明版本号:${latest.declaredVersion ?? '未声明'} → ${sk.declaredVersion ?? '未声明'}` })
  295. }
  296. return { skill: sk, status: 'pending', checks }
  297. }
  298. /** 技能名与 SKILL.md 中的 name 不同时,改写 SKILL.md 的 name */
  299. private withName(files: FileMap, name: string): FileMap {
  300. const md = files['SKILL.md']
  301. if (!md) return files
  302. const text = this.skills.store.get(md.sha).toString('utf8')
  303. const next = renameSkillMd(text, name)
  304. return next === text ? files : { ...files, 'SKILL.md': this.skills.store.put(next) }
  305. }
  306. private pendingOfKey(keyId: string): ItemRow[] {
  307. const subs = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).all().map(s => s.id)
  308. if (!subs.length) return []
  309. return this.db.select().from(skillSubmissionItems).where(and(inArray(skillSubmissionItems.submissionId, subs), eq(skillSubmissionItems.status, 'pending'))).all()
  310. }
  311. private keyRow(id: string): KeyRow {
  312. const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.id, id)).get()
  313. if (!r) throw notFound('发布密钥')
  314. return r
  315. }
  316. private itemRow(id: string): ItemRow {
  317. const r = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).get()
  318. if (!r) throw notFound('提交项')
  319. return r
  320. }
  321. private submissionsById(ids: string[]): Map<string, SubmissionRow> {
  322. if (!ids.length) return new Map()
  323. return new Map(this.db.select().from(skillSubmissions).where(inArray(skillSubmissions.id, [...new Set(ids)])).all().map(s => [s.id, s]))
  324. }
  325. private existingOf(name: string) {
  326. const r = this.skills.byName(name)
  327. if (!r) return null
  328. const l = this.skills.latest(r.id)
  329. return { id: r.id, version: l.version, declaredVersion: l.declaredVersion, files: l.files }
  330. }
  331. private toItem(r: ItemRow, s: SubmissionRow): SkillSubmissionItemDto {
  332. const e = r.status === 'pending' ? this.existingOf(r.name) : null
  333. const files = Object.values(r.files)
  334. return {
  335. id: r.id, submissionId: r.submissionId, name: r.name, description: r.description, declaredVersion: r.declaredVersion, status: r.status, reason: r.reason,
  336. checks: r.checks, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), keyName: s.keyName, message: s.message, meta: s.meta,
  337. createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result,
  338. }
  339. }
  340. private toRegistryItem(r: ItemRow): RegistryItemDto {
  341. const e = r.status === 'pending' || r.status === 'unchanged' || !r.id ? this.existingOf(r.name) : null
  342. return {
  343. id: r.id, name: r.name, status: r.status, reason: r.reason, declaredVersion: r.declaredVersion,
  344. existing: e && { version: e.version, declaredVersion: e.declaredVersion },
  345. changes: r.status === 'pending' ? changesBetween(e?.files ?? {}, r.files) : null,
  346. checks: r.checks, reviewNote: r.reviewNote,
  347. result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, published: r.result.published },
  348. }
  349. }
  350. private toSubmission(s: SubmissionRow): RegistrySubmissionDto {
  351. const items = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, s.id)).all()
  352. return {
  353. id: s.id, status: items.some(i => i.status === 'pending') ? 'pending' : 'done', message: s.message, meta: s.meta, createdAt: s.createdAt, dryRun: false, skipped: [],
  354. items: items.map(i => this.toRegistryItem(i)),
  355. }
  356. }
  357. }
  358. const STATUS_TEXT: Record<SkillSubmissionStatus, string> = {
  359. pending: '待审核', approved: '已批准', rejected: '已拒绝', withdrawn: '已撤回', superseded: '已替代', invalid: '不能导入', unchanged: '无变化',
  360. }
  361. /** 改写 SKILL.md frontmatter 中的 name(没有 frontmatter 时补上) */
  362. export function renameSkillMd(md: string, name: string): string {
  363. const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(md)
  364. if (!m) return `---\nname: ${name}\n---\n${md}`
  365. const body = /^name:.*$/m.test(m[2]!) ? m[2]!.replace(/^name:.*$/m, `name: ${name}`) : `name: ${name}\n${m[2]}`
  366. return m[1] + body + m[3] + md.slice(m[0].length)
  367. }