feat: prepare versioned production Curio releases

This commit is contained in:
2026-09-09 17:28:28 +08:00
parent 775d891288
commit 68b7164df8
10 changed files with 461 additions and 54 deletions
+9 -48
View File
@@ -1,58 +1,19 @@
# Trove Curios
第一方 Curio 与官方软件源仓库。目前包含一套用于验证 Trove 底座的测试插件,尚无正式发布的产品插件。测试源与 `source/index.json` 官方源分开,打包不会修改官方源。
让桌面,多一点顺手。
## 底座测试套件
Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常用能力触手可及。
15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。
## 小工具,大用途
```sh
pnpm install --frozen-lockfile
每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。
# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。
export TROVE_BIN=/absolute/path/to/app/target/debug/trove
pnpm run pack
## 按你的习惯组合
# 可选:使用真实底座组件在临时目录自动验证。
export TROVE_APP_DIR=/absolute/path/to/app
pnpm run verify
```
按需安装,自由搭配。工具各自更新,也能通过服务相互配合,让你的工作方式逐渐长成自己的样子。
打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。
## 从桌面,开始
生成结果:
打开 Trove,发现 Curios,为下一个灵感和下一项任务留出空间。
- `dist/packages/`:15 个可安装 `.curio` 包。
- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。
- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。
- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。
- `dist/verification.json`:自动验证的逐项结果、环境和观察项。
在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载:
```sh
pnpm run serve
# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json
# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。
```
完整步骤、覆盖矩阵与当前限制见 [TESTING.md](TESTING.md)。
## 目录
- `curios/<name>/`:各插件独立 Manifest 与公共服务契约。
- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。
- `source/index.json`:官方源的初始空索引。
- `tools/`:构建、打包、测试源和真实底座验证适配器。
- `vendor/`:版本化 SDK 分发包与来源说明。
项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。
## 发布约定
1. 各插件独立声明版本、平台、依赖与权限。
2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。
3. 先上传不可变的版本包,再更新索引中的下载 URL、大小和 SHA-256。
4. 最后原子发布 index.json。私有 Git 仓库不等于私有下载源;下载地址应符合分发对象的访问权限。
官网:https://trove.render.ink
[探索 Trove](https://trove.render.ink) · [发现 Curios](https://trove.render.ink/curios) · [开发者文档](docs/development.md)
+60
View File
@@ -0,0 +1,60 @@
# Curio 开发与验收
此仓库承载 Trove 正式插件与生产软件源。当前包含用于首批验收的简单插件;生产发布以 `source/index.json` 为准,本地测试材料位于 `dist/`。
## 底座测试套件
15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。
```sh
pnpm install --frozen-lockfile
# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。
export TROVE_BIN=/absolute/path/to/app/target/debug/trove
pnpm run pack
# 可选:使用真实底座组件在临时目录自动验证。
export TROVE_APP_DIR=/absolute/path/to/app
pnpm run verify
```
打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。
生成结果:
- `dist/packages/`:15 个可安装 `.curio` 包。
- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。
- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。
- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。
- `dist/verification.json`:自动验证的逐项结果、环境和观察项。
在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载:
```sh
pnpm run serve
# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json
# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。
```
完整步骤、覆盖矩阵与当前限制见 [测试指南](testing.md)。
插件各自通过 `curio.json.version` 管理版本;公开服务契约版本与底座兼容范围分别维护。历史发布索引只追加版本、禁止覆盖同版本产物,详见 [版本与发布](versioning.md)。`pnpm run release:prepare --base-url <HTTPS 分发目录>` 可准备保留历史的发布材料,`pnpm run test:release` 验证这些约束。
## 目录
- `curios/<name>/`:各插件独立 Manifest 与公共服务契约。
- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。
- `source/index.json`:生产软件源与历史版本索引。
- `tools/`:构建、打包、测试源和真实底座验证适配器。
- `vendor/`:版本化 SDK 分发包与来源说明。
项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。
## 发布约定
1. 各插件独立声明版本、平台、依赖与权限。
2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。
3. 先上传不可变的版本包,再更新索引中的下载 URL、大小和 SHA-256。
4. 最后原子发布 index.json。私有 Git 仓库不等于私有下载源;下载地址应符合分发对象的访问权限。
官网:https://trove.render.ink
+15 -1
View File
@@ -4,7 +4,7 @@
## 快速验收
1. 按 README 执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。
1. 按[开发文档](development.md)执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。
2. 在底座添加本地源 `dist/index.json`,先安装「01 · Host 能力检查」「02 · 数据隔离对照」「03 · 原生后端检查」。逐一打开,点击「运行常规检查」。
3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。
4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。
@@ -97,6 +97,20 @@ pnpm run verify
首次编译可能需要较长时间。自动结果不等于桌面 WebView/系统交互的完整验收;真实 WebView 注入、窗口视觉、系统权限、快捷键触发、通知展示、app-start 的应用启动调度仍应按上文手动检查。
## 远程软件源验收
发布完成后,必须使用底座实际访问的索引 URL 单独验收:
```sh
TROVE_APP_DIR=/absolute/path/to/app pnpm run verify \
--remote-source https://downloads.example/source/index.json \
--source-id trove-official
```
此模式不读取本地 `.curio`:在新的临时目录中通过真实 PackageManager 从远程刷新索引、下载并安装其中的每个版本,检查 UI 入口,启动安装后的原生后端,再执行 consumer → provider 调用。结果写入 `dist/remote-verification.json`;失败时非零退出,不能视为安装通过。
当前运行探针适用于生产源中的首批验收插件;后续新增产品插件时,应为它们补充对应的运行验收。故障源包含故意无法启动的后端,不能以全部启动成功作为标准。示例域名须替换为真实分发地址;直接读取私有 Gitea 仓库需要登录,推送源码本身不代表已发布软件源。下载可见性和历史发布管理见 [版本与发布](versioning.md)。
## 当前能力边界
- 当前底座默认未调用 `strict_contracts(true)`。公共服务的无效返回值可能直接透传,因此 consumer 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。
+68
View File
@@ -0,0 +1,68 @@
# 插件版本与发布记录
仓库是 monorepo:**每个 Curio 独立版本、独立兼容范围、独立更新**。不要求一次提交中的所有插件一起升级。
| 信息 | 唯一来源 | 作用 |
| --- | --- | --- |
| 插件版本 | `curios/<name>/curio.json` 的 `version` | 底座安装、更新、切换和回滚的版本 |
| 公共服务版本 | `services.provides[].version`,与 Contract 的 `version` 一致 | 调用方依赖约束;不兼容接口升级主版本 |
| 底座兼容范围 | `engine.trove` | 声明可运行的 Trove 版本;不是插件版本 |
| 已发布版本索引 | `source/index.json` | 保存全部历史版本、平台、URL、大小和 SHA-256 |
| 源码快照 | Git commit;可打 `<curio-id>/v<version>` tag | 从一个已发布插件版本定位对应源代码 |
| 仓库工具版本 | 根 `package.json.version` | 构建/验证工具版本,不用于判断插件是否有更新 |
例如只修复 `dev.trove.qa.host` 的 UI,应将该插件从 `1.0.0` 升到 `1.0.1`,其他插件保持原版本。服务接口没有变化时,服务版本仍可保持 `1.0.0`。
## 不可变发布
一个产物由 **插件 ID + 插件版本 + 平台架构** 唯一标识:
```text
packages/dev.trove.qa.host/1.0.0/any.curio
packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio
packages/dev.trove.qa.self/1.0.0/x86_64-apple-darwin.curio
```
同一版本可以补齐另一种架构,但不能替换已经发布的同架构内容。发布后发现问题要发新版本,不能重传覆盖旧文件。回滚切换到已有历史版本,不删除或重写坏版本对应的源码记录。服务有破坏性变更时还需要修改服务主版本和使用方依赖。
当前 `test-upgrade-v1` 与 `test-upgrade-v2` 是验收专用的双版本样本,共用 `dev.trove.qa.upgrade` ID。普通产品插件应只维护一份当前源码;历史通过 Git 和发布目录保存,不为每次升级复制一个源码文件夹。
## 准备发布
先修改需要发布的 Manifest,执行 `pnpm run pack` 和验证。`dist/index.json` 是可反复生成的本地开发索引;不能把它当作历史发布记录。
```sh
# 必须是确定的、可由底座访问的 HTTPS 分发目录;示例域名须替换。
pnpm run release:prepare --base-url https://downloads.example/source/
pnpm run test:release
```
命令会将本次打包的常规插件合并到生产源 `source/index.json`(源 ID 为 `trove-official`),并在 `dist/release/` 准备同目录结构的上传材料。故障注入插件和无效包不会进入生产源。未确定分发地址时不预先填入不可访问的下载地址。
校验规则:
- 从当前包计算大小和 SHA-256,与打包元数据比较,防止索引指向错误内容。
- 保留旧版本以及其他架构;不会因为本次只发布部分内容而重建/清空历史。
- 同 ID、版本、架构的 SHA-256、大小、URL 必须与历史完全一致;不一致会要求升级插件版本。
- 同一已发布版本的 `engine.trove` 不得悄悄变化。
- 重复准备相同材料不会改变首次发布日期。
- 先完整校验本次所有条目,再复制产物和更新本地索引。
`--catalog <目录>` 可在隔离目录试运行。正式发布应将 `source/` 的历史索引提交回仓库。该脚本只准备本地材料,不表示上传或远程安装已经成功。
## 发布顺序
1. 提交并推送源代码,记录 Git commit;需要时为各插件打独立 tag。
2. 构建和验证,准备发布索引;审阅本次新增的版本与产物。
3. 上传新安装包,拒绝覆盖已存在且内容不同的文件。
4. 从底座实际使用的网络路径下载每个新增包,核对 SHA-256 和大小。
5. 最后原子替换远程索引,提交并推送仓库中的发布索引。
6. 从该远程源刷新、安装并运行验证;不能用本地文件安装冒充远程源验收。
发布工作串行进行;两种架构先各自构建,再将产物合并至同一版本。若更新 `shared/ui/` 或 `shared/backend/`,所有受影响且需要重新发布的插件都要增加插件版本,未受影响的插件不必修改。
## 私有源码与下载认证
当前 `https://git.render.ink/trove/curios` 是私有源码仓库,匿名访问原始 JSON 会跳转到登录页。推送源代码本身不能使它成为可安装的软件源。
Trove 当前 HTTP 软件源没有仓库登录凭据配置。生产分发可使用免登录的安装包/索引下载入口,或在底座支持私有软件源认证后使用受保护的入口;不要把访问令牌写进 Manifest、源码索引或下载 URL。源码仓库可保持私有,安装包的可见性单独决定。
+3 -1
View File
@@ -1,5 +1,5 @@
{
"name": "trove-curio-test-lab",
"name": "trove-curios",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -9,6 +9,8 @@
"pack": "node tools/pack.mjs",
"verify": "node tools/verify.mjs",
"serve": "node tools/serve.mjs",
"release:prepare": "node tools/prepare-release.mjs",
"test:release": "node --test tools/release-catalog.test.mjs",
"test": "node tools/verify.mjs"
},
"dependencies": {
+138
View File
@@ -372,6 +372,14 @@ fn register(
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("--remote-source") {
return remote_source_check(
args.get(2).context("remote source URL required")?,
args.get(3).context("remote source ID required")?,
)
.await;
}
let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?)
.canonicalize()?;
let sandbox = tempfile::Builder::new()
@@ -468,3 +476,133 @@ async fn main() -> Result<()> {
gateway.shutdown().await;
Ok(())
}
async fn remote_source_check(url: &str, source_id: &str) -> Result<()> {
let sandbox = tempfile::Builder::new()
.prefix("trove-remote-qa-")
.tempdir_in("/tmp")?;
let paths = AppPaths::under(sandbox.path());
let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?);
let runtime = BackendSupervisor::new(sandbox.path().join("host.sock"));
let bus = ServiceBus::new(runtime.clone());
bus.register_builtin(Arc::new(RuntimeService {
paths: paths.clone(),
packages: packages.clone(),
}))?;
bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?;
let gateway =
UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?;
let mut rows = Vec::new();
check(&mut rows, "从真实远程源读取索引", async {
packages.add_source(SourceConfig {
id: source_id.into(),
name: source_id.into(),
kind: SourceKind::Http,
index: url.into(),
enabled: true,
priority: 0,
refresh_interval_seconds: 3600,
})?;
let refreshed = packages.refresh_source(source_id).await?;
ensure!(!refreshed.stale, "Remote source refresh used stale cache");
ensure!(
!packages.catalog().is_empty(),
"Remote source has no installable plugins"
);
Ok(json!({"url":url,"sourceId":source_id,"curioCount":packages.catalog().len()}))
})
.await;
if rows.iter().all(|row| row["status"] == "pass") {
for entry in packages.catalog() {
for version in entry.package.versions {
check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async {
let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?;
ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source");
if let Some(ui) = &package.manifest.ui {
let asset = trove::runtime::assets::resolve_asset(&package.root, &package.manifest.id, &ui.entry, &format!("/{}/{}", package.manifest.id, ui.entry))?;
ensure!(asset.is_file(), "Installed UI entry is missing");
}
Ok(json!({"id":package.manifest.id,"version":package.active_version,"sourceId":package.source_id}))
}).await;
}
}
for package in packages.list() {
register(&package, &paths, &runtime, &bus)?;
}
for package in packages
.list()
.into_iter()
.filter(|package| package.manifest.backend.is_some())
{
check(
&mut rows,
format!("运行远程安装后端 {}", package.manifest.id),
async {
let context = trove::service::CallContext::new(
Caller {
curio_id: Some(package.manifest.id.clone()),
instance_id: None,
endpoint: "ui".into(),
},
"remote-check",
);
let result = bus
.call(context, "@self", "info", json!({}), Default::default())
.await?;
ensure!(
result["curioId"] == package.manifest.id
&& result["version"] == package.active_version,
"Backend identity doesn't match installed remote version"
);
ensure!(
result["pid"].as_u64().unwrap_or(0) > 0,
"Backend didn't report a process"
);
Ok(result)
},
)
.await;
}
if packages.get("dev.trove.qa.consumer").is_ok()
&& packages.get("dev.trove.qa.provider").is_ok()
{
check(&mut rows, "远程安装插件间的服务调用", async {
let context = trove::service::CallContext::new(
Caller {
curio_id: Some("dev.trove.qa.consumer".into()),
instance_id: None,
endpoint: "ui".into(),
},
"remote-consumer",
);
let result = bus
.call(
context,
"dev.trove.qa.provider.probe",
"echo",
json!({"remote":true,"text":"远程安装 🧪"}),
Default::default(),
)
.await?;
ensure!(
result == json!({"remote":true,"text":"远程安装 🧪"}),
"Cross-plugin response changed"
);
Ok(result)
})
.await;
}
}
bus.shutdown().await;
runtime.shutdown_all().await;
gateway.shutdown().await;
sandbox.close()?;
let failed = rows.iter().filter(|row| row["status"] == "fail").count();
emit(json!({
"schemaVersion":1,"sourceUrl":url,"sourceId":source_id,"target":target(),
"scope":"Remote HTTP index and packages via real PackageManager; installed native backends via real UDS. Desktop UI rendering is not included.",
"passed":rows.len()-failed,"failed":failed,"temporaryDataRemoved":true,"results":rows,
}));
ensure!(failed == 0, "Remote source verification failed");
Ok(())
}
+69
View File
@@ -0,0 +1,69 @@
import { readFile, writeFile, mkdir, copyFile, rename, rm } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { parseArgs } from 'node:util';
import path from 'node:path';
import { root, output } from './suite.mjs';
import { appendRelease, artifactPath } from './release-catalog.mjs';
const { values } = parseArgs({ options: {
'base-url': { type: 'string' },
catalog: { type: 'string', default: 'source' },
} });
if (!values['base-url']) throw new Error('Usage: pnpm run release:prepare --base-url https://downloads.example/source/');
const base = new URL(values['base-url']);
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) {
throw new Error('Publication base must be an HTTPS directory URL without credentials, query or fragment');
}
if (!base.pathname.endsWith('/')) base.pathname += '/';
const catalogDirectory = path.resolve(root, values.catalog);
const publishDirectory = path.join(output, 'release');
const timestamp = new Date().toISOString();
const specifications = [
{ file: 'index.json', id: 'trove-official', name: 'Trove 官方软件源' },
];
const catalogs = new Map();
for (const spec of specifications) {
let index;
try { index = JSON.parse(await readFile(path.join(catalogDirectory, spec.file), 'utf8')); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
index = { schemaVersion: 1, id: spec.id, name: spec.name, generatedAt: timestamp, curios: [] };
}
if (index.id !== spec.id || index.schemaVersion !== 1 || !Array.isArray(index.curios)) throw new Error(`Unexpected catalog identity: ${spec.file}`);
catalogs.set(spec.file, index);
}
const entries = JSON.parse(await readFile(path.join(output, 'suite.json'), 'utf8'));
const files = [];
// Validate every proposed addition before changing any catalog or copying artifacts.
for (const entry of entries) {
if (entry.fault) continue;
const bytes = await readFile(entry.file);
const sha256 = createHash('sha256').update(bytes).digest('hex');
if (sha256 !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error(`Build metadata doesn't match ${entry.file}; run pack again`);
const relative = artifactPath(entry.manifest, entry.artifact.target);
const url = new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href;
const spec = specifications[0];
const artifact = { ...entry.artifact, url };
catalogs.set(spec.file, appendRelease(catalogs.get(spec.file), entry.manifest, artifact, timestamp));
files.push({ from: entry.file, relative, sha256, size: bytes.length, url });
}
// Only the current approved publication plan belongs in the generated upload directory.
await rm(publishDirectory, { recursive: true, force: true });
for (const file of files) {
const destination = path.join(publishDirectory, file.relative);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.from, destination);
}
await mkdir(catalogDirectory, { recursive: true });
await mkdir(publishDirectory, { recursive: true });
for (const [file, index] of catalogs) {
const text = JSON.stringify(index, null, 2) + '\n';
await writeFile(path.join(publishDirectory, file), text);
const temporary = path.join(catalogDirectory, `${file}.tmp`);
await writeFile(temporary, text);
await rename(temporary, path.join(catalogDirectory, file));
}
await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ baseUrl: base.href, files }, null, 2) + '\n');
console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`);
console.log(`Catalog history: ${catalogDirectory}`);
console.log('Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.');
+37
View File
@@ -0,0 +1,37 @@
// Published identity is (Curio ID, version, target). Existing identities are immutable.
export function appendRelease(catalog, manifest, artifact, releasedAt) {
const next = structuredClone(catalog);
let curio = next.curios.find(item => item.id === manifest.id);
if (!curio) {
curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', versions: [] };
next.curios.push(curio);
}
let version = curio.versions.find(item => item.version === manifest.version);
if (version && version.engine.trove !== manifest.engine.trove) {
throw new Error(`${manifest.id}@${manifest.version}: published engine range cannot change; bump the plugin version`);
}
const existing = version?.artifacts.find(item => item.target === artifact.target);
if (existing) {
for (const key of ['sha256', 'size', 'url']) {
if (existing[key] !== artifact[key]) throw new Error(`${manifest.id}@${manifest.version}/${artifact.target}: published ${key} differs; bump the plugin version`);
}
return next;
}
if (!version) {
version = { version: manifest.version, engine: structuredClone(manifest.engine), releasedAt, artifacts: [] };
curio.versions.unshift(version);
curio.name = manifest.name;
curio.description = manifest.description ?? '';
}
version.artifacts.push(structuredClone(artifact));
next.generatedAt = releasedAt;
return next;
}
export function artifactPath(manifest, target) {
// Validate path components before they become a publication path.
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(manifest.id)) throw new Error('Invalid Curio ID');
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error('Invalid plugin version');
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) throw new Error('Unsupported artifact target');
return `packages/${manifest.id}/${manifest.version}/${target}.curio`;
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendRelease, artifactPath } from './release-catalog.mjs';
const initial = { schemaVersion: 1, id: 'trove-qa', name: 'QA', generatedAt: '2026-09-01T00:00:00Z', curios: [] };
const manifest = { id: 'dev.trove.qa.example', name: 'Example', version: '1.0.0', engine: { trove: '^0.1.0' } };
const arm = { target: 'aarch64-apple-darwin', url: 'https://example.org/1.0.0/arm.curio', sha256: 'a'.repeat(64), size: 100 };
const timestamp = '2026-09-09T00:00:00Z';
const released = () => appendRelease(initial, manifest, arm, timestamp);
test('publishing a new version retains all earlier releases', () => {
const original = released();
const updated = appendRelease(original, { ...manifest, version: '1.1.0' }, { ...arm, url: 'https://example.org/1.1.0/arm.curio', sha256: 'b'.repeat(64) }, '2026-09-10T00:00:00Z');
assert.equal(updated.curios[0].versions.length, 2);
assert.deepEqual(updated.curios[0].versions[1], original.curios[0].versions[0]);
assert.equal(initial.curios.length, 0);
});
test('repeating an identical release does not change dates or metadata', () => {
const original = released();
assert.deepEqual(appendRelease(original, manifest, arm, '2026-10-01T00:00:00Z'), original);
});
test('one version can add a second architecture without replacing the first', () => {
const original = released();
const updated = appendRelease(original, manifest, { ...arm, target: 'x86_64-apple-darwin', url: 'https://example.org/1.0.0/intel.curio', sha256: 'c'.repeat(64) }, '2026-09-10T00:00:00Z');
assert.equal(updated.curios[0].versions.length, 1);
assert.equal(updated.curios[0].versions[0].artifacts.length, 2);
assert.deepEqual(updated.curios[0].versions[0].artifacts[0], arm);
assert.equal(updated.curios[0].versions[0].releasedAt, timestamp);
});
for (const [key, value] of [['sha256', 'd'.repeat(64)], ['size', 101], ['url', 'https://example.org/replaced.curio']]) {
test(`same version and architecture cannot replace ${key}`, () => {
const original = released();
assert.throws(() => appendRelease(original, manifest, { ...arm, [key]: value }, timestamp), /bump the plugin version/);
assert.deepEqual(original.curios[0].versions[0].artifacts[0], arm);
});
}
test('published engine compatibility cannot change silently', () => {
assert.throws(() => appendRelease(released(), { ...manifest, engine: { trove: '^0.2.0' } }, arm, timestamp), /engine range cannot change/);
});
test('artifact paths distinguish architecture, ID and version and reject traversal', () => {
assert.equal(artifactPath(manifest, 'any'), 'packages/dev.trove.qa.example/1.0.0/any.curio');
assert.notEqual(artifactPath(manifest, 'aarch64-apple-darwin'), artifactPath(manifest, 'x86_64-apple-darwin'));
assert.throws(() => artifactPath({ ...manifest, id: '../outside' }, 'any'));
assert.throws(() => artifactPath({ ...manifest, version: '../outside' }, 'any'));
assert.throws(() => artifactPath(manifest, '../outside'));
});
+16 -4
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { parseArgs } from 'node:util';
import { createInterface } from 'node:readline';
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
import path from 'node:path';
@@ -8,10 +9,12 @@ import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '..
import { serve } from './serve.mjs';
// Only this optional integration adapter depends on the app checkout.
await rm(path.join(output, 'verification.json'), { force: true });
const { values: args } = parseArgs({ options: { 'remote-source': { type: 'string' }, 'source-id': { type: 'string', default: 'trove-official' } } });
const reportPath = path.join(output, args['remote-source'] ? 'remote-verification.json' : 'verification.json');
await rm(reportPath, { force: true });
const app = process.env.TROVE_APP_DIR;
if (!app) throw new Error('Set TROVE_APP_DIR to the app repository, then run pnpm run verify.');
await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); });
if (!args['remote-source']) await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); });
const harness = path.join(output, 'host-check');
await mkdir(harness, { recursive: true });
const manifest = `[package]
@@ -37,9 +40,18 @@ await writeFile(path.join(harness, 'Cargo.toml'), manifest);
const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target');
try {
run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]);
if (args['remote-source']) {
const result = spawnSync(path.join(target, 'debug/trove-qa-host-check'), ['--remote-source', args['remote-source'], args['source-id']], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 180000 });
if (result.error) throw result.error;
const report = JSON.parse(result.stdout);
report.generatedAt = new Date().toISOString();
await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n');
console.log(`${report.passed} passed, ${report.failed} failed. Report: ${reportPath}`);
process.exit(result.status ?? 1);
}
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
} catch (error) {
await writeFile(path.join(output, 'verification.json'), JSON.stringify({
await writeFile(reportPath, JSON.stringify({
schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0,
results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }],
}, null, 2) + '\n');