| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- // 技能发布平台(docs/design/18-skill-registry.md):项目用发布密钥推送技能包,管理员审核后新建技能或覆盖已有技能。
- // 推送只产生待审核的提交项,不改变任何技能;提交项的文件清单在推送时存入文件库且不可修改,批准的就是审核时看到的内容
- import crypto from 'node:crypto'
- import fs from 'node:fs'
- import os from 'node:os'
- import path from 'node:path'
- import { and, desc, eq, gte, inArray } from 'drizzle-orm'
- import type {
- RegistryItemDto, RegistryMeDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto,
- SkillSubmissionItemDetailDto, SkillSubmissionItemDto, SkillSubmissionStatus,
- } from '@agent-server/shared'
- import type { DB } from '../db'
- import { skillPublishKeys, skillSubmissionItems, skillSubmissions, type FileMap, type SkillCheck } from '../db/schema'
- import { AppError, badRequest, conflict, notFound } from '../lib/errors'
- import { newId, nowIso } from '../lib/ids'
- import { hashKey } from '../services/catalog'
- import { detectSkillDirs, extractZip, readSkillDir, type PackageSkill } from './package'
- import type { CustomSkillService } from './service'
- import { changesBetween, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath, sha256 } from './store'
- type KeyRow = typeof skillPublishKeys.$inferSelect
- type ItemRow = typeof skillSubmissionItems.$inferSelect
- type SubmissionRow = typeof skillSubmissions.$inferSelect
- export const PUBLISH_KEY_PREFIX = 'sk_pub_'
- /** 每个密钥每小时的推送次数、待审核的提交项数 */
- export const REGISTRY_LIMITS = { submissionsPerHour: 30, pendingPerKey: 50 }
- const SCRIPT_RE = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd|php)$/i
- const SECRET_PATTERNS: [RegExp, string][] = [
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, '私钥'],
- [/\bAKIA[0-9A-Z]{16}\b/, 'AWS Access Key'],
- [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, 'API Key(sk-…)'],
- [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'GitHub 令牌'],
- [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'],
- [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, '本平台的密钥'],
- [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'],
- [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '密码或密钥赋值'],
- ]
- const URL_RE = /\bhttps?:\/\/([a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?/gi
- const DONE: SkillSubmissionStatus[] = ['approved', 'rejected', 'withdrawn', 'superseded', 'invalid', 'unchanged']
- export class SkillRegistryService {
- constructor(
- private db: DB,
- private skills: CustomSkillService,
- private audit: (summary: string, detail?: unknown) => void,
- ) {}
- // ---- 发布密钥 ----
- listKeys(): SkillPublishKeyDto[] {
- const counts = new Map<string, { submissions: number; pending: number }>()
- const subs = new Map(this.db.select({ id: skillSubmissions.id, keyId: skillSubmissions.keyId }).from(skillSubmissions).all().map(s => [s.id, s.keyId]))
- for (const s of subs.values()) counts.set(s, { submissions: (counts.get(s)?.submissions ?? 0) + 1, pending: counts.get(s)?.pending ?? 0 })
- for (const i of this.db.select({ submissionId: skillSubmissionItems.submissionId }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all()) {
- const k = subs.get(i.submissionId)
- if (k && counts.get(k)) counts.get(k)!.pending++
- }
- return this.db.select().from(skillPublishKeys).orderBy(desc(skillPublishKeys.createdAt)).all().map(r => ({
- id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt, origin: r.origin,
- createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, lastUsedIp: r.lastUsedIp, ...(counts.get(r.id) ?? { submissions: 0, pending: 0 }),
- }))
- }
- /** 返回明文密钥,只在创建时出现一次 */
- createKey(input: SkillPublishKeyInput, origin: 'manual' | 'device' = 'manual'): { key: string; publishKey: SkillPublishKeyDto } {
- const name = input.name?.trim()
- if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字),它表示推送的来源,例如"fwh-app · GitHub Actions"')
- const days = input.expiresInDays
- if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 3650)) throw badRequest('有效期应在 1 ~ 3650 天之间,或选择永久')
- const key = `${PUBLISH_KEY_PREFIX}${crypto.randomBytes(24).toString('base64url')}`
- const id = newId('spk')
- this.db.insert(skillPublishKeys).values({
- id, name, description: (input.description ?? '').slice(0, 500), keyHash: hashKey(key), keyPrefix: key.slice(0, 12),
- expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(), origin,
- }).run()
- this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…${origin === 'device' ? ',推送工具浏览器登录' : ''})`)
- return { key, publishKey: this.listKeys().find(k => k.id === id)! }
- }
- updateKey(id: string, input: { name?: string; description?: string; enabled?: boolean }): SkillPublishKeyDto {
- const r = this.keyRow(id)
- const name = input.name === undefined ? r.name : input.name.trim()
- if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字)')
- const enabled = input.enabled ?? r.enabled
- this.db.update(skillPublishKeys).set({ name, description: (input.description ?? r.description).slice(0, 500), enabled }).where(eq(skillPublishKeys.id, id)).run()
- if (enabled !== r.enabled) this.audit(`${enabled ? '启用' : '停用'}技能发布密钥 ${name}`)
- else if (name !== r.name) this.audit(`技能发布密钥 ${r.name} 改名为 ${name}`)
- return this.listKeys().find(k => k.id === id)!
- }
- /** 删除密钥:已批准的技能不受影响,待审核的提交项保留 */
- deleteKey(id: string) {
- const r = this.keyRow(id)
- this.db.delete(skillPublishKeys).where(eq(skillPublishKeys.id, id)).run()
- this.audit(`删除技能发布密钥 ${r.name}(${r.keyPrefix}…)`)
- }
- /** 发布接口鉴权:返回密钥(启用且未过期),并记录使用时间与来源 IP */
- verify(key: string, ip: string): KeyRow | null {
- if (!key.startsWith(PUBLISH_KEY_PREFIX)) return null
- const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.keyHash, hashKey(key))).get()
- if (!r || !r.enabled || (r.expiresAt && Date.parse(r.expiresAt) <= Date.now())) return null
- this.db.update(skillPublishKeys).set({ lastUsedAt: nowIso(), lastUsedIp: ip }).where(eq(skillPublishKeys.id, r.id)).run()
- return r
- }
- /** 推送工具 whoami:当前密钥的信息 */
- me(key: KeyRow): RegistryMeDto {
- return { name: key.name, prefix: key.keyPrefix, expiresAt: key.expiresAt, origin: key.origin, createdAt: key.createdAt }
- }
- /** 推送工具 logout:停用当前密钥 */
- revokeSelf(key: KeyRow) {
- this.db.update(skillPublishKeys).set({ enabled: false }).where(eq(skillPublishKeys.id, key.id)).run()
- this.audit(`推送工具退出登录,停用技能发布密钥 ${key.name}(${key.keyPrefix}…)`)
- }
- // ---- 推送(发布接口) ----
- /** 推送一个技能包;dryRun 只校验不保存 */
- submit(key: KeyRow, pkg: { data: Buffer; message: string; meta: Record<string, string> }, dryRun: boolean): RegistrySubmissionDto {
- if (pkg.data.length > LIMITS.zipBytes) throw badRequest(`技能包不能超过 ${LIMITS.zipBytes / 1048576} MB`)
- const message = pkg.message.slice(0, 2000)
- const meta = Object.fromEntries(Object.entries(pkg.meta).slice(0, 20).map(([k, v]) => [k.slice(0, 40), String(v).slice(0, 300)]))
- if (!dryRun) {
- const since = new Date(Date.now() - 3600_000).toISOString()
- const recent = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(and(eq(skillSubmissions.keyId, key.id), gte(skillSubmissions.createdAt, since))).all().length
- if (recent >= REGISTRY_LIMITS.submissionsPerHour) throw new AppError(429, 'rate_limited', `每个密钥每小时最多推送 ${REGISTRY_LIMITS.submissionsPerHour} 次,请稍后再试`)
- }
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-push-'))
- try {
- const skipped = extractZip(pkg.data, tmp)
- const dirs = detectSkillDirs(tmp)
- if (!dirs.length) throw badRequest('技能包中没有找到技能(需要包含 SKILL.md 的目录:单个技能、<名称>/SKILL.md 或 skills/<名称>/SKILL.md)')
- const seen = new Set<string>()
- const evaluated = dirs.map(d => {
- const sk = readSkillDir(d, skipped)
- if (!sk.reason && seen.has(sk.name)) sk.reason = `技能包中有多个名为 ${sk.name} 的技能`
- seen.add(sk.name)
- return this.evaluate(sk)
- })
- const newPending = evaluated.filter(e => e.status === 'pending')
- if (!dryRun && newPending.length) {
- const replaced = new Set(newPending.map(e => e.skill.name))
- const pending = this.pendingOfKey(key.id).filter(i => !replaced.has(i.name)).length
- if (pending + newPending.length > REGISTRY_LIMITS.pendingPerKey) {
- throw new AppError(429, 'too_many_pending', `待审核的技能最多 ${REGISTRY_LIMITS.pendingPerKey} 个,请等管理员处理后再推送`)
- }
- }
- const now = nowIso()
- if (dryRun) {
- return {
- id: '', status: newPending.length ? 'pending' : 'done', message, meta, createdAt: now, dryRun: true, skipped,
- 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)),
- }
- }
- // 文件存入文件库(按推送时的摘要核对)
- for (const e of evaluated) {
- if (e.skill.reason) continue
- for (const f of e.skill.files) {
- const stored = this.skills.store.put(fs.readFileSync(f.abs))
- if (stored.sha !== e.skill.map[f.rel]!.sha) throw conflict('package_changed', '技能包在处理过程中发生了变化,请重新推送')
- }
- }
- const id = newId('sub')
- this.db.transaction(tx => {
- tx.insert(skillSubmissions).values({ id, keyId: key.id, keyName: key.name, message, meta, packageSha: sha256(pkg.data), packageSize: pkg.data.length, createdAt: now }).run()
- for (const e of evaluated) {
- if (e.status === 'pending') {
- // 同一密钥推送的同名技能只审核最新的一份
- const old = this.pendingOfKey(key.id).filter(i => i.name === e.skill.name).map(i => i.id)
- if (old.length) tx.update(skillSubmissionItems).set({ status: 'superseded', reviewedAt: now }).where(inArray(skillSubmissionItems.id, old)).run()
- }
- tx.insert(skillSubmissionItems).values({
- id: newId('sbi'), submissionId: id, name: e.skill.name, description: e.skill.description, declaredVersion: e.skill.declaredVersion,
- files: e.skill.reason ? {} : e.skill.map, checks: e.checks, status: e.status, reason: e.skill.reason, createdAt: now,
- }).run()
- }
- })
- 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('、')}`,
- { submissionId: id, keyPrefix: key.keyPrefix, message, meta, skipped })
- return { ...this.getSubmission(key.id, id), skipped }
- } finally {
- fs.rmSync(tmp, { recursive: true, force: true })
- }
- }
- listSubmissions(keyId: string, opts: { status?: 'pending' | 'done'; limit?: number }): RegistrySubmissionDto[] {
- const subs = this.db.select().from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).orderBy(desc(skillSubmissions.createdAt)).all()
- const out = subs.map(s => this.toSubmission(s)).filter(s => !opts.status || s.status === opts.status)
- return out.slice(0, Math.min(Math.max(opts.limit ?? 20, 1), 100))
- }
- getSubmission(keyId: string, id: string): RegistrySubmissionDto {
- const s = this.db.select().from(skillSubmissions).where(and(eq(skillSubmissions.id, id), eq(skillSubmissions.keyId, keyId))).get()
- if (!s) throw notFound('提交')
- return this.toSubmission(s)
- }
- /** 撤回提交中仍在待审核的技能 */
- withdraw(keyId: string, id: string): RegistrySubmissionDto {
- const s = this.getSubmission(keyId, id)
- const ids = s.items.filter(i => i.status === 'pending').map(i => i.id)
- if (ids.length) {
- this.db.update(skillSubmissionItems).set({ status: 'withdrawn', reviewedAt: nowIso() }).where(inArray(skillSubmissionItems.id, ids)).run()
- this.audit(`撤回推送 ${id}:${s.items.filter(i => ids.includes(i.id)).map(i => i.name).join('、')}`)
- }
- return this.getSubmission(keyId, id)
- }
- // ---- 审核(后台) ----
- pendingCount(): number {
- return this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all().length
- }
- listItems(filter: 'pending' | 'done'): SkillSubmissionItemDto[] {
- const rows = this.db.select().from(skillSubmissionItems)
- .where(filter === 'pending' ? eq(skillSubmissionItems.status, 'pending') : inArray(skillSubmissionItems.status, DONE))
- .orderBy(desc(skillSubmissionItems.createdAt)).all()
- const subs = this.submissionsById(rows.map(r => r.submissionId))
- return rows.map(r => this.toItem(r, subs.get(r.submissionId)!))
- }
- item(id: string): SkillSubmissionItemDetailDto {
- const r = this.itemRow(id)
- return {
- ...this.toItem(r, this.submissionsById([r.submissionId]).get(r.submissionId)!),
- files: Object.entries(r.files).map(([p, f]) => ({ path: p, size: f.size, sha: f.sha, text: isTextPath(p) }))
- .sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path)),
- }
- }
- readFile(id: string, rawPath: string): { data: Buffer; name: string; text: boolean } {
- const r = this.itemRow(id)
- const rel = normalizeRelPath(rawPath)
- const f = r.files[rel]
- if (!f) throw notFound(`文件 ${rel}`)
- return { data: this.skills.store.get(f.sha), name: path.posix.basename(rel), text: isTextPath(rel) }
- }
- /** 差异:覆盖时与目标技能的最新版本对比;新建时与空技能对比。技能名与 SKILL.md 中的不同时按改写后的内容对比 */
- diff(id: string, opts: { targetId?: string; name?: string }): SkillSubmissionDiffDto {
- const r = this.itemRow(id)
- const target = opts.targetId ? this.skills.latest(opts.targetId) : null
- const name = target?.name ?? opts.name?.trim() ?? r.name
- const files = this.withName(r.files, name)
- return {
- target: target && { id: target.id, name: target.name, version: target.version, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt },
- renamed: name !== r.name,
- files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.version}` : '(空)', '提交'),
- }
- }
- approve(id: string, input: SkillApproveInput): SkillSubmissionItemDto {
- const r = this.itemRow(id)
- if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
- const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
- const name = input.action === 'create' ? input.name.trim() : this.skills.latest(input.targetId).name
- if (input.action === 'create' && !NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
- const res = this.skills.applySubmission(
- input.action === 'create' ? { action: 'create', name } : { action: 'overwrite', id: input.targetId, expectedVersion: input.expectedVersion },
- this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish,
- )
- const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, published: input.publish }
- this.db.update(skillSubmissionItems).set({ status: 'approved', result, reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
- this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.version}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id })
- return this.toItem(this.itemRow(id), sub)
- }
- reject(id: string, reason: string): SkillSubmissionItemDto {
- const r = this.itemRow(id)
- if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
- const note = reason.trim()
- if (!note) throw badRequest('请填写拒绝的原因(推送方可以看到)')
- this.db.update(skillSubmissionItems).set({ status: 'rejected', reviewNote: note.slice(0, 2000), reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
- const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
- this.audit(`拒绝推送的技能 ${r.name}(${sub.keyName}):${note}`, { itemId: id, submissionId: sub.id })
- return this.toItem(this.itemRow(id), sub)
- }
- /** 删除已处理的提交项:不影响已生成的技能版本;提交中没有其他项时一并删除提交 */
- deleteItem(id: string) {
- const r = this.itemRow(id)
- if (r.status === 'pending') throw conflict('pending', '待审核的提交项请先批准或拒绝')
- this.db.delete(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).run()
- if (!this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, r.submissionId)).get()) {
- this.db.delete(skillSubmissions).where(eq(skillSubmissions.id, r.submissionId)).run()
- }
- this.skills.gc()
- this.audit(`删除技能提交记录 ${r.name}(${STATUS_TEXT[r.status]})`)
- }
- // ---- 内部 ----
- /** 推送时判定:校验不通过、与同名技能当前版本相同,或待审核(附自动检查) */
- private evaluate(sk: PackageSkill): { skill: PackageSkill; status: SkillSubmissionStatus; checks: SkillCheck[] } {
- if (sk.reason) return { skill: sk, status: 'invalid', checks: [] }
- const existing = this.skills.byName(sk.name)
- const latest = existing ? this.skills.latest(existing.id) : null
- if (latest && mapDigest(latest.files) === mapDigest(sk.map)) return { skill: sk, status: 'unchanged', checks: [] }
- const checks: SkillCheck[] = sk.warnings.map(w => ({ level: 'warning' as const, code: 'skill_md' as const, message: w }))
- const scripts = sk.files.filter(f => SCRIPT_RE.test(f.rel)).map(f => f.rel)
- if (scripts.length) checks.push({ level: 'warning', code: 'script', message: `包含 ${scripts.length} 个脚本文件,请重点审核`, files: scripts })
- const binaries = sk.files.filter(f => !isTextPath(f.rel)).map(f => f.rel)
- if (binaries.length) checks.push({ level: 'info', code: 'binary', message: `包含 ${binaries.length} 个二进制文件`, files: binaries })
- const secrets = new Map<string, string[]>()
- const hosts = new Set<string>()
- for (const f of sk.files) {
- if (!isTextPath(f.rel) || f.size > 1048576) continue
- const text = fs.readFileSync(f.abs, 'utf8')
- for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.set(label, [...(secrets.get(label) ?? []), f.rel])
- for (const m of text.matchAll(URL_RE)) hosts.add(m[1]!.toLowerCase())
- }
- for (const [label, files] of secrets) checks.push({ level: 'danger', code: 'secret', message: `疑似包含${label}`, files })
- if (hosts.size) checks.push({ level: 'info', code: 'url', message: `引用的外部地址:${[...hosts].slice(0, 20).join('、')}${hosts.size > 20 ? ` 等 ${hosts.size} 个` : ''}` })
- if (latest && sk.declaredVersion && sk.declaredVersion === latest.declaredVersion) {
- checks.push({ level: 'warning', code: 'version', message: `声明版本号仍为 ${sk.declaredVersion},但内容有变化` })
- } else if (latest && (sk.declaredVersion || latest.declaredVersion)) {
- checks.push({ level: 'info', code: 'version', message: `声明版本号:${latest.declaredVersion ?? '未声明'} → ${sk.declaredVersion ?? '未声明'}` })
- }
- return { skill: sk, status: 'pending', checks }
- }
- /** 技能名与 SKILL.md 中的 name 不同时,改写 SKILL.md 的 name */
- private withName(files: FileMap, name: string): FileMap {
- const md = files['SKILL.md']
- if (!md) return files
- const text = this.skills.store.get(md.sha).toString('utf8')
- const next = renameSkillMd(text, name)
- return next === text ? files : { ...files, 'SKILL.md': this.skills.store.put(next) }
- }
- private pendingOfKey(keyId: string): ItemRow[] {
- const subs = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).all().map(s => s.id)
- if (!subs.length) return []
- return this.db.select().from(skillSubmissionItems).where(and(inArray(skillSubmissionItems.submissionId, subs), eq(skillSubmissionItems.status, 'pending'))).all()
- }
- private keyRow(id: string): KeyRow {
- const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.id, id)).get()
- if (!r) throw notFound('发布密钥')
- return r
- }
- private itemRow(id: string): ItemRow {
- const r = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).get()
- if (!r) throw notFound('提交项')
- return r
- }
- private submissionsById(ids: string[]): Map<string, SubmissionRow> {
- if (!ids.length) return new Map()
- return new Map(this.db.select().from(skillSubmissions).where(inArray(skillSubmissions.id, [...new Set(ids)])).all().map(s => [s.id, s]))
- }
- private existingOf(name: string) {
- const r = this.skills.byName(name)
- if (!r) return null
- const l = this.skills.latest(r.id)
- return { id: r.id, version: l.version, declaredVersion: l.declaredVersion, files: l.files }
- }
- private toItem(r: ItemRow, s: SubmissionRow): SkillSubmissionItemDto {
- const e = r.status === 'pending' ? this.existingOf(r.name) : null
- const files = Object.values(r.files)
- return {
- id: r.id, submissionId: r.submissionId, name: r.name, description: r.description, declaredVersion: r.declaredVersion, status: r.status, reason: r.reason,
- checks: r.checks, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), keyName: s.keyName, message: s.message, meta: s.meta,
- createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result,
- }
- }
- private toRegistryItem(r: ItemRow): RegistryItemDto {
- const e = r.status === 'pending' || r.status === 'unchanged' || !r.id ? this.existingOf(r.name) : null
- return {
- id: r.id, name: r.name, status: r.status, reason: r.reason, declaredVersion: r.declaredVersion,
- existing: e && { version: e.version, declaredVersion: e.declaredVersion },
- changes: r.status === 'pending' ? changesBetween(e?.files ?? {}, r.files) : null,
- checks: r.checks, reviewNote: r.reviewNote,
- result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, published: r.result.published },
- }
- }
- private toSubmission(s: SubmissionRow): RegistrySubmissionDto {
- const items = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, s.id)).all()
- return {
- id: s.id, status: items.some(i => i.status === 'pending') ? 'pending' : 'done', message: s.message, meta: s.meta, createdAt: s.createdAt, dryRun: false, skipped: [],
- items: items.map(i => this.toRegistryItem(i)),
- }
- }
- }
- const STATUS_TEXT: Record<SkillSubmissionStatus, string> = {
- pending: '待审核', approved: '已批准', rejected: '已拒绝', withdrawn: '已撤回', superseded: '已替代', invalid: '不能导入', unchanged: '无变化',
- }
- /** 改写 SKILL.md frontmatter 中的 name(没有 frontmatter 时补上) */
- export function renameSkillMd(md: string, name: string): string {
- const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(md)
- if (!m) return `---\nname: ${name}\n---\n${md}`
- const body = /^name:.*$/m.test(m[2]!) ? m[2]!.replace(/^name:.*$/m, `name: ${name}`) : `name: ${name}\n${m[2]}`
- return m[1] + body + m[3] + md.slice(m[0].length)
- }
|