@boostkit-dsh/skill-market 0.3.1
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/CHANGELOG.md +39 -0
- package/README.md +85 -0
- package/archive.js +108 -0
- package/catalog.js +139 -0
- package/client.js +464 -0
- package/cordis.patch.yml +4 -0
- package/index.js +323 -0
- package/installer.js +224 -0
- package/package.json +66 -0
package/index.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CATALOG_PACKAGE, DEFAULT_REGISTRIES, downloadSkillPackage, loadRemoteCatalog } from './catalog.js'
|
|
5
|
+
import { createSkillInstaller } from './installer.js'
|
|
6
|
+
|
|
7
|
+
export const name = 'boostkit-skill-market'
|
|
8
|
+
export const inject = ['authManagement']
|
|
9
|
+
|
|
10
|
+
const API_PREFIX = '/boostkit-skill-market'
|
|
11
|
+
const REQUEST_BYTES = 4096
|
|
12
|
+
const NETWORK_TIMEOUT_MS = 20_000
|
|
13
|
+
const BOOSTKIT_SKILLS_REPOSITORY = 'https://gitcode.com/boostkit/skills'
|
|
14
|
+
|
|
15
|
+
function sameOrigin(request) {
|
|
16
|
+
const origin = request.headers.origin
|
|
17
|
+
const host = request.headers.host
|
|
18
|
+
if (origin === undefined || host === undefined) return false
|
|
19
|
+
const remote = request.socket?.remoteAddress
|
|
20
|
+
const loopback = remote === '::1' || remote === '127.0.0.1' || (typeof remote === 'string' && remote.startsWith('127.')) || (typeof remote === 'string' && remote.startsWith('::ffff:127.'))
|
|
21
|
+
if (!loopback) return false
|
|
22
|
+
try { return new URL(origin).host === host } catch { return false }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sendJson(response, status, payload) {
|
|
26
|
+
if (response.destroyed || response.writableEnded) return
|
|
27
|
+
response.writeHead(status, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' })
|
|
28
|
+
response.end(JSON.stringify(payload))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sendProtectedError(response, error) {
|
|
32
|
+
if (error?.code === 'AUTH_REQUIRED') return sendJson(response, 401, { ok: false, error: { code: 'AUTH_REQUIRED', message: String(error.message) } })
|
|
33
|
+
if (error?.code === 'AUTH_UNAVAILABLE') return sendJson(response, 503, { ok: false, error: { code: 'AUTH_UNAVAILABLE', message: String(error.message) } })
|
|
34
|
+
if (error?.code === 'busy') return sendJson(response, 409, { ok: false, agentsBusy: true, runningAgents: error.agents ?? [], error: String(error.message) })
|
|
35
|
+
sendJson(response, 400, { ok: false, error: String(error?.message ?? error) })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJsonBody(request) {
|
|
39
|
+
const chunks = []
|
|
40
|
+
let size = 0
|
|
41
|
+
for await (const chunk of request) {
|
|
42
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
43
|
+
size += buffer.length
|
|
44
|
+
if (size > REQUEST_BYTES) throw new Error('request body too large')
|
|
45
|
+
chunks.push(buffer)
|
|
46
|
+
}
|
|
47
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
48
|
+
return text === '' ? {} : JSON.parse(text)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requestedName(body) {
|
|
52
|
+
return typeof body?.name === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(body.name) ? body.name : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function requestedWorkspaceId(value) {
|
|
56
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200 ? value : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Match the filesystem Skill provider's upward `.git` project-root discovery. */
|
|
60
|
+
function dshProjectRoot(cwd) {
|
|
61
|
+
const fallback = resolve(cwd)
|
|
62
|
+
let current = fallback
|
|
63
|
+
while (true) {
|
|
64
|
+
if (existsSync(join(current, '.git'))) return current
|
|
65
|
+
const parent = dirname(current)
|
|
66
|
+
if (parent === current) return fallback
|
|
67
|
+
current = parent
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve a client-selected, Host-registered workspace to the root DSH scans. */
|
|
72
|
+
function workspaceProjectRoot(ctx, config, workspaceId) {
|
|
73
|
+
let registry
|
|
74
|
+
try { registry = ctx.get?.('workspaceRegistry') } catch { registry = undefined }
|
|
75
|
+
if (workspaceId !== null) {
|
|
76
|
+
const workspace = registry?.get?.(workspaceId)
|
|
77
|
+
if (workspace === undefined || typeof workspace.path !== 'string') throw new Error('所选 DSH 工作区不存在,请重新选择。')
|
|
78
|
+
return dshProjectRoot(workspace.path)
|
|
79
|
+
}
|
|
80
|
+
if (config.projectRoot !== undefined) return dshProjectRoot(resolve(config.projectRoot))
|
|
81
|
+
const workspaces = registry?.list?.()
|
|
82
|
+
if (Array.isArray(workspaces) && workspaces.length === 1 && typeof workspaces[0]?.path === 'string') return dshProjectRoot(workspaces[0].path)
|
|
83
|
+
throw new Error('请选择 Skill 的安装工作区。')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runningAgentIds(ctx) {
|
|
87
|
+
let agents
|
|
88
|
+
try { agents = ctx.get?.('agents') } catch { return [] }
|
|
89
|
+
if (agents === undefined) return []
|
|
90
|
+
let listed
|
|
91
|
+
try { listed = agents.list() } catch { return [] }
|
|
92
|
+
if (!Array.isArray(listed)) return []
|
|
93
|
+
return [...new Set(listed.filter((agent) => agent?.status === 'running').map((agent) => typeof agent.id === 'string' && agent.id !== '' ? agent.id : 'agent'))]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mergeState(catalog, installed) {
|
|
97
|
+
const local = new Map(installed.map((entry) => [entry.name, entry]))
|
|
98
|
+
const result = catalog.skills.map((entry) => {
|
|
99
|
+
const current = local.get(entry.name)
|
|
100
|
+
local.delete(entry.name)
|
|
101
|
+
return {
|
|
102
|
+
...entry,
|
|
103
|
+
installed: current !== undefined,
|
|
104
|
+
present: current?.present ?? false,
|
|
105
|
+
enabled: current?.enabled ?? true,
|
|
106
|
+
currentVersion: current?.version ?? '',
|
|
107
|
+
updateAvailable: current !== undefined && (current.version !== entry.version || current.integrity !== entry.integrity),
|
|
108
|
+
managed: current !== undefined,
|
|
109
|
+
sourceUrl: `${entry.source.repository.replace(/\/$/, '')}/tree/${entry.commit}/${entry.source.path}`,
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
for (const current of local.values()) {
|
|
113
|
+
result.push({
|
|
114
|
+
...current,
|
|
115
|
+
displayName: { zh: current.name },
|
|
116
|
+
description: '此 Skill 已安装,但当前 npm catalog 中不存在。',
|
|
117
|
+
category: 'unlisted',
|
|
118
|
+
tags: [],
|
|
119
|
+
installed: true,
|
|
120
|
+
enabled: current.enabled ?? true,
|
|
121
|
+
currentVersion: current.version,
|
|
122
|
+
updateAvailable: false,
|
|
123
|
+
managed: true,
|
|
124
|
+
listed: false,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
return result.sort((left, right) => left.name.localeCompare(right.name))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create the host service used by the BoostKit Skill Market page. */
|
|
131
|
+
export function createSkillMarketService(options = {}) {
|
|
132
|
+
const defaultProjectRoot = resolve(options.projectRoot ?? process.cwd())
|
|
133
|
+
const installers = new Map()
|
|
134
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
135
|
+
const catalogPackage = options.catalogPackage ?? DEFAULT_CATALOG_PACKAGE
|
|
136
|
+
const registries = options.registries ?? DEFAULT_REGISTRIES
|
|
137
|
+
const timeoutMs = options.timeoutMs ?? NETWORK_TIMEOUT_MS
|
|
138
|
+
let cachedCatalog = null
|
|
139
|
+
let activeOperation = null
|
|
140
|
+
let disposed = false
|
|
141
|
+
const controllers = new Set()
|
|
142
|
+
|
|
143
|
+
function installerFor(projectRoot = defaultProjectRoot) {
|
|
144
|
+
const root = resolve(projectRoot)
|
|
145
|
+
let installer = installers.get(root)
|
|
146
|
+
if (installer === undefined) {
|
|
147
|
+
installer = createSkillInstaller({ projectRoot: root, now: options.now })
|
|
148
|
+
installers.set(root, installer)
|
|
149
|
+
}
|
|
150
|
+
return installer
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function network(task) {
|
|
154
|
+
if (disposed) throw new Error('BoostKit Skill 市场服务已释放')
|
|
155
|
+
const controller = new AbortController()
|
|
156
|
+
const timer = setTimeout(() => controller.abort(new Error('npm 请求超时')), timeoutMs)
|
|
157
|
+
controllers.add(controller)
|
|
158
|
+
try { return await task(controller.signal) } finally { clearTimeout(timer); controllers.delete(controller) }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function catalog(refresh = false) {
|
|
162
|
+
if (!refresh && cachedCatalog !== null) return cachedCatalog
|
|
163
|
+
const loaded = await network((signal) => loadRemoteCatalog({ fetchImpl, packageName: catalogPackage, registries, signal }))
|
|
164
|
+
cachedCatalog = loaded
|
|
165
|
+
return loaded
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function state(refresh = false, projectRoot = defaultProjectRoot) {
|
|
169
|
+
const installer = installerFor(projectRoot)
|
|
170
|
+
const installed = installer.list()
|
|
171
|
+
try {
|
|
172
|
+
const remote = await catalog(refresh)
|
|
173
|
+
return {
|
|
174
|
+
connected: true,
|
|
175
|
+
release: remote.release,
|
|
176
|
+
catalogPackage,
|
|
177
|
+
repositoryUrl: BOOSTKIT_SKILLS_REPOSITORY,
|
|
178
|
+
skills: mergeState(remote, installed),
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
connected: false,
|
|
183
|
+
error: String(error?.message ?? error),
|
|
184
|
+
release: '',
|
|
185
|
+
catalogPackage,
|
|
186
|
+
repositoryUrl: BOOSTKIT_SKILLS_REPOSITORY,
|
|
187
|
+
skills: mergeState({ skills: [] }, installed),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function mutate(action, skillName, projectRoot = defaultProjectRoot) {
|
|
193
|
+
if (disposed) throw new Error('BoostKit Skill 市场服务已释放')
|
|
194
|
+
if (activeOperation !== null) throw Object.assign(new Error(`正在处理 ${activeOperation}`), { code: 'busy' })
|
|
195
|
+
activeOperation = `${action}:${skillName}`
|
|
196
|
+
try {
|
|
197
|
+
const installer = installerFor(projectRoot)
|
|
198
|
+
if (action === 'remove') return { action, skill: installer.remove(skillName) }
|
|
199
|
+
if (action === 'enable' || action === 'disable') return { action, skill: installer.setEnabled(skillName, action === 'enable') }
|
|
200
|
+
if (action !== 'install' && action !== 'update') throw new Error(`Skill 操作非法:${action}`)
|
|
201
|
+
const remote = await catalog(true)
|
|
202
|
+
const entry = remote.skills.find((skill) => skill.name === skillName)
|
|
203
|
+
if (!entry) throw new Error(`npm catalog 中不存在 ${skillName}`)
|
|
204
|
+
const tarball = await network((signal) => downloadSkillPackage(entry, { fetchImpl, registries, signal }))
|
|
205
|
+
const skill = installer.install(entry, tarball, action)
|
|
206
|
+
return { action, skill }
|
|
207
|
+
} finally {
|
|
208
|
+
activeOperation = null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
projectRoot: defaultProjectRoot,
|
|
214
|
+
state,
|
|
215
|
+
mutate,
|
|
216
|
+
detail(name, projectRoot = defaultProjectRoot) { return installerFor(projectRoot).readSkill(name) },
|
|
217
|
+
refresh() { cachedCatalog = null },
|
|
218
|
+
dispose() {
|
|
219
|
+
disposed = true
|
|
220
|
+
cachedCatalog = null
|
|
221
|
+
for (const controller of controllers) controller.abort()
|
|
222
|
+
controllers.clear()
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleMutation(ctx, auth, service, config, action, request, response) {
|
|
228
|
+
if (request.method !== 'POST') {
|
|
229
|
+
response.writeHead(405, { allow: 'POST' })
|
|
230
|
+
response.end()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, error: 'same-origin required' })
|
|
234
|
+
await auth.requireActor()
|
|
235
|
+
const body = await readJsonBody(request)
|
|
236
|
+
const skillName = requestedName(body)
|
|
237
|
+
if (skillName === null) return sendJson(response, 400, { ok: false, error: 'invalid Skill name' })
|
|
238
|
+
try {
|
|
239
|
+
const result = await auth.executeAudited({
|
|
240
|
+
action: `skill.${action}`,
|
|
241
|
+
target: { type: 'skill', id: skillName },
|
|
242
|
+
workspaceId: requestedWorkspaceId(body.workspaceId) ?? undefined,
|
|
243
|
+
}, () => {
|
|
244
|
+
const agents = runningAgentIds(ctx)
|
|
245
|
+
if (agents.length > 0) throw Object.assign(new Error(`有 Agent 正在运行(${agents.join(', ')}),请等待任务停止后重试。`), { code: 'busy', agents })
|
|
246
|
+
const projectRoot = workspaceProjectRoot(ctx, config, requestedWorkspaceId(body.workspaceId))
|
|
247
|
+
return service.mutate(action, skillName, projectRoot)
|
|
248
|
+
})
|
|
249
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
250
|
+
} catch (error) {
|
|
251
|
+
sendProtectedError(response, error)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Register the Skill market service, HTTP endpoints and lifecycle cleanup. */
|
|
256
|
+
export function apply(ctx, config = {}) {
|
|
257
|
+
const auth = ctx.get?.('authManagement') ?? ctx.authManagement
|
|
258
|
+
if (auth === undefined) throw new Error('boostkit-skill-market 需要 authManagement 服务')
|
|
259
|
+
const service = createSkillMarketService({
|
|
260
|
+
projectRoot: config.projectRoot === undefined ? process.cwd() : resolve(config.projectRoot),
|
|
261
|
+
catalogPackage: config.catalogPackage,
|
|
262
|
+
registries: config.registries,
|
|
263
|
+
fetchImpl: config.fetchImpl,
|
|
264
|
+
})
|
|
265
|
+
ctx.provide('boostkitSkillMarket', service)
|
|
266
|
+
ctx.effect?.(() => () => service.dispose(), 'boostkit-skill-market.dispose')
|
|
267
|
+
ctx.inject(['webServer'], (host) => {
|
|
268
|
+
host.effect(() => {
|
|
269
|
+
const routes = [
|
|
270
|
+
{
|
|
271
|
+
kind: 'exact',
|
|
272
|
+
path: `${API_PREFIX}/catalog`,
|
|
273
|
+
handler: (request, response) => {
|
|
274
|
+
if (request.method !== 'GET') { response.writeHead(405, { allow: 'GET' }); response.end(); return }
|
|
275
|
+
void auth.requireActor().then(() => {
|
|
276
|
+
let url
|
|
277
|
+
try { url = new URL(request.url ?? '', 'http://localhost') } catch { sendJson(response, 400, { connected: false, error: 'invalid request URL', skills: [] }); return null }
|
|
278
|
+
let projectRoot
|
|
279
|
+
try { projectRoot = workspaceProjectRoot(ctx, config, requestedWorkspaceId(url.searchParams.get('workspaceId'))) } catch (error) { sendJson(response, 400, { connected: false, error: String(error?.message ?? error), skills: [] }); return null }
|
|
280
|
+
return service.state(url.searchParams.has('refresh'), projectRoot).then((value) => sendJson(response, 200, value))
|
|
281
|
+
}).catch((error) => sendProtectedError(response, error))
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
kind: 'exact',
|
|
286
|
+
path: `${API_PREFIX}/detail`,
|
|
287
|
+
handler: (request, response) => {
|
|
288
|
+
if (request.method !== 'GET') { response.writeHead(405, { allow: 'GET' }); response.end(); return }
|
|
289
|
+
void auth.requireActor().then(() => {
|
|
290
|
+
let skillName = null
|
|
291
|
+
let workspaceId = null
|
|
292
|
+
try {
|
|
293
|
+
const url = new URL(request.url ?? '', 'http://localhost')
|
|
294
|
+
skillName = requestedName({ name: url.searchParams.get('name') })
|
|
295
|
+
workspaceId = requestedWorkspaceId(url.searchParams.get('workspaceId'))
|
|
296
|
+
} catch { /* invalid request URL */ }
|
|
297
|
+
if (skillName === null) { sendJson(response, 400, { ok: false, error: 'invalid Skill name' }); return }
|
|
298
|
+
const projectRoot = workspaceProjectRoot(ctx, config, workspaceId)
|
|
299
|
+
sendJson(response, 200, { ok: true, name: skillName, ...service.detail(skillName, projectRoot) })
|
|
300
|
+
}).catch((error) => sendProtectedError(response, error))
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
...['install', 'update', 'remove', 'enable', 'disable'].map((action) => ({
|
|
304
|
+
kind: 'exact',
|
|
305
|
+
path: `${API_PREFIX}/${action}`,
|
|
306
|
+
handler: (request, response) => { void handleMutation(ctx, auth, service, config, action, request, response).catch((error) => sendProtectedError(response, error)) },
|
|
307
|
+
})),
|
|
308
|
+
]
|
|
309
|
+
const disposers = routes.map((route) => host.webServer.register(route))
|
|
310
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
311
|
+
}, 'boostkit-skill-market.http')
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const __testing = {
|
|
316
|
+
mergeState,
|
|
317
|
+
dshProjectRoot,
|
|
318
|
+
requestedName,
|
|
319
|
+
requestedWorkspaceId,
|
|
320
|
+
runningAgentIds,
|
|
321
|
+
sameOrigin,
|
|
322
|
+
workspaceProjectRoot,
|
|
323
|
+
}
|
package/installer.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { extractPackage } from './archive.js'
|
|
6
|
+
|
|
7
|
+
const LOCK_VERSION = 1
|
|
8
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
9
|
+
const MAX_SKILL_FILE_BYTES = 1024 * 1024
|
|
10
|
+
|
|
11
|
+
function displayPath(projectRoot, target) {
|
|
12
|
+
return relative(projectRoot, target).split(sep).join('/')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseJson(file) {
|
|
16
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emptyLock() {
|
|
20
|
+
return { version: LOCK_VERSION, skills: {} }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateLock(value) {
|
|
24
|
+
if (value?.version !== LOCK_VERSION || value.skills === null || typeof value.skills !== 'object' || Array.isArray(value.skills)) throw new Error('BoostKit Skill lock 文件格式非法')
|
|
25
|
+
for (const [name, entry] of Object.entries(value.skills)) {
|
|
26
|
+
if (!SKILL_NAME_RE.test(name) || entry?.name !== name || typeof entry.package !== 'string' || typeof entry.version !== 'string' || typeof entry.commit !== 'string' || typeof entry.integrity !== 'string' || typeof entry.path !== 'string' || (entry.enabled !== undefined && typeof entry.enabled !== 'boolean')) throw new Error(`BoostKit Skill lock 条目非法:${name}`)
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function frontmatterName(text) {
|
|
32
|
+
const normalized = text.replace(/\r\n/g, '\n')
|
|
33
|
+
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized)
|
|
34
|
+
if (!match) throw new Error('SKILL.md 缺少 YAML frontmatter')
|
|
35
|
+
const name = /^name:\s*['"]?([^'"\n]+?)['"]?\s*$/m.exec(match[1])?.[1]?.trim()
|
|
36
|
+
if (!name) throw new Error('SKILL.md frontmatter 缺少 name')
|
|
37
|
+
return name
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeLockAtomic(file, value) {
|
|
41
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
|
|
42
|
+
const backup = `${file}.${process.pid}.${randomUUID()}.bak`
|
|
43
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
44
|
+
let backedUp = false
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(file)) {
|
|
47
|
+
renameSync(file, backup)
|
|
48
|
+
backedUp = true
|
|
49
|
+
}
|
|
50
|
+
renameSync(temporary, file)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
rmSync(temporary, { force: true })
|
|
53
|
+
if (backedUp && !existsSync(file)) renameSync(backup, file)
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
if (backedUp) {
|
|
57
|
+
try { rmSync(backup, { force: true }) } catch { /* committed lock remains authoritative */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Create a project-scoped, no-script BoostKit Skill installer. */
|
|
62
|
+
export function createSkillInstaller(options = {}) {
|
|
63
|
+
const projectRoot = resolve(options.projectRoot ?? process.cwd())
|
|
64
|
+
const dshRoot = join(projectRoot, '.dsh')
|
|
65
|
+
const skillsRoot = join(dshRoot, 'skills')
|
|
66
|
+
const disabledRoot = join(skillsRoot, '.disabled')
|
|
67
|
+
const lockFile = join(skillsRoot, '.boostkit-lock.json')
|
|
68
|
+
const now = options.now ?? (() => new Date())
|
|
69
|
+
|
|
70
|
+
function skillPath(name, enabled) {
|
|
71
|
+
return enabled ? join(skillsRoot, name) : join(disabledRoot, name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readLock() {
|
|
75
|
+
if (!existsSync(lockFile)) return emptyLock()
|
|
76
|
+
return validateLock(parseJson(lockFile))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function list() {
|
|
80
|
+
const lock = readLock()
|
|
81
|
+
return Object.values(lock.skills).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
82
|
+
const enabled = entry.enabled !== false
|
|
83
|
+
return {
|
|
84
|
+
...entry,
|
|
85
|
+
enabled,
|
|
86
|
+
present: existsSync(skillPath(entry.name, enabled)),
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function install(entry, tarball, action = 'install') {
|
|
92
|
+
if (action !== 'install' && action !== 'update') throw new Error(`Skill 操作非法:${action}`)
|
|
93
|
+
if (!SKILL_NAME_RE.test(entry?.name ?? '')) throw new Error('Skill 名称非法')
|
|
94
|
+
mkdirSync(skillsRoot, { recursive: true })
|
|
95
|
+
const lock = readLock()
|
|
96
|
+
const current = lock.skills[entry.name]
|
|
97
|
+
const enabled = current?.enabled !== false
|
|
98
|
+
const target = skillPath(entry.name, enabled)
|
|
99
|
+
if (action === 'install' && current !== undefined) throw new Error(`${entry.name} 已安装`)
|
|
100
|
+
if (action === 'update' && current === undefined) throw new Error(`${entry.name} 尚未安装`)
|
|
101
|
+
if (action === 'update' && current.package !== entry.package) throw new Error(`${entry.name} 的 npm 包身份发生变化,拒绝自动更新`)
|
|
102
|
+
if (current === undefined && existsSync(target)) throw new Error(`${entry.name} 是未由 BoostKit Skill 市场管理的本地 Skill,禁止覆盖`)
|
|
103
|
+
if (action === 'update' && current.version === entry.version && current.integrity === entry.integrity && existsSync(target)) throw new Error(`${entry.name} 已是 catalog 版本`)
|
|
104
|
+
|
|
105
|
+
const staging = mkdtempSync(join(dshRoot, `.boostkit-stage-${entry.name}-`))
|
|
106
|
+
const backup = join(dshRoot, `.boostkit-backup-${entry.name}-${randomUUID()}`)
|
|
107
|
+
let targetBackedUp = false
|
|
108
|
+
let targetInstalled = false
|
|
109
|
+
try {
|
|
110
|
+
const files = extractPackage(tarball, staging)
|
|
111
|
+
const skillFile = join(staging, 'SKILL.md')
|
|
112
|
+
if (!existsSync(skillFile)) throw new Error(`${entry.package} npm 包根目录缺少 SKILL.md`)
|
|
113
|
+
if (frontmatterName(readFileSync(skillFile, 'utf8')) !== entry.name) throw new Error(`${entry.package} 的 SKILL.md name 与 catalog 不一致`)
|
|
114
|
+
if (existsSync(target)) {
|
|
115
|
+
renameSync(target, backup)
|
|
116
|
+
targetBackedUp = true
|
|
117
|
+
}
|
|
118
|
+
renameSync(staging, target)
|
|
119
|
+
targetInstalled = true
|
|
120
|
+
const installed = {
|
|
121
|
+
name: entry.name,
|
|
122
|
+
package: entry.package,
|
|
123
|
+
version: entry.version,
|
|
124
|
+
commit: entry.commit,
|
|
125
|
+
integrity: entry.integrity,
|
|
126
|
+
path: displayPath(projectRoot, target),
|
|
127
|
+
enabled,
|
|
128
|
+
installedAt: now().toISOString(),
|
|
129
|
+
source: entry.source,
|
|
130
|
+
files: files.sort(),
|
|
131
|
+
}
|
|
132
|
+
writeLockAtomic(lockFile, { version: LOCK_VERSION, skills: { ...lock.skills, [entry.name]: installed } })
|
|
133
|
+
if (targetBackedUp) {
|
|
134
|
+
try { rmSync(backup, { recursive: true, force: true }) } catch { /* committed update remains valid */ }
|
|
135
|
+
}
|
|
136
|
+
return installed
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (targetInstalled) rmSync(target, { recursive: true, force: true })
|
|
139
|
+
if (targetBackedUp && existsSync(backup)) renameSync(backup, target)
|
|
140
|
+
throw error
|
|
141
|
+
} finally {
|
|
142
|
+
try { rmSync(staging, { recursive: true, force: true }) } catch { /* best-effort staging cleanup */ }
|
|
143
|
+
if (!targetBackedUp) {
|
|
144
|
+
try { rmSync(backup, { recursive: true, force: true }) } catch { /* path normally does not exist */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function remove(name) {
|
|
150
|
+
if (!SKILL_NAME_RE.test(name ?? '')) throw new Error('Skill 名称非法')
|
|
151
|
+
const lock = readLock()
|
|
152
|
+
const current = lock.skills[name]
|
|
153
|
+
if (current === undefined) throw new Error(`${name} 未由 BoostKit Skill 市场管理`)
|
|
154
|
+
const target = skillPath(name, current.enabled !== false)
|
|
155
|
+
const backup = join(dshRoot, `.boostkit-remove-${name}-${randomUUID()}`)
|
|
156
|
+
let moved = false
|
|
157
|
+
try {
|
|
158
|
+
if (existsSync(target)) {
|
|
159
|
+
renameSync(target, backup)
|
|
160
|
+
moved = true
|
|
161
|
+
}
|
|
162
|
+
const skills = { ...lock.skills }
|
|
163
|
+
delete skills[name]
|
|
164
|
+
writeLockAtomic(lockFile, { version: LOCK_VERSION, skills })
|
|
165
|
+
if (moved) {
|
|
166
|
+
try { rmSync(backup, { recursive: true, force: true }) } catch { /* committed removal remains valid */ }
|
|
167
|
+
}
|
|
168
|
+
return current
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (moved && existsSync(backup) && !existsSync(target)) renameSync(backup, target)
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setEnabled(name, enabled) {
|
|
176
|
+
if (!SKILL_NAME_RE.test(name ?? '')) throw new Error('Skill 名称非法')
|
|
177
|
+
if (typeof enabled !== 'boolean') throw new Error('Skill 启用状态非法')
|
|
178
|
+
const lock = readLock()
|
|
179
|
+
const current = lock.skills[name]
|
|
180
|
+
if (current === undefined) throw new Error(`${name} 未由 BoostKit Skill 市场管理`)
|
|
181
|
+
const wasEnabled = current.enabled !== false
|
|
182
|
+
if (wasEnabled === enabled) return { ...current, enabled: wasEnabled }
|
|
183
|
+
const source = skillPath(name, wasEnabled)
|
|
184
|
+
const target = skillPath(name, enabled)
|
|
185
|
+
if (!existsSync(source)) throw new Error(`${name} 的 Skill 文件缺失`)
|
|
186
|
+
if (existsSync(target)) throw new Error(`${name} 的目标目录已存在,无法切换状态`)
|
|
187
|
+
mkdirSync(enabled ? skillsRoot : disabledRoot, { recursive: true })
|
|
188
|
+
renameSync(source, target)
|
|
189
|
+
try {
|
|
190
|
+
const updated = { ...current, enabled, path: displayPath(projectRoot, target) }
|
|
191
|
+
writeLockAtomic(lockFile, { version: LOCK_VERSION, skills: { ...lock.skills, [name]: updated } })
|
|
192
|
+
return updated
|
|
193
|
+
} catch (error) {
|
|
194
|
+
renameSync(target, source)
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readSkill(name) {
|
|
200
|
+
if (!SKILL_NAME_RE.test(name ?? '')) throw new Error('Skill 名称非法')
|
|
201
|
+
const lock = readLock()
|
|
202
|
+
const current = lock.skills[name]
|
|
203
|
+
if (current === undefined) throw new Error(`${name} 未由 BoostKit Skill 市场管理`)
|
|
204
|
+
const enabled = current.enabled !== false
|
|
205
|
+
const file = join(skillPath(name, enabled), 'SKILL.md')
|
|
206
|
+
if (!existsSync(file)) throw new Error(`${name} 的 SKILL.md 文件缺失`)
|
|
207
|
+
if (statSync(file).size > MAX_SKILL_FILE_BYTES) throw new Error(`${name} 的 SKILL.md 超过 1 MiB 展示上限`)
|
|
208
|
+
return { content: readFileSync(file, 'utf8'), enabled }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
projectRoot,
|
|
213
|
+
skillsRoot,
|
|
214
|
+
lockFile,
|
|
215
|
+
install,
|
|
216
|
+
list,
|
|
217
|
+
readSkill,
|
|
218
|
+
readLock,
|
|
219
|
+
remove,
|
|
220
|
+
setEnabled,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const skillLockVersion = LOCK_VERSION
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boostkit-dsh/skill-market",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "在 DSH 中浏览、安装、启停、更新和卸载 BoostKit 项目级 Skill",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"CHANGELOG.md",
|
|
9
|
+
"README.md",
|
|
10
|
+
"archive.js",
|
|
11
|
+
"catalog.js",
|
|
12
|
+
"client.js",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"index.js",
|
|
15
|
+
"installer.js"
|
|
16
|
+
],
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://gitcode.com/lujinxing/kunpeng-dsh-demo.git",
|
|
21
|
+
"directory": "plugins/skill-market"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.js",
|
|
29
|
+
"./client": "./client.js",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test test/*.test.mjs"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@boostkit-dsh/auth-management": "0.1.1"
|
|
37
|
+
},
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"platform": "web",
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-api-workspace-controller",
|
|
46
|
+
"@deepseek-ai/dsh-client-locale",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"market": {
|
|
52
|
+
"displayName": {
|
|
53
|
+
"zh": "BoostKit Skill 市场",
|
|
54
|
+
"en": "BoostKit Skill Market"
|
|
55
|
+
},
|
|
56
|
+
"category": "kunpeng-rd",
|
|
57
|
+
"maintainers": [
|
|
58
|
+
"@lujinxing"
|
|
59
|
+
],
|
|
60
|
+
"compatibility": {
|
|
61
|
+
"dsh": "0.1.7-alpha.1"
|
|
62
|
+
},
|
|
63
|
+
"allowedBuilds": []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|