diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 58bf00a..ef9e638 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -110,13 +110,13 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + | 5 | 签名私钥 | GitHub secret + `HarlonWang/secrets` 备份一份,本机生成后即删 | | 6 | `installId` | 复用 TrendingAI 的 `getOrCreateInstallId()`,`Updates` 事件接进埋点 | | 7 | 命名 | 包名 `trendingai`(页面 `trendingai/subscription`),线上 app id `trendingai`;宿主层面标识页面(深链、埋点页面名、日志)用 `tinyui:trendingai/subscription`,`navigation.push` 仍用不带前缀的 | -| 8 | JS 侧填错目标宿主版本 | 发布前核对(updates.md §1.3):`tinyui build` 把每页用到的能力名与宿主组件名写进 manifest 的 `requires`;`bundle` 从热下发服务读目标版本的宿主快照比对,不通过即拒绝出包。快照由宿主 CI 在发 App 版本时上传,每版本只写一次。铁律:`host.call` 的名字必须是字面量、宿主组件必须能静态解析(build-chain.md §5.1)。设备侧核对暂不做 | +| 8 | JS 侧填错目标宿主版本 | 发布前核对(updates.md §1.3):`tinyui build` 把每页用到的能力名与宿主组件名写进 manifest 的 `requires`;`publish` 上传前从热下发服务读目标版本的宿主快照比对,不通过即拒绝发布(`bundle` 保持离线)。快照由宿主 CI 在发 App 版本时上传,每版本只写一次。铁律:`host.call` 的名字必须是字面量、宿主组件必须能静态解析(build-chain.md §5.1)。设备侧核对暂不做 | | 9 | 服务端凭据分层 | 在实例 `ADMIN_TOKEN` 与包的发布 token 之间补 app token:一个 app 的完整管理权(注册包、公钥、签发发布 token、上传宿主快照),暂不细分权限范围;宿主 CI 用它上传快照;本 app 的发布 token 可读快照。权限与 URL 挂在 app(一个宿主 App)上,不挂在租户(`org`,可改可转让)上(updates.md §6) | 交付: -- **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui bundle` 从服务端读目标宿主版本的快照核对(updates.md §1.3);宿主契约快照——导出宿主组件 schema、能力名、tinyui 版本为稳定排序的文本,存 `tinyui-host/.txt`;检查任务(当前宿主与快照不符即失败)与生成任务(已随发版带出的版本拒绝覆盖),任务名做时定。前置:宿主能力能集中枚举(TrendingAI 现在散在各 Screen 里注册)。`tinyui:` 前缀约定写进 app-model.md -- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 的 0.3.0,加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `bundle` 对照服务端的宿主快照拒绝出包,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollback / rollout` +- **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui publish` 上传前读目标宿主版本的快照核对(updates.md §1.3,快照格式 §6.4);宿主契约快照——导出宿主组件 schema、能力名、tinyui 版本为稳定排序的文本,存 `tinyui-host/.txt`;检查任务(当前宿主与快照不符即失败)与生成任务(已随发版带出的版本拒绝覆盖),任务名做时定。前置:宿主能力能集中枚举(TrendingAI 现在散在各 Screen 里注册)。`tinyui:` 前缀约定写进 app-model.md +- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 的 0.3.0,加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollback / rollout` - **服务端代码**:app token 的签发与吊销、各管理端点改为认 app token(updates.md §6.3);宿主快照的上传与读取端点(§6.4,每个 (app, hostVersion) 只写一次);CLI 配套 `apps tokens`、`hosts upload` - **服务端(线上,不可逆,执行前列命令确认)**:`apps create trendingai --name TrendingAI`(`org` 留空);签一枚 app token 给 TrendingAI 宿主 CI 上传快照;`packages create trendingai --app trendingai`(登记公钥);两枚发布 token,CI 用的限 `staging`,production environment 用的限 `production` - **TrendingAI**:依赖升到 tinyui 0.3.0 并加 `tinyui-updates`;`TinyUIHost` 改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;宿主能力集中注册,`shared/tinyui-host/1.txt` 与检查任务进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 @@ -139,5 +139,5 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + | M5.1 改名 `hostVersion` | 已完成(2026-09-23,tinyui PR #17、tinyui-updates-server PR #2,服务端已部署):manifest 字段、Kotlin API、CLI `--host-version`、服务端路由与校验、文档全部改名;CLI、`Updates` 构造、服务端路径与 manifest 五处限定正整数;共用签名夹具换钥重签,Android host 与 iOS 模拟器两端验签通过 | | M5.2 `qjsc-kmp` 平台包 | 已完成(2026-09-23,quickjs-kmp PR #13 与 0.1.2、tinyui PR #18):入口包 `qjsc-kmp` + `@qjsc-kmp/{darwin-arm64,darwin-x64,linux-x64,linux-arm64}`,Linux 为 musl 全静态;首版本地发布,之后随 quickjs-kmp 的 tag 经 OIDC 发布;`tinyui-cli` 依赖它并由测试钉住与 `quickjsKmp` 同版本,CI 不再现编 `qjsc-kmp` | | tinyui 0.3.0 | 已发(2026-09-23):npm `tinyui-core` / `tinyui-native` / `tinyui-cli`,Maven `app.tinyui:tinyui` 与首发的 `app.tinyui:tinyui-updates`;`tinyui-cli` 带 `qjsc-kmp@0.1.2` | -| M6 TrendingAI 接入 | 进行中:拍板八点(2026-09-23);`tinyui build` 写 `requires` 已完成(PR #19,TrendingAI 订阅页实测 5 个能力、2 个宿主组件);下一步服务端快照端点与宿主 CI 凭据,再做 `bundle` 核对 | +| M6 TrendingAI 接入 | 进行中:拍板八点(2026-09-23);`tinyui build` 写 `requires` 已完成(PR #19,TrendingAI 订阅页实测 5 个能力、2 个宿主组件);服务端 app token 与快照端点、CLI 的 `apps tokens` / `hosts upload` 与 `publish` 核对进行中 | | M7 第二个 App | 待开 | diff --git a/docs/updates.md b/docs/updates.md index 9b4a03a..fe91ad9 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -37,7 +37,8 @@ pages/**/*.bin | `engine` | `tinyui build` | 字节码文件头里的引擎 commit(40 位 hex,所有 `.bin` 一致,取第一个) | | `protocol` | `tinyui build` | 所含 `tinyui-core` 的 `PROTOCOL`,读自子路径导出 `tinyui-core/protocol`(只含常量,不在 Node 里执行运行时模块) | | `hashes` | `tinyui build` | 模块名 → 该模块 `.bin` 的 sha256 hex,键与 `files` 一致 | -| `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities }`:该页用到的带点宿主组件名与 `host.call` 能力名,各自排序;内置组件不列。`bundle` 据此核对目标宿主版本(§1.3),客户端不读 | +| `tinyui` | `tinyui build` | 运行时模块取自的 `tinyui-core` 版本,决定页面能用哪些内置组件;`publish` 核对它等于目标宿主的 tinyui 版本(§1.3),客户端不读 | +| `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities }`:该页用到的带点宿主组件名与 `host.call` 能力名,各自排序;内置组件不列。`publish` 据此核对目标宿主版本(§1.3),客户端不读 | | `hostVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | 签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包的 manifest 没有 `hostVersion`,也没有指针文件——宿主知道自己的 hostVersion,内置包不参与灰度、不验签。 @@ -65,16 +66,16 @@ dist/ota////pages/**/*.bin `--host-version` 是发布方手填的。填成一个仍有设备在用的旧值(例:main 已为宿主版本 3 用上新能力 `coupon.apply`,却以 `--host-version 2` 发出一个修文案的包),旧宿主会照常装上,页面调用它没有的能力只得到 `E_UNSUPPORTED`、用到它没有的组件只渲染占位——都不崩溃,§4.5 的回退接不住。宿主侧的构建检查(§4.1)只管宿主漏 bump,管不到发布方填错。 -所以 `bundle` 在签名前核对:**这个包用到的宿主东西,目标宿主版本都提供**。 +所以 `tinyui publish` 在上传前核对:**这个包用到的宿主东西,目标宿主版本都提供**。放在 `publish` 而不是 `bundle`:`bundle` 是纯离线命令(签名、写目录,静态托管就用它的产物),`publish` 本来就带着 app、实例地址与 token 在和服务端说话,快照也在服务端上;静态托管没有快照,也就没有这层核对。 | 输入 | 来源 | |---|---| | 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities }`):`host.call` 的能力名、带点的宿主组件名(`ta.Icon`);内置组件不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | -| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、tinyui 版本)。宿主发 App 版本时由其 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次,与已随发版冻结的快照一致;`bundle` 从服务端读 | +| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、tinyui 版本)。宿主发 App 版本时由其 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次,与已随发版冻结的快照一致;`publish` 从服务端读 | -不通过即拒绝出包,报出哪一页缺了什么:页面用到的能力或宿主组件不在快照里;或打包用的 `tinyui-cli` 版本不等于快照里的 tinyui 版本(新版 tinyui 的内置组件,旧宿主没有)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 +不通过即拒绝发布(一个请求都不上传),报出哪一页缺了什么:页面用到的能力或宿主组件不在快照里;或 manifest 的 `tinyui` 不等于快照里的 tinyui 版本(新版 tinyui 的内置组件,旧宿主没有)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 -核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `bundle` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。 +核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `publish` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。 ## 2. 投递协议 @@ -146,7 +147,7 @@ class Updates( 验签公钥不由宿主传:每个包的信任锚是它内置 manifest 里的 `publicKey`(§1.1、§7)。单包宿主写 `Updates(listOf(embedded), …)`。 -**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。宿主给页面的东西变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;升级 tinyui。只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `bundle` 发布前核对(§1.3)。 +**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。宿主给页面的东西变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;升级 tinyui。只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 版本)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已随发版带出去的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 @@ -312,12 +313,27 @@ CLI:`tinyui apps create` / `tinyui apps tokens create|revoke` / `tinyui packag ### 6.4 宿主快照 -宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名与 tinyui 版本。宿主发 App 版本时由其 CI 上传,`bundle` 发布前读取核对。 +宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名与 tinyui 版本。宿主发 App 版本时由其 CI 上传,`publish` 发布前读取核对。格式是宿主侧生成工具与 CLI 之间的契约: + +``` +hostVersion 2 +tinyui 0.3.0 + +components + ta.Icon name: string, tint?: color, size?: dp + ta.Loading color?: color, size?: dp + +capabilities + checkout.start + coupon.apply +``` + +前两行固定;`components` 与 `capabilities` 两段按此顺序各出现一次,没有条目也要写段名(缺段会被读成"什么都不提供",所以一律拒绝);每项缩进两格、一行一项、按名排序。`tinyui hosts upload` 先解析并核对首行的 `hostVersion` 与 `--host-version` 相符再上传——服务端只收第一份。**每行第一个词是名字**,其后供人阅读、供宿主侧检查判断 schema 是否变了,CLI 核对只看名字。宿主组件只列带点的,内置组件随 `tinyui` 版本。 | 请求 | 凭据 | 语义 | |---|---|---| | `PUT /apps//hosts/`,body 为快照字节 | `ADMIN_TOKEN` 或本 app 的 app token | 每个 (app, hostVersion) 只写一次:同字节重传返回已存在,不同字节 → 409——已随发版带出的宿主版本,其快照冻结 | -| `GET /apps//hosts/` | 以上两种,或本 app 任一包的发布 token | 原字节;没上传过 → 404,`bundle` 据此拒绝出包 | +| `GET /apps//hosts/` | 以上两种,或本 app 任一包的发布 token | 原字节;没上传过 → 404,`publish` 据此拒绝发布 | CLI:`tinyui hosts upload --host-version [--app ]`(宿主 CI 用)。 diff --git a/packages/cli/README.md b/packages/cli/README.md index 76d13b1..1380ed5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,6 +2,6 @@ [TinyUI](https://github.com/tiny-ui/tinyui) 的构建工具:`tinyui build` 把 `tinyui.config.json` 描述的包(`src/pages/**/*.tsx`)编成每页一个 ESM 模块字节码(含运行时模块与 `manifest.json`),`tinyui bundle` 把一次 build 签名成可上传 / 可静态托管的热下发目录,`tinyui keys generate` 生成签名密钥对,`tinyui schema` 从组件 schema 生成 TS 类型与 Kotlin 侧代码。 -`tinyui publish` 把 `bundle` 的产物传到热下发服务并切换 channel 指针,`tinyui apps` / `packages` / `tokens` / `releases` 是这套服务的管理面(建 app、登记包的验签公钥、签发与吊销发布 token、列 release 与回滚 / 晋级 / 改灰度)。实例地址取 `--url`、`$TINYUI_UPDATES_URL`、`https://updates.tinyui.app` 三者之一;凭据只从环境变量读,不接受命令行参数:`$TINYUI_TOKEN` 是发布 token,`$TINYUI_ADMIN_TOKEN` 是实例管理 token。`--app` 可用 `$TINYUI_APP` 代替,`--pkg` 缺省取当前目录 `tinyui.config.json` 的 `name`,`--channel` 必须每次显式写。 +`tinyui publish` 先对照目标宿主版本的快照核对包用到的宿主能力与组件,再把 `bundle` 的产物传到热下发服务并切换 channel 指针;`tinyui apps` / `packages` / `tokens` / `releases` / `hosts` 是这套服务的管理面(建 app 与签发 app token、登记包的验签公钥、签发与吊销发布 token、列 release 与回滚 / 晋级 / 改灰度、宿主 CI 上传快照)。实例地址取 `--url`、`$TINYUI_UPDATES_URL`、`https://updates.tinyui.app` 三者之一;凭据只从环境变量读,不接受命令行参数:`$TINYUI_TOKEN` 是发布 token,`$TINYUI_ADMIN_TOKEN` 放实例的管理 token 或某个 app 的 app token。`--app` 可用 `$TINYUI_APP` 代替,`--pkg` 缺省取当前目录 `tinyui.config.json` 的 `name`,`--channel` 必须每次显式写。 字节码编译用 quickjs-kmp 的宿主工具 `qjsc-kmp`:依赖里的 npm 包 `qjsc-kmp` 带 macOS 与 Linux 的预编译版,装完即用;其他平台用 `--qjsc` 或 `TINYUI_QJSC` 指向自己编的那份。`qjsc-kmp` 的版本钉在与 tinyui 所用 quickjs-kmp 相同的版本上,编出的字节码才认 App 里的引擎。用法见仓库 `docs/build-chain.md` 与 `docs/updates.md`;与 `tinyui-core` 同版本号。 diff --git a/packages/cli/src/admin.ts b/packages/cli/src/admin.ts index b83f435..f542961 100644 --- a/packages/cli/src/admin.ts +++ b/packages/cli/src/admin.ts @@ -25,6 +25,20 @@ export interface IssuedToken { createdAt: string; } +/** An app token: the whole management of one app (docs/updates.md §6); readable only once, like publish tokens. */ +export interface IssuedAppToken { + id: string; + token: string; + createdAt: string; +} + +/** A host snapshot upload: written once per (app, hostVersion) (docs/updates.md §6.4). */ +export interface HostSnapshotResult { + hostVersion: string; + sha256: string; + existing: boolean; +} + export interface Releases { versions: { version: string; createdAt: string; publishedAt?: string }[]; channels: Record; @@ -50,6 +64,22 @@ export function revokeToken(client: UpdatesClient, app: string, pkg: string, tok return client.empty("DELETE", segments("apps", app, "packages", pkg, "tokens", tokenId)); } +export function createAppToken(client: UpdatesClient, app: string): Promise { + return client.json("POST", segments("apps", app, "tokens")); +} + +export function revokeAppToken(client: UpdatesClient, app: string, tokenId: string): Promise { + return client.empty("DELETE", segments("apps", app, "tokens", tokenId)); +} + +export function uploadHostSnapshot(client: UpdatesClient, app: string, hostVersion: string, snapshot: Uint8Array): Promise { + return client.putBytes(segments("apps", app, "hosts", hostVersion), snapshot); +} + +export function readHostSnapshot(client: UpdatesClient, app: string, hostVersion: string): Promise { + return client.bytes(segments("apps", app, "hosts", hostVersion)); +} + export function listReleases(client: UpdatesClient, app: string, pkg: string, hostVersion: string): Promise { return client.json("GET", segments(app, pkg, hostVersion, "releases")); } diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 2c38709..66a3733 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -2,13 +2,14 @@ import { parseArgs, type ParseArgsConfig } from "node:util"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; +import { createApp, createAppToken, createPackage, createToken, listReleases, movePointer, revokeAppToken, revokeToken, rotatePublicKey, uploadHostSnapshot } from "./admin.ts"; import { build } from "./build.ts"; import { bundle, isHostVersion, isPathSegment } from "./bundle.ts"; import { ADMIN_TOKEN_ENV, DEFAULT_URL, resolveUrl, requireToken, TOKEN_ENV, UpdatesClient } from "./client.ts"; import { loadConfig, requireName } from "./config.ts"; import { generateKeyPair } from "./keys.ts"; import { publish } from "./publish.ts"; +import { parseHostSnapshot } from "./snapshot.ts"; import { generateKt, generateTs } from "./schema/generate.ts"; import { loadSchema } from "./schema/load.ts"; @@ -18,6 +19,9 @@ const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] tinyui publish --channel [--app ] [--dir ] [--rollout <0-100>] tinyui apps create --name [--org ] + tinyui apps tokens create + tinyui apps tokens revoke --id + tinyui hosts upload --host-version [--app ] tinyui packages create --public-key [--app ] tinyui packages rotate-key --public-key [--app ] tinyui tokens create --channels [--app ] @@ -58,7 +62,8 @@ publish upload a bundle and move the channel pointer (docs/updates.md §6.1) the publishing and management commands talk to --url, default ${DEFAULT_URL} (or $TINYUI_UPDATES_URL) $${TOKEN_ENV} publish token, for publish and releases - $${ADMIN_TOKEN_ENV} instance admin token, for apps, packages and tokens + $${ADMIN_TOKEN_ENV} the instance's admin token or an app token, for apps, packages, tokens and hosts; + only the admin token creates apps and app tokens (docs/updates.md §6) $TINYUI_APP default for --app --pkg defaults to "name" in ./tinyui.config.json `; @@ -148,9 +153,10 @@ const COMMANDS: Record { - if (positionals[1] !== "create") throw new Error("apps: expected `apps create `"); + if (positionals[1] === "tokens") return appTokens(v, positionals); + if (positionals[1] !== "create") throw new Error("apps: expected `apps create ` or `apps tokens create|revoke `"); const id = required(positionals[2], "apps create: an app id is required"); const record = await createApp(adminClient(v), { id: requireName("an app id", id), @@ -203,12 +209,47 @@ const COMMANDS: Record { + if (positionals[1] !== "upload") throw new Error("hosts: expected `hosts upload --host-version `"); + const file = required(positionals[2], "hosts upload: the snapshot file is required"); + const hostVersion = required(v["host-version"], "hosts upload: --host-version is required"); + if (!isHostVersion(hostVersion)) throw new Error(`--host-version must be a positive integer, got "${hostVersion}"`); + const app = await appId(v); + const bytes = await readFile(file); + // the server keeps the first bytes for good, so a wrong file must fail here rather than freeze + const snapshot = parseHostSnapshot(new TextDecoder().decode(bytes)); + if (snapshot.hostVersion !== hostVersion) throw new Error(`${file} says hostVersion ${snapshot.hostVersion}, not ${hostVersion}; a host version's snapshot can be uploaded only once`); + const result = await uploadHostSnapshot(adminClient(v), app, hostVersion, bytes); + process.stderr.write(`${app} host version ${hostVersion}: ${result.existing ? "already uploaded with these bytes" : "snapshot uploaded"}\n`); + process.stdout.write(`${result.sha256}\n`); + return 0; + }, + }, releases: { options: RELEASE_OPTIONS, run: async (v, positionals) => releases(v, positionals), }, }; +async function appTokens(v: Values, positionals: string[]): Promise { + const action = positionals[2]; + if (action !== "create" && action !== "revoke") throw new Error("apps tokens: expected `apps tokens create ` or `apps tokens revoke --id `"); + const app = requireName("an app id", required(positionals[3] ?? (v["app"] as string | undefined), `apps tokens ${action}: an app id is required`)); + const client = adminClient(v); + if (action === "revoke") { + const id = pathSegment(required(v["id"], "apps tokens revoke: --id is required (keep the id from apps tokens create)"), "--id"); + await revokeAppToken(client, app, id); + process.stderr.write(`app token ${id} of ${app} revoked\n`); + return 0; + } + const issued = await createAppToken(client, app); + process.stderr.write(`app token ${issued.id} for ${app}; it manages everything under ${app} and is shown once\n`); + process.stdout.write(`${issued.token}\n`); + return 0; +} + async function releases(v: Values, positionals: string[]): Promise { const action = positionals[1]; const client = publishClient(v); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 7e63e9a..790cc1b 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -60,6 +60,8 @@ export interface Manifest { /** Engine commit the bytecode is bound to; empty when built with `jsOnly`. */ engine: string; protocol: number; + /** Version of the `tinyui-core` the runtime modules came from: which built-in components the pages may use. */ + tinyui: string; /** Module name → sha256 hex of its `.bin`; empty when built with `jsOnly`. */ hashes: Record; /** Page module name → the host capabilities and host components it uses (docs/updates.md §1.3). */ @@ -113,6 +115,7 @@ export async function build(options: BuildOptions): Promise { createdAt, engine: runtime[0]?.bin ? await engineCommit(runtime[0].bin) : "", protocol: await runtimeProtocol(root), + tinyui: await tinyuiVersion(root), hashes, requires, }; @@ -227,6 +230,18 @@ async function runtimeProtocol(root: string): Promise { return PROTOCOL; } +async function tinyuiVersion(root: string): Promise { + // the package's exports map hides package.json, so walk up from a file it does export + let dir = dirname(createRequire(join(root, "package.json")).resolve("tinyui-core/protocol")); + for (;;) { + const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8").catch(() => "{}")) as { name?: string; version?: string }; + if (pkg.name === "tinyui-core" && pkg.version) return pkg.version; + const parent = dirname(dir); + if (parent === dir) throw new Error("cannot find the version of tinyui-core"); + dir = parent; + } +} + async function gitShortSha(root: string): Promise { try { const { stdout } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd: root }); diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 7d4cfb9..e2cd977 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -58,9 +58,19 @@ export class UpdatesClient { } /** One file of a version. A Uint8Array body sets content-length, which the server's size check reads. */ + /** Raw bytes back, for documents such as host snapshots that are compared byte for byte. */ + async bytes(path: string): Promise { + const response = await this.#send("GET", path, undefined, undefined); + return new Uint8Array(await response.arrayBuffer()); + } + async upload(path: string, bytes: Uint8Array): Promise { + return this.putBytes(path, bytes); + } + + async putBytes(path: string, bytes: Uint8Array): Promise { const response = await this.#send("PUT", path, bytes, "application/octet-stream"); - return (await response.json()) as UploadResult; + return (await response.json()) as T; } async #send(method: string, path: string, body: string | Uint8Array | undefined, contentType: string | undefined): Promise { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6897ae4..8a3f5de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ export { build, RUNTIME_MODULES } from "./build.ts"; export type { BuildOptions, BuildResult, BuiltModule, Manifest } from "./build.ts"; -export { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; -export type { AppRecord, IssuedToken, PackageRecord, Releases } from "./admin.ts"; +export { createApp, createAppToken, createPackage, createToken, listReleases, movePointer, readHostSnapshot, revokeAppToken, revokeToken, rotatePublicKey, uploadHostSnapshot } from "./admin.ts"; +export type { AppRecord, HostSnapshotResult, IssuedAppToken, IssuedToken, PackageRecord, Releases } from "./admin.ts"; export { bundle, isHostVersion, isPathSegment } from "./bundle.ts"; export type { BundleOptions, BundleResult, Pointer } from "./bundle.ts"; export { ADMIN_TOKEN_ENV, DEFAULT_URL, requireSecureUrl, requireToken, resolveUrl, segments, TOKEN_ENV, UpdatesClient, UpdatesError, URL_ENV } from "./client.ts"; @@ -11,6 +11,8 @@ export type { TinyUIConfig } from "./config.ts"; export { generateKeyPair, publicKeyOf, isPublicKey, sign, verify } from "./keys.ts"; export type { KeyPair } from "./keys.ts"; export { publish } from "./publish.ts"; +export { parseHostSnapshot } from "./snapshot.ts"; +export type { HostSnapshot } from "./snapshot.ts"; export type { PublishOptions, PublishResult } from "./publish.ts"; export { compileModule, findQjsc } from "./qjsc.ts"; export { analyzePage, RequiresError } from "./requires.ts"; diff --git a/packages/cli/src/publish.ts b/packages/cli/src/publish.ts index f05131f..09e6e12 100644 --- a/packages/cli/src/publish.ts +++ b/packages/cli/src/publish.ts @@ -2,7 +2,10 @@ import { readFile, readdir, realpath } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import type { Manifest } from "./build.ts"; import { isHostVersion, isPathSegment, type Pointer } from "./bundle.ts"; -import { segments, type UpdatesClient, type UploadResult } from "./client.ts"; +import { readHostSnapshot } from "./admin.ts"; +import { segments, UpdatesError, type UpdatesClient, type UploadResult } from "./client.ts"; +import type { PageRequires } from "./requires.ts"; +import { parseHostSnapshot } from "./snapshot.ts"; export interface PublishOptions { client: UpdatesClient; @@ -46,6 +49,7 @@ export async function publish(options: PublishOptions): Promise { const pkg = manifest.name; const hostVersion = manifest.hostVersion; const version = manifest.version; + await checkHost(client, app, local.manifest); const prefix = segments(app, pkg, hostVersion, version); const files = [...new Set(Object.values(manifest.files).map((path) => `${path}.bin`)), "manifest.json"]; @@ -70,6 +74,40 @@ export async function publish(options: PublishOptions): Promise { }; } +/** + * Refuses a package the target host version cannot run: a page using a capability or host component the host does + * not provide, or built-ins of another tinyui. Such a page does not crash, so no rollback would catch it (docs/updates.md §1.3). + */ +async function checkHost(client: UpdatesClient, app: string, manifest: LocalBundle["manifest"]): Promise { + const target = `host version ${manifest.hostVersion} of ${app}`; + if (typeof manifest.tinyui !== "string" || typeof manifest.requires !== "object" || manifest.requires === null || Array.isArray(manifest.requires)) { + throw new Error(`${manifest.name} ${manifest.version} was built without "tinyui" / "requires" in its manifest; rebuild it with a current tinyui-cli`); + } + // a page with no entry would skip the check entirely, so the map has to cover every page, well formed + const names = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === "string"); + for (const page of manifest.pages) { + const needs = (manifest.requires as Record)[page] as Partial | undefined; + if (!needs || !names(needs.components) || !names(needs.capabilities)) { + throw new Error(`${manifest.name} ${manifest.version}: "requires" has no well-formed entry for page ${page}; rebuild it with a current tinyui-cli`); + } + } + const bytes = await readHostSnapshot(client, app, manifest.hostVersion).catch((e: unknown) => { + if (e instanceof UpdatesError && e.status === 404) throw new Error(`${target} has no snapshot, so nothing says what it provides; the host's CI uploads it with tinyui hosts upload`); + throw e; + }); + const host = parseHostSnapshot(new TextDecoder().decode(bytes)); + if (host.hostVersion !== manifest.hostVersion) throw new Error(`the snapshot stored for ${target} says hostVersion ${host.hostVersion}`); + const problems: string[] = []; + if (host.tinyui !== manifest.tinyui) problems.push(` built with tinyui ${manifest.tinyui}, the host runs tinyui ${host.tinyui}`); + for (const [page, needs] of Object.entries(manifest.requires as Record).sort(([a], [b]) => (a < b ? -1 : 1))) { + const components = needs.components.filter((c) => !host.components.has(c)); + const capabilities = needs.capabilities.filter((c) => !host.capabilities.has(c)); + if (components.length) problems.push(` ${page} uses host component${components.length > 1 ? "s" : ""} ${components.join(", ")}`); + if (capabilities.length) problems.push(` ${page} calls ${capabilities.join(", ")}`); + } + if (problems.length) throw new Error(`${manifest.name} ${manifest.version} cannot go to ${target}, which does not provide what it uses:\n${problems.join("\n")}`); +} + /** `` is either the `/` directory itself or the root `tinyui bundle --out` wrote it under. */ async function discover(dir: string): Promise { if (await exists(join(dir, "current.json"))) return dir; diff --git a/packages/cli/src/snapshot.ts b/packages/cli/src/snapshot.ts new file mode 100644 index 0000000..70d5c6d --- /dev/null +++ b/packages/cli/src/snapshot.ts @@ -0,0 +1,38 @@ +/** A host snapshot as `tinyui-host/.txt` holds it (docs/updates.md §6.4). */ +export interface HostSnapshot { + hostVersion: string; + tinyui: string; + components: Set; + capabilities: Set; +} + +/** + * Two header lines, then `components` and `capabilities` sections with one two-space-indented item per line. + * The first word of an item is its name; the rest is for people and for the host's own check, not for this parser. + */ +export function parseHostSnapshot(text: string): HostSnapshot { + const lines = text.split(/\r?\n/); + const header = (index: number, key: string) => { + const match = new RegExp(`^${key} (\\S+)$`).exec(lines[index] ?? ""); + if (!match) throw new Error(`host snapshot line ${index + 1} must be "${key} ", got "${lines[index] ?? ""}"`); + return match[1]!; + }; + const snapshot: HostSnapshot = { hostVersion: header(0, "hostVersion"), tinyui: header(1, "tinyui"), components: new Set(), capabilities: new Set() }; + // both sections, once each, components first: a missing one would read as "provides nothing" and a check would pass on it + const order = ["components", "capabilities"] as const; + let seen = 0; + let section: Set | undefined; + lines.slice(2).forEach((line, i) => { + if (line.trim() === "") return; + if (line === order[seen]) { + section = line === "components" ? snapshot.components : snapshot.capabilities; + seen++; + } else if (line.startsWith(" ") && section) { + section.add(line.trim().split(/\s+/)[0]!); + } else { + throw new Error(`host snapshot line ${i + 3}: expected ${seen < order.length ? `the "${order[seen]}" section` : "an item"}${section ? " or an indented item" : ""}, got "${line}"`); + } + }); + if (seen < order.length) throw new Error(`host snapshot has no "${order[seen]}" section; write it even when empty`); + return snapshot; +} diff --git a/packages/cli/test/admin.test.ts b/packages/cli/test/admin.test.ts index 144cb33..7b8b949 100644 --- a/packages/cli/test/admin.test.ts +++ b/packages/cli/test/admin.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; -import { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "../src/admin.ts"; +import { createApp, createAppToken, createPackage, createToken, listReleases, movePointer, revokeAppToken, revokeToken, rotatePublicKey, uploadHostSnapshot } from "../src/admin.ts"; import { ADMIN_TOKEN_ENV, DEFAULT_URL, requireToken, resolveUrl, segments, TOKEN_ENV, UpdatesClient, URL_ENV } from "../src/client.ts"; import { requireName } from "../src/config.ts"; import { startFakeServer, type FakeServer } from "./helpers.ts"; @@ -64,6 +64,24 @@ describe("management commands", () => { assert.deepEqual(last().body, { rollout: 50 }); }); + it("issues and revokes app tokens, and uploads a host snapshot once", async () => { + const issued = await createAppToken(client, "demo"); + assert.equal(issued.token, "app-token-shown-once"); + assert.deepEqual(last(), { method: "POST", path: "/apps/demo/tokens", auth: "Bearer admin-token", body: null }); + await revokeAppToken(client, "demo", "app_1"); + assert.equal(last().method, "DELETE"); + assert.equal(last().path, "/apps/demo/tokens/app_1"); + + const snapshot = new TextEncoder().encode("hostVersion 2\ncapabilities\n checkout.start\n"); + const first = await uploadHostSnapshot(client, "demo", "2", snapshot); + assert.equal(first.existing, false); + const request = server.requests.at(-1)!; + assert.equal(request.path, "/apps/demo/hosts/2"); + assert.deepEqual(new Uint8Array(request.body), snapshot); + assert.equal((await uploadHostSnapshot(client, "demo", "2", snapshot)).existing, true); + await assert.rejects(uploadHostSnapshot(client, "demo", "2", new TextEncoder().encode("other")), /409 a shipped host version is frozen/); + }); + it("encodes each segment and holds names to the rule the server enforces", () => { assert.equal(segments("demo", "a/b", "1"), "/demo/a%2Fb/1"); // page paths are ASCII by construction (tinyui build refuses the rest); encoding is the last line of defence diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index d3d92c6..7ed5055 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -25,7 +25,12 @@ export async function fakeDist(dir: string, publicKey: string, edit: (m: Manifes createdAt: "2026-09-22T09:00:00Z", engine: "a".repeat(40), protocol: 1, + tinyui: "0.3.0", hashes, + requires: { + "fixture/home": { components: ["ta.Icon"], capabilities: ["billing.prices"] }, + "fixture/orders": { components: [], capabilities: [] }, + }, }; edit(manifest); await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); @@ -58,6 +63,7 @@ export async function startFakeServer(): Promise { const requests: RecordedRequest[] = []; const queued = new Map(); const uploads = new Map(); + const hosts = new Map(); const server = createServer((req: IncomingMessage, res: ServerResponse) => { const chunks: Buffer[] = []; @@ -84,6 +90,7 @@ export async function startFakeServer(): Promise { return send(forced.status, forced.body ?? { error: "forced by the test" }); } if (!/^Bearer .+$/.test(req.headers.authorization ?? "")) return send(401, { error: "a token is required" }); + if (path.split("/")[3] === "hosts") return hostSnapshot(req.method ?? "", path, body, hosts, res); return send(...defaultReply(req.method ?? "", path, body, uploads)); } catch (e) { // a hang here would look like a client bug, so every failure has to come back as a response @@ -118,6 +125,8 @@ function defaultReply(method: string, path: string, body: Buffer, uploads: Map/hosts/`: written once, read back byte for byte, 404 until written. */ +function hostSnapshot(method: string, path: string, body: Buffer, hosts: Map, res: ServerResponse) { + const stored = hosts.get(path); + if (method === "GET") { + if (!stored) return res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "has no snapshot" })); + return res.writeHead(200, { "content-type": "text/plain" }).end(stored); + } + const sha256 = createHash("sha256").update(body).digest("hex"); + if (stored && !stored.equals(body)) return res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "a shipped host version is frozen" })); + if (!stored) hosts.set(path, body); + res.writeHead(stored ? 200 : 201, { "content-type": "application/json" }).end(JSON.stringify({ hostVersion: path.split("/")[4], sha256, existing: !!stored })); +} + /** Uploads carry bytecode, not JSON; only the pointer and the management bodies are objects. */ function asJson(body: Buffer): Record { try { diff --git a/packages/cli/test/publish.test.ts b/packages/cli/test/publish.test.ts index 5da9cc9..c57671a 100644 --- a/packages/cli/test/publish.test.ts +++ b/packages/cli/test/publish.test.ts @@ -6,11 +6,13 @@ import { after, before, describe, it } from "node:test"; import { bundle } from "../src/bundle.ts"; import { UpdatesClient, UpdatesError } from "../src/client.ts"; import { generateKeyPair } from "../src/keys.ts"; +import { uploadHostSnapshot } from "../src/admin.ts"; import { publish } from "../src/publish.ts"; import { fakeDist, startFakeServer, type FakeServer } from "./helpers.ts"; const pair = generateKeyPair(); const VERSION = "20260922T090000Z-3f2a1c"; +const HOST_1 = "hostVersion 1\ntinyui 0.3.0\n\ncomponents\n ta.Icon name: string\n\ncapabilities\n billing.prices\n"; const FILES = ["runtime/core.bin", "runtime/native.bin", "pages/home.bin", "pages/orders.bin", "manifest.json"]; describe("tinyui publish", () => { @@ -25,6 +27,8 @@ describe("tinyui publish", () => { await writeFile(signingKey, pair.privateKeyPem); server = await startFakeServer(); client = new UpdatesClient({ url: server.url, token: "publish-token" }); + // what host version 1 of demo provides: exactly what the fixture's pages use + await uploadHostSnapshot(client, "demo", "1", new TextEncoder().encode(HOST_1)); }); after(async () => { await server.close(); @@ -53,8 +57,10 @@ describe("tinyui publish", () => { assert.equal(result.existing, 0); const sent = requestsSince(mark); + // the host check reads the target's snapshot before any upload + assert.equal(sent[0], "GET /apps/demo/hosts/1"); assert.deepEqual( - sent.slice(0, -1).sort(), + sent.slice(1, -1).sort(), FILES.map((f) => `PUT /demo/fixture/1/${VERSION}/${f}`).sort(), ); assert.equal(sent.at(-1), "PUT /demo/staging/fixture/1/current.json"); @@ -126,7 +132,7 @@ describe("tinyui publish", () => { const mark = server.requests.length; const result = await publish({ client, dir: renamed, app: "demo", channel: "staging" }); assert.equal(result.pkg, "fixture"); - assert.ok(requestsSince(mark).every((r) => r.includes("/demo/fixture/1/") || r === "PUT /demo/staging/fixture/1/current.json")); + assert.ok(requestsSince(mark).every((r) => r.includes("/demo/fixture/1/") || r === "PUT /demo/staging/fixture/1/current.json" || r === "GET /apps/demo/hosts/1")); }); it("checks the pointer against the manifest locally, before uploading anything", async () => { @@ -183,6 +189,31 @@ describe("tinyui publish", () => { assert.deepEqual(requestsSince(before), []); }); + it("checks the package against what the target host version provides before uploading anything", async () => { + const mark = server.requests.length; + const refuse = async (label: string, edit: Parameters[2], message: RegExp) => { + const dir = await bundled(label, edit); + await assert.rejects(publish({ client, dir, app: "demo", channel: "staging" }), message); + }; + await refuse("coupon", (m) => { m.requires["fixture/home"]!.capabilities.push("coupon.apply"); }, + /cannot go to host version 1 of demo[\s\S]*fixture\/home calls coupon\.apply/); + await refuse("badge", (m) => { m.requires["fixture/orders"]!.components.push("ta.Badge"); }, + /fixture\/orders uses host component ta\.Badge/); + await refuse("newer-tinyui", (m) => { m.tinyui = "0.4.0"; }, /built with tinyui 0\.4\.0, the host runs tinyui 0\.3\.0/); + await refuse("old-manifest", (m) => { delete (m as Partial).requires; }, /rebuild it with a current tinyui-cli/); + // a page left out of requires would skip the check: every page needs a well-formed entry + await refuse("page-missing", (m) => { delete m.requires["fixture/orders"]; }, /no well-formed entry for page fixture\/orders/); + await refuse("array", (m) => { (m as { requires: unknown }).requires = []; }, /rebuild it with a current tinyui-cli/); + await refuse("malformed", (m) => { (m.requires["fixture/home"] as { capabilities: unknown }).capabilities = "billing.prices"; }, /no well-formed entry for page fixture\/home/); + // nothing but the snapshot reads went out + assert.ok(server.requests.slice(mark).every((r) => r.method === "GET" && r.path === "/apps/demo/hosts/1")); + }); + + it("refuses a host version nobody uploaded a snapshot for", async () => { + const dir = await bundled("no-snapshot"); + await assert.rejects(publish({ client, dir, app: "other", channel: "staging" }), /host version 1 of other has no snapshot[\s\S]*tinyui hosts upload/); + }); + it("passes the server's own words through", async () => { const dir = await bundled("rejected"); server.reply(`/demo/staging/fixture/1/current.json`, { status: 409, body: { error: "manifest name fixture is not the path package other" } }); diff --git a/packages/cli/test/requires.test.ts b/packages/cli/test/requires.test.ts index b3f5891..5d50487 100644 --- a/packages/cli/test/requires.test.ts +++ b/packages/cli/test/requires.test.ts @@ -27,6 +27,8 @@ describe("host requirements of a page", () => { it("finds them in a real build and writes them into the manifest per page", async () => { const result = await build({ root: fixture, out: join(out, "dist"), jsOnly: true }); const manifest = JSON.parse(await readFile(result.manifest, "utf8")) as Manifest; + const core = JSON.parse(await readFile(join(import.meta.dirname, "..", "..", "core", "package.json"), "utf8")) as { version: string }; + assert.equal(manifest.tinyui, core.version, "the tinyui-core the runtime modules came from"); assert.deepEqual(manifest.requires, { "shop/plain": { components: [], capabilities: [] }, // ta.Badge through the local PlanCard, ta.Loading through a hand-written h() choosing between two; diff --git a/packages/cli/test/snapshot.test.ts b/packages/cli/test/snapshot.test.ts new file mode 100644 index 0000000..76b0918 --- /dev/null +++ b/packages/cli/test/snapshot.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseHostSnapshot } from "../src/snapshot.ts"; + +describe("host snapshot", () => { + it("reads the header and the names, leaving the rest of each line to people", () => { + const snapshot = parseHostSnapshot("hostVersion 2\r\ntinyui 0.3.0\r\n\r\ncomponents\r\n ta.Icon name: string, size?: dp\r\n ta.Loading\r\n\r\ncapabilities\r\n checkout.start\r\n plain\r\n"); + assert.equal(snapshot.hostVersion, "2"); + assert.equal(snapshot.tinyui, "0.3.0"); + assert.deepEqual([...snapshot.components], ["ta.Icon", "ta.Loading"]); + assert.deepEqual([...snapshot.capabilities], ["checkout.start", "plain"]); + const empty = parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n\ncomponents\n\ncapabilities\n"); + assert.equal(empty.components.size + empty.capabilities.size, 0); + }); + + it("refuses anything it would have to guess at", () => { + assert.throws(() => parseHostSnapshot("tinyui 0.3.0\nhostVersion 1\n"), /line 1 must be "hostVersion "/); + assert.throws(() => parseHostSnapshot("hostVersion 1\n"), /line 2 must be "tinyui "/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n ta.Icon\n"), /line 3: expected the "components" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\nwidgets\n ta.Icon\n"), /line 3: expected the "components" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\nplain\n"), /line 5: expected an item/); + // both sections, once each, in order: a missing one would read as "provides nothing" + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n"), /no "components" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\n ta.Icon\n"), /no "capabilities" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncapabilities\ncomponents\n"), /line 3: expected the "components" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\ncomponents\n"), /line 5: expected an item/); + }); +});