Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/build-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ tinyui-native 同上(对它而言 tinyui-core 是 external)
| esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`inject` 一个内存模块自动引入 `h` / `Fragment`,页面不手写);业务内部相对 import 合并进页面模块;`tinyui-core` / `tinyui-native` 在插件 `onResolve` 里标 `external` + `sideEffects: false`,保持裸说明符且未用到时整条 import 被去掉;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对运行时模块的解析 |
| qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) |

**页面名即模块名即路由键**:`<pkg>/<src/pages 下的相对路径>`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages" }`,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。运行时模块名固定 `tinyui-core`、`tinyui-native`,产物文件是 `runtime/core`、`runtime/native`。产物目录 `runtime/*.bin`、`pages/**/*.bin`、`manifest.json`(页面与运行时模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `protocol` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。
**页面名即模块名即路由键**:`<pkg>/<src/pages 下的相对路径>`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages" }`,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,产物文件是 `runtime/core`、`runtime/native`。产物目录 `runtime/*.bin`、`pages/**/*.bin`、`manifest.json`(页面与运行时模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `protocol` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。

业务工程的 tsconfig 用 `jsx: "react-jsx"` + `jsxImportSource: "tinyui-core"` 做类型检查([jsx-transform.md](./jsx-transform.md) §4),而 esbuild 会读到这份 tsconfig 并按它产出 `import { jsx } from "tinyui-core/jsx-runtime"`,即使 `build()` 显式传了 `jsx: "transform"`;引擎里没有这个模块,加载报 `module 'tinyui-core/jsx-runtime' is not registered`(2026-09-16 CI 实测)。页面构建要传 `tsconfigRaw` 覆盖 tsconfig 的 jsx 三项。

Expand Down
4 changes: 2 additions & 2 deletions docs/updates-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ M1 之后 M2 → M3 与 M4 并行;M6 要等 M3 与 M5。

仓:tinyui,`packages/cli`。约 200 行。

`tinyui publish` / `apps create` / `packages create` / `tokens create|revoke` / `releases list|rollback|rollout|promote`,全是对 M4 端点的薄封装;`--url` 缺省 `https://updates.tinyui.app`。测试对着内存适配器起的本地服务跑。
`tinyui publish` / `apps create` / `packages create` / `tokens create|revoke` / `releases list|rollback|rollout|promote`,全是对 M4 端点的薄封装;`--url` 缺省 `https://updates.tinyui.app`。凭据只从 `$TINYUI_TOKEN` / `$TINYUI_ADMIN_TOKEN` 读,不给 `--token`;`--app` 可用 `$TINYUI_APP`,`--channel` 必须显式。测试对着本仓 `node:http` 起的薄 fake 跑(只回应协议的形状,不复刻验签与 sha256 校验——那些归服务端仓的测试),端到端另跑一次真服务端。

## M6 · TrendingAI 接入

Expand All @@ -100,6 +100,6 @@ M1 之后 M2 → M3 与 M4 并行;M6 要等 M3 与 M5。
| M2 core `Bundle` | 已完成(2026-09-22):`BuildManifest` 解析 §1.1 全部字段(缺 `name` / `publicKey` 即失败),`BundleFiles` / `Bundle` / `LoadedPage`,`SourceMaps.isEmpty`;sample 资源目录改 `files/tinyui/sample/`,Android host 冒烟测试改走 `Bundle` |
| M3 `updates/` 模块 | 已完成(2026-09-22):`Updates` 状态机与启动选择(含 sha256 重算、原子 state.json)、两端 ECDSA 验签(Android `java.security`、iOS `Security.framework`)、`UpdatesPage` E2 / E6 回退;sample 加第二个包 `sample-extra`,Android 模拟器从本机 `http.server` 装包、重启生效、坏包回退并 `RolledBack` 全部验过 |
| M4 服务 MVP | 已完成(tiny-ui/tinyui-updates-server PR #1,2026-09-22):Hono + 单 R2 桶,投递 / 发布 / release / 管理四组端点,Workers 运行时一致性测试 12 条;已部署 `updates.tinyui.app`(R2 桶 `tinyui-updates`,`ADMIN_TOKEN` 存 HarlonWang/secrets 的 `tinyui-updates/admin-token.txt`),Cloudflare Workers Builds 已连 tiny-ui/tinyui-updates-server 的 main,push 即部署(`wrangler deploy --config wrangler.tinyui.toml`) |
| M5 CLI 发布与管理 | 待开 |
| M5 CLI 发布与管理 | 已完成(2026-09-22):`publish`(发现 bundle 目录、并发上传、指针最后)、`apps` / `packages`(含 `rotate-key`)/ `tokens` / `releases` 四组命令;凭据只走环境变量,`--app` / `--channel` / `--pkg` 按名字规则校验;CLI 侧契约测试 15 条,另对本地 `wrangler dev` 起的真服务端跑通发布 / 幂等重发 / 晋级 / 改灰度 / 回滚 / 跨 channel 被拒 / 吊销即时生效 |
| M6 TrendingAI 接入 | 待开 |
| M7 第二个 App | 待开;前置 `qjsc-kmp` 平台包待开 |
4 changes: 2 additions & 2 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,13 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段

顺序由 CLI 保证:内容先、指针后。指针请求在内容不齐时拒绝,所以乱序不会产生半个包。

`tinyui publish --url <实例> --app <app> --channel <c> --token <t> [--dir dist/ota]`:读 §1.2 的目录(包名从 manifest 来),先 PUT `<version>/` 下全部文件含 `manifest.json`(已存在的跳过),再 PUT `current.json`。
`tinyui publish --channel <c> [--app <app>] [--dir dist/ota]`:读 §1.2 的目录(包名从 manifest 来),先 PUT `<version>/` 下全部文件含 `manifest.json`(服务端幂等,同字节即已存在),再 PUT `current.json`。token 只从 `$TINYUI_TOKEN` 读,管理端点从 `$TINYUI_ADMIN_TOKEN` 读,实例地址取 `--url` / `$TINYUI_UPDATES_URL` / 托管实例。

### 6.2 管理 release

| 请求 | 语义 |
|---|---|
| `GET /<app>/<pkg>/<rv>/releases` | 已发布的 version 列表:`createdAt`、各 channel 的指针与 `rollout` |
| `GET /<app>/<pkg>/<rv>/releases` | 已发布的 version 列表:`createdAt`(包的构建时刻,来自 manifest)、`publishedAt`(服务端记下的发布时刻)、各 channel 的指针与 `rollout` |
| `POST /<app>/<channel>/<pkg>/<rv>/pointer`,body `{ "version": …, "rollout"?: … }` | 指针指向 (app, pkg, rv) 下任一已发布 version——回滚与跨 channel 晋级是同一个操作;或只改当前指针的 `rollout`。服务端用记录里该 version 的 `signature` 重写 `current.json` |

服务端按 version 保存 release 记录(`createdAt`、`signature`),指针切换不需要重新上传;改 `rollout` 只改指针,签名不受影响(§7)。CLI:`tinyui releases list` / `rollback <version>` / `rollout <p>` / `promote <version> --to <channel>`。
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +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` 必须每次显式写。

字节码编译需要 quickjs-kmp 的宿主工具 `qjsc-kmp`(`--qjsc` 或 `TINYUI_QJSC`)。用法见仓库 `docs/build-chain.md` 与 `docs/updates.md`;与 `tinyui-core` 同版本号。
64 changes: 64 additions & 0 deletions packages/cli/src/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Pointer } from "./bundle.ts";
import { segments, type UpdatesClient } from "./client.ts";

// One function per endpoint of docs/updates.md §6.2 and §6.3; the CLI adds nothing but argument parsing.

export interface AppRecord {
id: string;
name: string;
org?: string;
createdAt: string;
}

export interface PackageRecord {
name: string;
publicKey: string;
createdAt: string;
publicKeyUpdatedAt?: string;
}

/** The only time the token itself is readable; the server keeps its sha256. */
export interface IssuedToken {
id: string;
token: string;
channels: string[];
createdAt: string;
}

export interface Releases {
versions: { version: string; createdAt: string; publishedAt?: string }[];
channels: Record<string, { version: string; rollout: number }>;
}

export function createApp(client: UpdatesClient, body: { id: string; name: string; org?: string }): Promise<AppRecord> {
return client.json<AppRecord>("POST", "/apps", body);
}

export function createPackage(client: UpdatesClient, app: string, body: { name: string; publicKey: string }): Promise<PackageRecord> {
return client.json<PackageRecord>("POST", segments("apps", app, "packages"), body);
}

export function rotatePublicKey(client: UpdatesClient, app: string, pkg: string, publicKey: string): Promise<PackageRecord> {
return client.json<PackageRecord>("PUT", `${segments("apps", app, "packages", pkg)}/publicKey`, { publicKey });
}

export function createToken(client: UpdatesClient, app: string, pkg: string, channels: string[]): Promise<IssuedToken> {
return client.json<IssuedToken>("POST", segments("apps", app, "packages", pkg, "tokens"), { channels });
}

export function revokeToken(client: UpdatesClient, app: string, pkg: string, tokenId: string): Promise<void> {
return client.empty("DELETE", segments("apps", app, "packages", pkg, "tokens", tokenId));
}

export function listReleases(client: UpdatesClient, app: string, pkg: string, runtimeVersion: string): Promise<Releases> {
return client.json<Releases>("GET", segments(app, pkg, runtimeVersion, "releases"));
}

/** Rollback, promotion between channels and a rollout change are one endpoint (docs/updates.md §6.2). */
export function movePointer(
client: UpdatesClient,
target: { app: string; channel: string; pkg: string; runtimeVersion: string },
body: { version?: string; rollout?: number },
): Promise<Pointer> {
return client.json<Pointer>("POST", segments(target.app, target.channel, target.pkg, target.runtimeVersion, "pointer"), body);
}
Loading
Loading