@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.1 - 2026-09-22
|
|
4
|
+
|
|
5
|
+
- 首次发布前将包名缩短为 `@boostkit-dsh/skill-market`,避免重复 scope 中的 `boostkit`。
|
|
6
|
+
- 将 DSH 精确兼容基线升级为 `0.1.7-alpha.1`,并同步鉴权依赖到 `@boostkit-dsh/auth-management@0.1.1`。
|
|
7
|
+
|
|
8
|
+
## 0.3.0 - 2026-09-22
|
|
9
|
+
|
|
10
|
+
- 对鉴权与审计管理增加精确硬依赖,禁止匿名浏览、读取和修改 Skill。
|
|
11
|
+
- 安装、更新、启停和卸载写入最小化本地审计事件。
|
|
12
|
+
|
|
13
|
+
## 0.2.2 - 2026-09-20
|
|
14
|
+
|
|
15
|
+
- 安装目标改为市场页面中明确选择的 DSH 工作区,不再取启动 `dsh web` 时的当前目录。
|
|
16
|
+
- Host 只接受已登记的工作区 ID,并按 DSH filesystem Skill Provider 相同的向上 `.git` 规则确定项目根目录。
|
|
17
|
+
- 工作区选择器只展示工作区名称;catalog 接口不再返回项目和 Skill 的绝对路径。
|
|
18
|
+
|
|
19
|
+
## 0.2.1 - 2026-09-20
|
|
20
|
+
|
|
21
|
+
- Skill 详情使用 DSH 原生 Markdown 组件渲染完整 `SKILL.md`,不再展示原始 Markdown 文本。
|
|
22
|
+
- 启用/禁用控件直接复用 DSH 原生 `Switch`,与 DSH 其他设置控件保持一致。
|
|
23
|
+
- 启停和卸载成功后在前端就地更新状态,移除多余的远程 npm catalog 强制刷新。
|
|
24
|
+
|
|
25
|
+
## 0.2.0 - 2026-09-20
|
|
26
|
+
|
|
27
|
+
- 将市场从设置页迁移为 DSH 首页左侧导航中的独立主页面。
|
|
28
|
+
- 列表仅展示简短说明和必要状态,长说明最多显示两行。
|
|
29
|
+
- 已安装 Skill 可打开详情弹窗并滚动查看完整 `SKILL.md`。
|
|
30
|
+
- 详情弹窗支持启用、禁用和卸载;禁用保留文件与版本锁记录。
|
|
31
|
+
- 移除页面中的项目绝对路径和 Skill 安装目录展示。
|
|
32
|
+
|
|
33
|
+
## 0.1.0 - 2026-09-20
|
|
34
|
+
|
|
35
|
+
- 建立 BoostKit Skill npm catalog 协议及完整性校验。
|
|
36
|
+
- 支持项目级 Skill 安装、更新、卸载和精确版本锁定。
|
|
37
|
+
- 提供 DSH 设置区的 Skill 浏览、搜索、详情和操作页面。
|
|
38
|
+
- 增加分类筛选、结果统计、分享信息和 GitCode 贡献入口。
|
|
39
|
+
- 展示实际项目与 Skill 安装目录,便于现场验证安装结果。
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# BoostKit Skill 市场
|
|
2
|
+
|
|
3
|
+
`@boostkit-dsh/skill-market` 是一个可独立安装的 DSH 插件,用于浏览、安装、启停、更新和卸载 BoostKit 项目级 Skill。
|
|
4
|
+
|
|
5
|
+
`0.3.0` 起要求 GitCode 鉴权;当前版本精确依赖 `@boostkit-dsh/auth-management@0.1.1`。目录、详情和全部变更都要求有效 GitCode ActorRef,不提供匿名浏览;安装、更新、启停和卸载会记录不含 Skill 正文的最小化审计事件。
|
|
6
|
+
|
|
7
|
+
## 市场能力
|
|
8
|
+
|
|
9
|
+
- 浏览 BoostKit Skill 目录,按名称、说明、标签搜索并按类别筛选;
|
|
10
|
+
- 在 DSH 首页左侧导航中提供独立市场页面,位置在插件入口之后、工作区之前;
|
|
11
|
+
- 在页面顶部明确选择安装目标工作区;默认选择 DSH 工作区列表中的第一项,只展示工作区名称;
|
|
12
|
+
- 列表采用渐进披露,只展示简短说明、分类、少量标签、版本和安装状态;
|
|
13
|
+
- 点击已安装 Skill 可在弹窗中滚动查看 DSH 原生渲染后的完整 `SKILL.md`,并通过 DSH 原生开关启用或禁用、或在左下角卸载;
|
|
14
|
+
- 展示 Skill 的源码位置、精确 npm 包版本和当前项目安装状态;
|
|
15
|
+
- 一键复制可分享的 Skill 信息,或进入 GitCode 提交贡献;
|
|
16
|
+
- 安装、更新和卸载项目级 Skill,并在 `.boostkit-lock.json` 中锁定版本;
|
|
17
|
+
|
|
18
|
+
首次联调可直接按[手工测试说明](../../docs/boostkit-skill-market-manual-test.md)准备两个最小测试包并完成页面验收。
|
|
19
|
+
|
|
20
|
+
## 边界
|
|
21
|
+
|
|
22
|
+
- 运行时目录来自 `@boostkit-dsh/skills-catalog`,通过 npm registry、镜像或 CDN 分发;
|
|
23
|
+
- GitCode 只保存 Skill 源码和 PR,不作为日常下载源;
|
|
24
|
+
- Skill 安装到项目根目录的 `.dsh/skills/<name>/`,由 DSH 原生 filesystem Skill Provider 发现;
|
|
25
|
+
- 禁用时 Skill 可逆移动到 `.dsh/skills/.disabled/<name>/`,保留内容与 lock 记录,但不再被 DSH 发现;
|
|
26
|
+
- 启停只执行同文件系统目录移动与 lock 原子写入,成功后前端就地更新,不刷新远程 npm catalog;
|
|
27
|
+
- 安装器只下载、校验和解包文件,不运行 `preinstall`、`install`、`postinstall` 或 Skill 脚本;
|
|
28
|
+
- 编辑、试运行和贡献 PR 属于 Skill Studio,不在本插件中实现。
|
|
29
|
+
|
|
30
|
+
## 安装作用域
|
|
31
|
+
|
|
32
|
+
页面中的“安装到工作区”是安装目标的唯一真源。浏览器只提交 DSH 工作区 ID,Host 从 `workspaceRegistry` 读取已登记路径,再使用 DSH filesystem Skill Provider 相同的规则向上查找最近的 `.git`:找到时以该 Git 根目录为项目根,未找到时以工作区路径本身为项目根。Skill 最终写入该项目根的 `.dsh/skills/`。
|
|
33
|
+
|
|
34
|
+
因此,启动 `dsh web` 的当前目录不会影响页面发起的安装、更新、启停、详情读取或卸载。多个工作区之间的本地安装状态相互独立,切换选择器时页面会重新读取对应项目。
|
|
35
|
+
|
|
36
|
+
可选配置:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
- id: boostkit-skill-market
|
|
40
|
+
name: '@boostkit-dsh/skill-market'
|
|
41
|
+
config:
|
|
42
|
+
projectRoot: /path/to/project
|
|
43
|
+
catalogPackage: '@boostkit-dsh/skills-catalog'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`projectRoot` 仅作为没有传入工作区 ID 的兼容回退(例如直接调用 Host 服务)使用,不会覆盖页面中选择的已登记工作区,也不会在市场页面展示。插件只修改最终项目根中的 `.dsh/skills/` 和 `.dsh/skills/.boostkit-lock.json`。
|
|
47
|
+
|
|
48
|
+
## Catalog 协议
|
|
49
|
+
|
|
50
|
+
目录包中的 `catalog.json` 使用协议版本 `1`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"protocol": 1,
|
|
55
|
+
"release": "0.1.0",
|
|
56
|
+
"skills": [
|
|
57
|
+
{
|
|
58
|
+
"name": "example-skill",
|
|
59
|
+
"package": "@boostkit-dsh/skill-example-skill",
|
|
60
|
+
"version": "0.0.0-0123456789ab",
|
|
61
|
+
"commit": "0123456789abcdef0123456789abcdef01234567",
|
|
62
|
+
"integrity": "sha512-...",
|
|
63
|
+
"displayName": { "zh": "示例 Skill", "en": "Example Skill" },
|
|
64
|
+
"description": "示例说明",
|
|
65
|
+
"category": "development",
|
|
66
|
+
"tags": ["example"],
|
|
67
|
+
"source": {
|
|
68
|
+
"repository": "https://gitcode.com/boostkit/skills",
|
|
69
|
+
"path": "skills/example-skill"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
每个 Skill npm 包在包根目录提供 `SKILL.md` 和相关资源。catalog 中的 integrity 必须与 npm exact-version manifest 一致;安装器还会重新计算 tarball 的 SHA-512。
|
|
77
|
+
|
|
78
|
+
## 安装安全
|
|
79
|
+
|
|
80
|
+
- 只接受 `@boostkit-dsh/skill-*` 精确版本包;
|
|
81
|
+
- 拒绝路径穿越、绝对路径、符号链接和硬链接;
|
|
82
|
+
- 限制 tarball 大小、解压后大小和文件数量;
|
|
83
|
+
- 使用同文件系统临时目录和 rename 替换 Skill;
|
|
84
|
+
- 不覆盖没有 lock 记录的同名本地 Skill;
|
|
85
|
+
- 卸载只移除由本市场 lock 文件登记的 Skill。
|
package/archive.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, posix } from 'node:path'
|
|
4
|
+
import { gunzipSync } from 'node:zlib'
|
|
5
|
+
|
|
6
|
+
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
|
7
|
+
const MAX_UNPACKED_BYTES = 50 * 1024 * 1024
|
|
8
|
+
const MAX_FILES = 500
|
|
9
|
+
|
|
10
|
+
function headerString(header, start, length) {
|
|
11
|
+
return header.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function octalField(header, start, length, field) {
|
|
15
|
+
const value = headerString(header, start, length).trim()
|
|
16
|
+
if (!/^[0-7]*$/.test(value)) throw new Error(`npm tarball ${field} 非法`)
|
|
17
|
+
const parsed = Number.parseInt(value || '0', 8)
|
|
18
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`npm tarball ${field} 非法`)
|
|
19
|
+
return parsed
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safePackagePath(path) {
|
|
23
|
+
if (!path.startsWith('package/') || path.includes('\\') || path.includes('\0')) throw new Error(`npm tarball 路径非法:${path}`)
|
|
24
|
+
const relative = path.slice('package/'.length).replace(/\/+$/, '')
|
|
25
|
+
if (relative === '' || relative.startsWith('/') || relative.split('/').includes('..')) throw new Error(`npm tarball 路径非法:${path}`)
|
|
26
|
+
const normalized = posix.normalize(relative)
|
|
27
|
+
if (normalized === '.' || normalized.startsWith('../') || normalized !== relative) throw new Error(`npm tarball 路径非法:${path}`)
|
|
28
|
+
return normalized
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Verify a buffer against an npm SHA-512 Subresource Integrity value. */
|
|
32
|
+
export function verifyIntegrity(buffer, integrity) {
|
|
33
|
+
if (typeof integrity !== 'string' || !integrity.startsWith('sha512-')) throw new Error('npm integrity 必须使用 sha512')
|
|
34
|
+
const expected = Buffer.from(integrity.slice('sha512-'.length), 'base64')
|
|
35
|
+
const actual = createHash('sha512').update(buffer).digest()
|
|
36
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new Error('npm tarball integrity 校验失败')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse safe regular-file and directory entries from an npm .tgz archive. */
|
|
40
|
+
export function readPackageEntries(tarball) {
|
|
41
|
+
if (!Buffer.isBuffer(tarball)) throw new TypeError('tarball 必须是 Buffer')
|
|
42
|
+
if (tarball.length > MAX_TARBALL_BYTES) throw new Error('npm tarball 超过 20 MiB')
|
|
43
|
+
const archive = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES })
|
|
44
|
+
const entries = []
|
|
45
|
+
const paths = new Set()
|
|
46
|
+
let totalBytes = 0
|
|
47
|
+
for (let offset = 0; offset + 512 <= archive.length;) {
|
|
48
|
+
const header = archive.subarray(offset, offset + 512)
|
|
49
|
+
if (header.every((byte) => byte === 0)) break
|
|
50
|
+
const name = headerString(header, 0, 100)
|
|
51
|
+
const prefix = headerString(header, 345, 155)
|
|
52
|
+
const path = prefix ? `${prefix}/${name}` : name
|
|
53
|
+
const size = octalField(header, 124, 12, '文件大小')
|
|
54
|
+
const mode = octalField(header, 100, 8, '文件模式')
|
|
55
|
+
const type = String.fromCharCode(header[156] || 48)
|
|
56
|
+
const contentStart = offset + 512
|
|
57
|
+
const contentEnd = contentStart + size
|
|
58
|
+
if (contentEnd > archive.length) throw new Error('npm tarball 文件越界')
|
|
59
|
+
offset = contentStart + Math.ceil(size / 512) * 512
|
|
60
|
+
|
|
61
|
+
// npm may add POSIX metadata headers. Skill packages use short paths, so
|
|
62
|
+
// the metadata itself is ignored and never allowed to redirect a path.
|
|
63
|
+
if (type === 'x' || type === 'g') continue
|
|
64
|
+
if (type !== '0' && type !== '\0' && type !== '5') throw new Error(`npm tarball 禁止文件类型 ${JSON.stringify(type)}:${path}`)
|
|
65
|
+
if (path === 'package' || path === 'package/') continue
|
|
66
|
+
const relative = safePackagePath(path)
|
|
67
|
+
if (paths.has(relative)) throw new Error(`npm tarball 路径重复:${relative}`)
|
|
68
|
+
paths.add(relative)
|
|
69
|
+
if (type === '5') {
|
|
70
|
+
entries.push({ type: 'directory', path: relative })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
totalBytes += size
|
|
74
|
+
if (totalBytes > MAX_UNPACKED_BYTES) throw new Error('npm tarball 解压内容超过 50 MiB')
|
|
75
|
+
if (entries.length >= MAX_FILES) throw new Error(`npm tarball 文件数超过 ${MAX_FILES}`)
|
|
76
|
+
entries.push({ type: 'file', path: relative, mode, content: Buffer.from(archive.subarray(contentStart, contentEnd)) })
|
|
77
|
+
}
|
|
78
|
+
return entries
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read one regular file from a verified npm tarball. */
|
|
82
|
+
export function readPackageFile(tarball, wanted) {
|
|
83
|
+
const entry = readPackageEntries(tarball).find((candidate) => candidate.type === 'file' && candidate.path === wanted)
|
|
84
|
+
if (!entry) throw new Error(`npm tarball 缺少 ${wanted}`)
|
|
85
|
+
return entry.content
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Extract safe npm package contents into an existing staging directory. */
|
|
89
|
+
export function extractPackage(tarball, destination) {
|
|
90
|
+
const entries = readPackageEntries(tarball)
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const target = join(destination, ...entry.path.split('/'))
|
|
93
|
+
if (entry.type === 'directory') {
|
|
94
|
+
mkdirSync(target, { recursive: true })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
98
|
+
writeFileSync(target, entry.content)
|
|
99
|
+
try { chmodSync(target, entry.mode & 0o111 ? 0o755 : 0o644) } catch { /* Windows or restricted filesystem */ }
|
|
100
|
+
}
|
|
101
|
+
return entries.filter((entry) => entry.type === 'file').map((entry) => entry.path)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const archiveLimits = {
|
|
105
|
+
maxTarballBytes: MAX_TARBALL_BYTES,
|
|
106
|
+
maxUnpackedBytes: MAX_UNPACKED_BYTES,
|
|
107
|
+
maxFiles: MAX_FILES,
|
|
108
|
+
}
|
package/catalog.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readPackageFile, verifyIntegrity } from './archive.js'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CATALOG_PACKAGE = '@boostkit-dsh/skills-catalog'
|
|
4
|
+
export const DEFAULT_REGISTRIES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com']
|
|
5
|
+
|
|
6
|
+
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
7
|
+
const PACKAGE_RE = /^@boostkit-dsh\/skill-[a-z0-9][a-z0-9._-]*$/
|
|
8
|
+
const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
9
|
+
const COMMIT_RE = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/
|
|
10
|
+
const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]+={0,2}$/
|
|
11
|
+
const CATEGORY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
12
|
+
|
|
13
|
+
function ownObject(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requiredText(value, field) {
|
|
18
|
+
if (typeof value !== 'string' || value.trim() === '') throw new Error(`skills catalog ${field} 必填`)
|
|
19
|
+
return value.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function manifestUrl(registry, packageName, version) {
|
|
23
|
+
return `${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/${version}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function responseJson(response, label) {
|
|
27
|
+
if (!response?.ok) throw new Error(`${label} HTTP ${response?.status ?? 'unknown'}`)
|
|
28
|
+
return await response.json()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function responseBuffer(response, label) {
|
|
32
|
+
if (!response?.ok) throw new Error(`${label} HTTP ${response?.status ?? 'unknown'}`)
|
|
33
|
+
return Buffer.from(await response.arrayBuffer())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateManifest(manifest, expectedPackage, expectedVersion) {
|
|
37
|
+
if (!ownObject(manifest) || manifest.name !== expectedPackage || manifest.version !== expectedVersion) throw new Error(`${expectedPackage}@${expectedVersion} npm manifest 身份不匹配`)
|
|
38
|
+
const tarball = manifest.dist?.tarball
|
|
39
|
+
const integrity = manifest.dist?.integrity
|
|
40
|
+
if (typeof tarball !== 'string' || !tarball.startsWith('https://') || !INTEGRITY_RE.test(String(integrity ?? ''))) throw new Error(`${expectedPackage}@${expectedVersion} npm 分发信息非法`)
|
|
41
|
+
return { tarball, integrity }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate and normalize one BoostKit Skill catalog document. */
|
|
45
|
+
export function validateSkillCatalog(value, expectedRelease) {
|
|
46
|
+
if (!ownObject(value) || value.protocol !== 1 || !VERSION_RE.test(String(value.release ?? '')) || !Array.isArray(value.skills)) throw new Error('skills catalog 协议不匹配')
|
|
47
|
+
if (expectedRelease !== undefined && value.release !== expectedRelease) throw new Error(`skills catalog release ${String(value.release)} 与 npm 包版本 ${expectedRelease} 不一致`)
|
|
48
|
+
const names = new Set()
|
|
49
|
+
const packages = new Set()
|
|
50
|
+
const skills = value.skills.map((entry, index) => {
|
|
51
|
+
const prefix = `skills[${index}]`
|
|
52
|
+
if (!ownObject(entry) || !NAME_RE.test(String(entry.name ?? ''))) throw new Error(`${prefix}.name 非法`)
|
|
53
|
+
if (!PACKAGE_RE.test(String(entry.package ?? ''))) throw new Error(`${prefix}.package 必须属于 @boostkit-dsh/skill-*`)
|
|
54
|
+
if (!VERSION_RE.test(String(entry.version ?? ''))) throw new Error(`${prefix}.version 必须是精确 SemVer`)
|
|
55
|
+
if (!COMMIT_RE.test(String(entry.commit ?? ''))) throw new Error(`${prefix}.commit 必须是完整 Git commit`)
|
|
56
|
+
if (!INTEGRITY_RE.test(String(entry.integrity ?? ''))) throw new Error(`${prefix}.integrity 必须使用 sha512`)
|
|
57
|
+
if (!ownObject(entry.displayName)) throw new Error(`${prefix}.displayName 必填`)
|
|
58
|
+
const displayName = {
|
|
59
|
+
zh: requiredText(entry.displayName.zh, `${prefix}.displayName.zh`),
|
|
60
|
+
...(entry.displayName.en === undefined ? {} : { en: requiredText(entry.displayName.en, `${prefix}.displayName.en`) }),
|
|
61
|
+
}
|
|
62
|
+
const description = requiredText(entry.description, `${prefix}.description`)
|
|
63
|
+
if (!CATEGORY_RE.test(String(entry.category ?? ''))) throw new Error(`${prefix}.category 非法`)
|
|
64
|
+
if (!Array.isArray(entry.tags) || entry.tags.some((tag) => typeof tag !== 'string' || tag.trim() === '')) throw new Error(`${prefix}.tags 非法`)
|
|
65
|
+
if (!ownObject(entry.source)) throw new Error(`${prefix}.source 必填`)
|
|
66
|
+
const repository = requiredText(entry.source.repository, `${prefix}.source.repository`)
|
|
67
|
+
const path = requiredText(entry.source.path, `${prefix}.source.path`)
|
|
68
|
+
if (!repository.startsWith('https://') || path.startsWith('/') || path.split('/').includes('..')) throw new Error(`${prefix}.source 非法`)
|
|
69
|
+
if (names.has(entry.name)) throw new Error(`Skill 名称重复:${entry.name}`)
|
|
70
|
+
if (packages.has(entry.package)) throw new Error(`Skill 包重复:${entry.package}`)
|
|
71
|
+
names.add(entry.name)
|
|
72
|
+
packages.add(entry.package)
|
|
73
|
+
return {
|
|
74
|
+
name: entry.name,
|
|
75
|
+
package: entry.package,
|
|
76
|
+
version: entry.version,
|
|
77
|
+
commit: entry.commit,
|
|
78
|
+
integrity: entry.integrity,
|
|
79
|
+
displayName,
|
|
80
|
+
description,
|
|
81
|
+
category: entry.category,
|
|
82
|
+
tags: [...new Set(entry.tags.map((tag) => tag.trim()))].sort(),
|
|
83
|
+
source: { repository, path },
|
|
84
|
+
}
|
|
85
|
+
}).sort((left, right) => left.name.localeCompare(right.name))
|
|
86
|
+
return { protocol: 1, release: value.release, skills }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fromRegistries(registries, task) {
|
|
90
|
+
const errors = []
|
|
91
|
+
for (const registry of registries) {
|
|
92
|
+
try {
|
|
93
|
+
return await task(registry)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
errors.push(`${registry}: ${String(error?.message ?? error)}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(errors.join(';') || '没有可用 npm registry')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Download and verify the published BoostKit Skill catalog package. */
|
|
102
|
+
export async function loadRemoteCatalog(options = {}) {
|
|
103
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
104
|
+
const packageName = options.packageName ?? DEFAULT_CATALOG_PACKAGE
|
|
105
|
+
const registries = options.registries ?? DEFAULT_REGISTRIES
|
|
106
|
+
const signal = options.signal
|
|
107
|
+
return await fromRegistries(registries, async (registry) => {
|
|
108
|
+
const latest = await responseJson(await fetchImpl(manifestUrl(registry, packageName, 'latest'), { signal, cache: 'no-store' }), `${registry} catalog manifest`)
|
|
109
|
+
if (!VERSION_RE.test(String(latest?.version ?? ''))) throw new Error('catalog npm 版本非法')
|
|
110
|
+
const distribution = validateManifest(latest, packageName, latest.version)
|
|
111
|
+
const tarball = await responseBuffer(await fetchImpl(distribution.tarball, { signal, cache: 'force-cache' }), `${registry} catalog tarball`)
|
|
112
|
+
verifyIntegrity(tarball, distribution.integrity)
|
|
113
|
+
const catalog = JSON.parse(readPackageFile(tarball, 'catalog.json').toString('utf8'))
|
|
114
|
+
return validateSkillCatalog(catalog, latest.version)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Download one exact Skill package and verify catalog, manifest and bytes agree. */
|
|
119
|
+
export async function downloadSkillPackage(entry, options = {}) {
|
|
120
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
121
|
+
const registries = options.registries ?? DEFAULT_REGISTRIES
|
|
122
|
+
const signal = options.signal
|
|
123
|
+
return await fromRegistries(registries, async (registry) => {
|
|
124
|
+
const manifest = await responseJson(await fetchImpl(manifestUrl(registry, entry.package, entry.version), { signal, cache: 'no-store' }), `${registry} Skill manifest`)
|
|
125
|
+
const distribution = validateManifest(manifest, entry.package, entry.version)
|
|
126
|
+
if (distribution.integrity !== entry.integrity) throw new Error(`${entry.package}@${entry.version} integrity 与 catalog 不一致`)
|
|
127
|
+
const tarball = await responseBuffer(await fetchImpl(distribution.tarball, { signal, cache: 'force-cache' }), `${registry} Skill tarball`)
|
|
128
|
+
verifyIntegrity(tarball, entry.integrity)
|
|
129
|
+
return tarball
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const skillCatalogPatterns = {
|
|
134
|
+
name: NAME_RE,
|
|
135
|
+
package: PACKAGE_RE,
|
|
136
|
+
version: VERSION_RE,
|
|
137
|
+
commit: COMMIT_RE,
|
|
138
|
+
integrity: INTEGRITY_RE,
|
|
139
|
+
}
|