feat: prepare versioned production Curio releases
This commit is contained in:
@@ -1,58 +1,19 @@
|
|||||||
# Trove Curios
|
# Trove Curios
|
||||||
|
|
||||||
第一方 Curio 与官方软件源仓库。目前包含一套用于验证 Trove 底座的测试插件,尚无正式发布的产品插件。测试源与 `source/index.json` 官方源分开,打包不会修改官方源。
|
让桌面,多一点顺手。
|
||||||
|
|
||||||
## 底座测试套件
|
Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常用能力触手可及。
|
||||||
|
|
||||||
15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。
|
## 小工具,大用途
|
||||||
|
|
||||||
```sh
|
每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。
|
||||||
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 中。
|
## 从桌面,开始
|
||||||
|
|
||||||
生成结果:
|
打开 Trove,发现 Curios,为下一个灵感和下一项任务留出空间。
|
||||||
|
|
||||||
- `dist/packages/`:15 个可安装 `.curio` 包。
|
[探索 Trove](https://trove.render.ink) · [发现 Curios](https://trove.render.ink/curios) · [开发者文档](docs/development.md)
|
||||||
- `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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 · 原生后端检查」。逐一打开,点击「运行常规检查」。
|
2. 在底座添加本地源 `dist/index.json`,先安装「01 · Host 能力检查」「02 · 数据隔离对照」「03 · 原生后端检查」。逐一打开,点击「运行常规检查」。
|
||||||
3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。
|
3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。
|
||||||
4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。
|
4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。
|
||||||
@@ -97,6 +97,20 @@ pnpm run verify
|
|||||||
|
|
||||||
首次编译可能需要较长时间。自动结果不等于桌面 WebView/系统交互的完整验收;真实 WebView 注入、窗口视觉、系统权限、快捷键触发、通知展示、app-start 的应用启动调度仍应按上文手动检查。
|
首次编译可能需要较长时间。自动结果不等于桌面 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 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。
|
- 当前底座默认未调用 `strict_contracts(true)`。公共服务的无效返回值可能直接透传,因此 consumer 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。
|
||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "trove-curio-test-lab",
|
"name": "trove-curios",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"pack": "node tools/pack.mjs",
|
"pack": "node tools/pack.mjs",
|
||||||
"verify": "node tools/verify.mjs",
|
"verify": "node tools/verify.mjs",
|
||||||
"serve": "node tools/serve.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"
|
"test": "node tools/verify.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -372,6 +372,14 @@ fn register(
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
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")?)
|
let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?)
|
||||||
.canonicalize()?;
|
.canonicalize()?;
|
||||||
let sandbox = tempfile::Builder::new()
|
let sandbox = tempfile::Builder::new()
|
||||||
@@ -468,3 +476,133 @@ async fn main() -> Result<()> {
|
|||||||
gateway.shutdown().await;
|
gateway.shutdown().await;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 { createInterface } from 'node:readline';
|
||||||
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
|
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -8,10 +9,12 @@ import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '..
|
|||||||
import { serve } from './serve.mjs';
|
import { serve } from './serve.mjs';
|
||||||
|
|
||||||
// Only this optional integration adapter depends on the app checkout.
|
// 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;
|
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.');
|
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');
|
const harness = path.join(output, 'host-check');
|
||||||
await mkdir(harness, { recursive: true });
|
await mkdir(harness, { recursive: true });
|
||||||
const manifest = `[package]
|
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');
|
const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target');
|
||||||
try {
|
try {
|
||||||
run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]);
|
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')]);
|
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
|
||||||
} catch (error) {
|
} 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,
|
schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0,
|
||||||
results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }],
|
results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }],
|
||||||
}, null, 2) + '\n');
|
}, null, 2) + '\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user