diff --git a/docs/build-chain.md b/docs/build-chain.md index 5d049d3..7a3fbe8 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -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` 保留行号去源码 | 校验模块图(引擎加载时查表) | -**页面名即模块名即路由键**:`/`——包 `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))。 +**页面名即模块名即路由键**:`/`——包 `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 三项。 diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 7deb676..181b663 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -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 接入 @@ -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` 平台包待开 | diff --git a/docs/updates.md b/docs/updates.md index 2c640cd..5b0ee70 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -246,13 +246,13 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 顺序由 CLI 保证:内容先、指针后。指针请求在内容不齐时拒绝,所以乱序不会产生半个包。 -`tinyui publish --url <实例> --app --channel --token [--dir dist/ota]`:读 §1.2 的目录(包名从 manifest 来),先 PUT `/` 下全部文件含 `manifest.json`(已存在的跳过),再 PUT `current.json`。 +`tinyui publish --channel [--app ] [--dir dist/ota]`:读 §1.2 的目录(包名从 manifest 来),先 PUT `/` 下全部文件含 `manifest.json`(服务端幂等,同字节即已存在),再 PUT `current.json`。token 只从 `$TINYUI_TOKEN` 读,管理端点从 `$TINYUI_ADMIN_TOKEN` 读,实例地址取 `--url` / `$TINYUI_UPDATES_URL` / 托管实例。 ### 6.2 管理 release | 请求 | 语义 | |---|---| -| `GET ////releases` | 已发布的 version 列表:`createdAt`、各 channel 的指针与 `rollout` | +| `GET ////releases` | 已发布的 version 列表:`createdAt`(包的构建时刻,来自 manifest)、`publishedAt`(服务端记下的发布时刻)、各 channel 的指针与 `rollout` | | `POST /////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 ` / `rollout

` / `promote --to `。 diff --git a/packages/cli/README.md b/packages/cli/README.md index 96d49d0..32f6d9e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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` 同版本号。 diff --git a/packages/cli/src/admin.ts b/packages/cli/src/admin.ts new file mode 100644 index 0000000..a03e884 --- /dev/null +++ b/packages/cli/src/admin.ts @@ -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; +} + +export function createApp(client: UpdatesClient, body: { id: string; name: string; org?: string }): Promise { + return client.json("POST", "/apps", body); +} + +export function createPackage(client: UpdatesClient, app: string, body: { name: string; publicKey: string }): Promise { + return client.json("POST", segments("apps", app, "packages"), body); +} + +export function rotatePublicKey(client: UpdatesClient, app: string, pkg: string, publicKey: string): Promise { + return client.json("PUT", `${segments("apps", app, "packages", pkg)}/publicKey`, { publicKey }); +} + +export function createToken(client: UpdatesClient, app: string, pkg: string, channels: string[]): Promise { + return client.json("POST", segments("apps", app, "packages", pkg, "tokens"), { channels }); +} + +export function revokeToken(client: UpdatesClient, app: string, pkg: string, tokenId: string): Promise { + return client.empty("DELETE", segments("apps", app, "packages", pkg, "tokens", tokenId)); +} + +export function listReleases(client: UpdatesClient, app: string, pkg: string, runtimeVersion: string): Promise { + return client.json("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 { + return client.json("POST", segments(target.app, target.channel, target.pkg, target.runtimeVersion, "pointer"), body); +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e0032ec..5fd96f8 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -2,9 +2,13 @@ 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 { build } from "./build.ts"; -import { bundle } from "./bundle.ts"; +import { bundle, 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 { generateKt, generateTs } from "./schema/generate.ts"; import { loadSchema } from "./schema/load.ts"; @@ -12,6 +16,16 @@ const USAGE = `usage: tinyui build [--root

] [--out ] [--qjsc ] tinyui bundle --runtime-version --signing-key [--dist ] [--rollout <0-100>] [--out ] tinyui keys generate [--out ] tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] + tinyui publish --channel [--app ] [--dir ] [--rollout <0-100>] + tinyui apps create --name [--org ] + tinyui packages create --public-key [--app ] + tinyui packages rotate-key --public-key [--app ] + tinyui tokens create --channels [--app ] + tinyui tokens revoke --id [--app ] + tinyui releases list --runtime-version [--pkg

] [--app ] [--json] + tinyui releases rollback --runtime-version --channel [--pkg

] [--app ] + tinyui releases rollout <0-100> --runtime-version --channel [--pkg

] [--app ] + tinyui releases promote --runtime-version --to [--pkg

] [--app ] build compile the pages of the package described by /tinyui.config.json --root project root (default: cwd) @@ -36,11 +50,26 @@ schema --kt write Kotlin schemas here (with --package, optional --object, default BuiltinSchemas) --node-import where the generated TS imports Node / Ref from (default tinyui-core) --check exit 1 if a target is not already up to date, write nothing + +publish upload a bundle and move the channel pointer (docs/updates.md §6.1) + --channel the channel to publish to; never defaulted, never read from the environment + --dir a tinyui bundle output root or one / inside it (default: ./dist/ota) + --rollout overrides the percentage current.json was bundled with + +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 + $TINYUI_APP default for --app + --pkg defaults to "name" in ./tinyui.config.json `; type Options = NonNullable; +type Values = Record; + +const URL_OPTION: Options = { url: { type: "string" } }; +const RELEASE_OPTIONS: Options = { ...URL_OPTION, app: { type: "string" }, pkg: { type: "string" }, "runtime-version": { type: "string" }, channel: { type: "string" }, to: { type: "string" }, json: { type: "boolean", default: false } }; -const COMMANDS: Record, positionals: string[]) => Promise }> = { +const COMMANDS: Record Promise }> = { build: { options: { root: { type: "string" }, out: { type: "string" }, qjsc: { type: "string" }, "js-only": { type: "boolean", default: false }, version: { type: "string" } }, run: async (v) => { @@ -66,7 +95,7 @@ const COMMANDS: Record ${result.dir}\n`); @@ -99,8 +128,166 @@ const COMMANDS: Record schema(v as Parameters[0]), }, + publish: { + options: { ...URL_OPTION, app: { type: "string" }, channel: { type: "string" }, dir: { type: "string" }, rollout: { type: "string" } }, + run: async (v) => { + const client = publishClient(v); + const result = await publish({ + client, + app: await appId(v), + // a mistyped channel is the one command that reaches every user at once + channel: requireName("--channel", required(v["channel"], "publish: --channel is required")), + dir: (v["dir"] as string | undefined) ?? resolve("dist", "ota"), + ...(v["rollout"] !== undefined && { rollout: percentage(v["rollout"] as string, "publish: --rollout") }), + onUpload: (u) => process.stderr.write(`${u.existing ? "exists " : "uploaded"} ${u.path}\n`), + }); + const { pkg, runtimeVersion, version, pointer } = result; + process.stderr.write(`published ${pkg}/${runtimeVersion}/${version} to ${v["channel"] as string} at rollout ${pointer.rollout} (${result.uploaded} uploaded, ${result.existing} already there)\n`); + process.stdout.write(`${version}\n`); + return 0; + }, + }, + apps: { + options: { ...URL_OPTION, name: { type: "string" }, org: { type: "string" } }, + run: async (v, positionals) => { + if (positionals[1] !== "create") throw new Error("apps: expected `apps create `"); + const id = required(positionals[2], "apps create: an app id is required"); + const record = await createApp(adminClient(v), { + id: requireName("an app id", id), + name: required(v["name"], "apps create: --name is required"), + ...(v["org"] !== undefined && { org: v["org"] as string }), + }); + process.stderr.write(`app ${record.id} created\n`); + process.stdout.write(`${record.id}\n`); + return 0; + }, + }, + packages: { + options: { ...URL_OPTION, app: { type: "string" }, "public-key": { type: "string" } }, + run: async (v, positionals) => { + const action = positionals[1]; + if (action !== "create" && action !== "rotate-key") throw new Error("packages: expected `packages create ` or `packages rotate-key `"); + const client = adminClient(v); + const app = await appId(v); + const name = await packageName(v, positionals[2]); + const publicKey = required(v["public-key"], `packages ${action}: --public-key is required (tinyui keys generate prints one)`); + const record = action === "create" ? await createPackage(client, app, { name, publicKey }) : await rotatePublicKey(client, app, name, publicKey); + process.stderr.write(`package ${app}/${record.name} ${action === "create" ? "registered" : "now verifies with the new key"}\n`); + process.stdout.write(`${record.name}\n`); + return 0; + }, + }, + tokens: { + options: { ...URL_OPTION, app: { type: "string" }, channels: { type: "string" }, id: { type: "string" } }, + run: async (v, positionals) => { + const action = positionals[1]; + if (action !== "create" && action !== "revoke") throw new Error("tokens: expected `tokens create ` or `tokens revoke `"); + const client = adminClient(v); + const app = await appId(v); + const pkg = await packageName(v, positionals[2]); + if (action === "revoke") { + const id = pathSegment(required(v["id"], "tokens revoke: --id is required (tinyui releases list does not show it; keep the id from tokens create)"), "--id"); + await revokeToken(client, app, pkg, id); + process.stderr.write(`token ${id} revoked\n`); + return 0; + } + const channels = required(v["channels"], "tokens create: --channels is required, e.g. --channels staging,production") + .split(",") + .map((c) => c.trim()) + .filter((c) => c !== "") + .map((c) => requireName("a channel", c)); + if (channels.length === 0) throw new Error("tokens create: --channels is empty"); + const issued = await createToken(client, app, pkg, channels); + process.stderr.write(`token ${issued.id} for ${app}/${pkg} on ${issued.channels.join(", ")}; it is shown once\n`); + process.stdout.write(`${issued.token}\n`); + return 0; + }, + }, + releases: { + options: RELEASE_OPTIONS, + run: async (v, positionals) => releases(v, positionals), + }, }; +async function releases(v: Values, positionals: string[]): Promise { + const action = positionals[1]; + const client = publishClient(v); + const app = await appId(v); + const pkg = await packageName(v, undefined); + const runtimeVersion = pathSegment(required(v["runtime-version"], `releases ${action ?? ""}: --runtime-version is required`), "--runtime-version"); + if (action === "list") { + const found = await listReleases(client, app, pkg, runtimeVersion); + if (v["json"] as boolean) { + process.stdout.write(`${JSON.stringify(found, null, 2)}\n`); + return 0; + } + const at = (version: string) => + Object.entries(found.channels) + .filter(([, pointer]) => pointer.version === version) + .map(([channel, pointer]) => `${channel}(${pointer.rollout})`) + .sort() + .join(" "); + const width = Math.max(7, ...found.versions.map((r) => r.version.length)); + process.stdout.write(`${"VERSION".padEnd(width)} ${"PUBLISHED".padEnd(24)} CHANNELS\n`); + for (const r of found.versions) process.stdout.write(`${r.version.padEnd(width)} ${(r.publishedAt ?? r.createdAt ?? "").padEnd(24)} ${at(r.version)}\n`); + return 0; + } + const target = { app, pkg, runtimeVersion, channel: "" }; + let body: { version?: string; rollout?: number }; + if (action === "rollback" || action === "promote") { + const version = pathSegment(required(positionals[2], `releases ${action}: a version is required`), "a version"); + target.channel = requireName("the channel", required(action === "promote" ? v["to"] : v["channel"], `releases ${action}: ${action === "promote" ? "--to" : "--channel"} is required`)); + body = { version }; + } else if (action === "rollout") { + target.channel = requireName("--channel", required(v["channel"], "releases rollout: --channel is required")); + body = { rollout: percentage(required(positionals[2], "releases rollout: a percentage is required"), "releases rollout") }; + } else { + throw new Error("releases: expected list, rollback, rollout or promote"); + } + const pointer = await movePointer(client, target, body); + process.stderr.write(`${app}/${pkg}/${runtimeVersion} ${target.channel} -> ${pointer.version} at rollout ${pointer.rollout}\n`); + process.stdout.write(`${pointer.version}\n`); + return 0; +} + +function publishClient(v: Values): UpdatesClient { + return new UpdatesClient({ url: resolveUrl(v["url"] as string | undefined), token: requireToken(TOKEN_ENV) }); +} + +function adminClient(v: Values): UpdatesClient { + return new UpdatesClient({ url: resolveUrl(v["url"] as string | undefined), token: requireToken(ADMIN_TOKEN_ENV) }); +} + +async function appId(v: Values): Promise { + return requireName("--app", required((v["app"] as string | undefined) ?? process.env["TINYUI_APP"], "--app is required, or set $TINYUI_APP")); +} + +/** The package is the one being built here unless another is named: `name` in tinyui.config.json is its identity. */ +async function packageName(v: Values, positional: string | undefined): Promise { + const explicit = positional ?? (v["pkg"] as string | undefined); + if (explicit !== undefined) return requireName("a package name", explicit); + const config = await loadConfig(process.cwd()).catch(() => null); + if (config) return config.name; + return required(undefined, "--pkg is required here; there is no tinyui.config.json in the current directory to take it from"); +} + +function pathSegment(value: string, what: string): string { + if (!isPathSegment(value)) throw new Error(`${what} must be a single path segment matching [A-Za-z0-9._-]+, got "${value}"`); + return value; +} + +function percentage(value: string, what: string): number { + // Number("") and Number(" ") are both 0, which would silently pause a rollout + const parsed = value.trim() === "" ? NaN : Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) throw new Error(`${what} must be an integer from 0 to 100, got "${value}"`); + return parsed; +} + +function required(value: unknown, message: string): string { + if (typeof value !== "string" || value === "") throw new Error(message); + return value; +} + async function main(argv: string[]): Promise { const command = COMMANDS[argv[0] ?? ""]; if (!command || argv.includes("--help") || argv.includes("-h")) { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 7fad97d..3f8c6da 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -126,7 +126,13 @@ async function discoverPages(pkg: string, pagesDir: string): Promise { for (const [module, path] of Object.entries(manifest.files)) { const source = resolve(dist, path + ".bin"); if (!within(dist, source)) throw new Error(`manifest.files["${module}"] points outside ${dist}: ${path}`); + // an output path is a URL path once the package is served; tinyui build refuses these at the source + if (!path.split("/").every(isPathSegment)) throw new Error(`manifest.files["${module}"] = "${path}": every segment must match [A-Za-z0-9._-]+ to survive the delivery URL`); const expected = manifest.hashes[module]; const actual = createHash("sha256").update(await readFile(source)).digest("hex"); if (actual !== expected) throw new Error(`${source} does not match manifest.hashes (${actual} vs ${expected}); rerun tinyui build`); diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts new file mode 100644 index 0000000..7d4cfb9 --- /dev/null +++ b/packages/cli/src/client.ts @@ -0,0 +1,124 @@ +// Thin client over the publishing and management endpoints of docs/updates.md §6. + +/** The hosted instance; `--url` or `TINYUI_UPDATES_URL` point elsewhere for a self-hosted one. */ +export const DEFAULT_URL = "https://updates.tinyui.app"; + +export const URL_ENV = "TINYUI_UPDATES_URL"; +export const TOKEN_ENV = "TINYUI_TOKEN"; +export const ADMIN_TOKEN_ENV = "TINYUI_ADMIN_TOKEN"; + +export interface ClientOptions { + url: string; + token: string; + /** Injected by the tests; defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch; +} + +export interface UploadResult { + path: string; + sha256: string; + /** The server already had these exact bytes: a version is immutable, re-publishing is free. */ + existing: boolean; +} + +export class UpdatesError extends Error { + readonly status: number; + readonly method: string; + readonly path: string; + + constructor(status: number, method: string, path: string, detail: string) { + super(`${method} ${path}: ${status} ${detail}`); + this.status = status; + this.method = method; + this.path = path; + } +} + +/** Loopback is the one place a token may travel without TLS: `wrangler dev` has no certificate. */ +const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +export class UpdatesClient { + readonly url: string; + readonly #token: string; + readonly #fetch: typeof globalThis.fetch; + + constructor(options: ClientOptions) { + this.url = requireSecureUrl(options.url).replace(/\/+$/, ""); + this.#token = options.token; + this.#fetch = options.fetch ?? globalThis.fetch; + } + + async json(method: string, path: string, body?: unknown): Promise { + const response = await this.#send(method, path, body === undefined ? undefined : JSON.stringify(body), "application/json"); + return (await response.json()) as T; + } + + async empty(method: string, path: string): Promise { + await this.#send(method, path, undefined, undefined); + } + + /** One file of a version. A Uint8Array body sets content-length, which the server's size check reads. */ + async upload(path: string, bytes: Uint8Array): Promise { + const response = await this.#send("PUT", path, bytes, "application/octet-stream"); + return (await response.json()) as UploadResult; + } + + async #send(method: string, path: string, body: string | Uint8Array | undefined, contentType: string | undefined): Promise { + const response = await this.#fetch(`${this.url}${path}`, { + method, + headers: { + authorization: `Bearer ${this.#token}`, + ...(contentType !== undefined && { "content-type": contentType }), + }, + ...(body !== undefined && { body }), + }); + if (!response.ok) throw new UpdatesError(response.status, method, path, await detail(response)); + return response; + } +} + +export function resolveUrl(explicit: string | undefined): string { + return requireSecureUrl(explicit ?? process.env[URL_ENV] ?? DEFAULT_URL); +} + +export function requireSecureUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`not an instance url: ${url}`); + } + if (parsed.protocol === "https:" || (parsed.protocol === "http:" && LOOPBACK.has(parsed.hostname))) return url; + throw new Error(`${url} would carry the token in cleartext; use https, or http on localhost only`); +} + +/** Credentials never come from the command line: they would land in shell history and in `ps` output. */ +export function requireToken(variable: typeof TOKEN_ENV | typeof ADMIN_TOKEN_ENV): string { + const token = process.env[variable]; + if (!token) throw new Error(`${variable} is not set; this command needs it (tinyui tokens create issues publish tokens)`); + return token; +} + +async function detail(response: Response): Promise { + const text = await response.text().catch(() => ""); + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && typeof (parsed as { error?: unknown }).error === "string") { + return (parsed as { error: string }).error; + } + } catch { + // not JSON: the body itself is the best detail there is + } + return text === "" ? response.statusText : text; +} + +/** + * A request path from raw values. Encoding alone is not enough: `encodeURIComponent` leaves `.` and `..` + * intact and fetch then normalises `/../` away, which would silently address another route. + */ +export function segments(...parts: string[]): string { + for (const part of parts) { + if (part === "" || part === "." || part === "..") throw new Error(`"${part}" cannot be a path segment`); + } + return `/${parts.map(encodeURIComponent).join("/")}`; +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 081860e..dd0b96b 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -19,6 +19,12 @@ export function isPackageName(name: string): boolean { return /^[a-z0-9-]+$/.test(name); } +/** App, channel and package names share this rule; a value that breaks it would also break out of its path segment. */ +export function requireName(kind: string, value: string): string { + if (!isPackageName(value)) throw new Error(`${kind} must match [a-z0-9-]+, got "${value}"`); + return value; +} + function insideRoot(root: string, dir: string): boolean { if (dir === "" || isAbsolute(dir)) return false; const rel = relative(root, resolve(root, dir)); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d07b1ce..91c321f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,11 +1,17 @@ export { build, RUNTIME_MODULES } from "./build.ts"; export type { BuildOptions, BuildResult, BuiltModule, Manifest } from "./build.ts"; -export { bundle } from "./bundle.ts"; +export { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; +export type { AppRecord, IssuedToken, PackageRecord, Releases } from "./admin.ts"; +export { bundle, isPathSegment } from "./bundle.ts"; export type { BundleOptions, BundleResult, Pointer } from "./bundle.ts"; -export { loadConfig, CONFIG_FILE } from "./config.ts"; +export { ADMIN_TOKEN_ENV, DEFAULT_URL, requireSecureUrl, requireToken, resolveUrl, segments, TOKEN_ENV, UpdatesClient, UpdatesError, URL_ENV } from "./client.ts"; +export type { ClientOptions, UploadResult } from "./client.ts"; +export { loadConfig, requireName, isPackageName, CONFIG_FILE } from "./config.ts"; 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 type { PublishOptions, PublishResult } from "./publish.ts"; export { compileModule, findQjsc } from "./qjsc.ts"; export type { CompileOptions } from "./qjsc.ts"; export { transformJsx, TransformError } from "./transform.ts"; diff --git a/packages/cli/src/publish.ts b/packages/cli/src/publish.ts new file mode 100644 index 0000000..5f9abd7 --- /dev/null +++ b/packages/cli/src/publish.ts @@ -0,0 +1,147 @@ +import { readFile, readdir, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { Manifest } from "./build.ts"; +import { isPathSegment, type Pointer } from "./bundle.ts"; +import { segments, type UpdatesClient, type UploadResult } from "./client.ts"; + +export interface PublishOptions { + client: UpdatesClient; + /** `tinyui bundle` output: either `` or the `//` inside it. */ + dir: string; + app: string; + channel: string; + /** Overrides the `rollout` in `current.json`. */ + rollout?: number; + concurrency?: number; + onUpload?: (result: UploadResult) => void; +} + +export interface PublishResult { + pkg: string; + runtimeVersion: string; + version: string; + uploaded: number; + existing: number; + pointer: Pointer; +} + +/** The signed bundle of one (pkg, rv), as `tinyui bundle` laid it out (docs/updates.md §1.2). */ +interface LocalBundle { + dir: string; + pointer: Pointer; + /** The bytes that were signed; they are uploaded unchanged (docs/updates.md §7). */ + manifestBytes: Uint8Array; + manifest: Manifest & { runtimeVersion: string }; +} + +export async function publish(options: PublishOptions): Promise { + const { client, app, channel } = options; + if (options.rollout !== undefined && (!Number.isInteger(options.rollout) || options.rollout < 0 || options.rollout > 100)) { + throw new Error(`rollout must be an integer from 0 to 100, got ${options.rollout}`); + } + const concurrency = options.concurrency ?? 4; + if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`concurrency must be a positive integer, got ${options.concurrency}`); + const local = await load(await discover(resolve(options.dir))); + const { manifest, pointer } = local; + const pkg = manifest.name; + const rv = manifest.runtimeVersion; + const version = manifest.version; + const prefix = segments(app, pkg, rv, version); + + const files = [...new Set(Object.values(manifest.files).map((path) => `${path}.bin`)), "manifest.json"]; + const results: UploadResult[] = []; + const versionDir = join(local.dir, version); + await inParallel(files, concurrency, async (path) => { + const bytes = path === "manifest.json" ? local.manifestBytes : await readInside(versionDir, path); + const result = await client.upload(`${prefix}${segments(...path.split("/"))}`, bytes); + results.push(result); + options.onUpload?.(result); + }); + + const body = { version, signature: pointer.signature, rollout: options.rollout ?? pointer.rollout }; + const written = await client.json("PUT", `${segments(app, channel, pkg, rv)}/current.json`, body); + return { + pkg, + runtimeVersion: rv, + version, + uploaded: results.filter((r) => !r.existing).length, + existing: results.filter((r) => r.existing).length, + pointer: written, + }; +} + +/** `

` 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; + const found: string[] = []; + for (const pkg of await subdirectories(dir)) { + for (const rv of await subdirectories(join(dir, pkg))) { + if (await exists(join(dir, pkg, rv, "current.json"))) found.push(join(dir, pkg, rv)); + } + } + if (found.length === 1) return found[0]!; + if (found.length === 0) throw new Error(`no current.json under ${dir}; run tinyui bundle first, or point --dir at the bundle directory`); + const list = found.map((f) => ` ${f}`).join("\n"); + throw new Error(`${dir} holds more than one bundle; publish one at a time by pointing --dir at it:\n${list}`); +} + +async function load(dir: string): Promise { + const pointer = JSON.parse(await readFile(join(dir, "current.json"), "utf8")) as Partial; + if (typeof pointer.version !== "string" || typeof pointer.signature !== "string" || typeof pointer.rollout !== "number") { + throw new Error(`${join(dir, "current.json")} is not a pointer file; expected version / rollout / signature`); + } + if (!Number.isInteger(pointer.rollout) || pointer.rollout < 0 || pointer.rollout > 100) { + throw new Error(`${join(dir, "current.json")}: rollout must be an integer from 0 to 100, got ${pointer.rollout}`); + } + if (!isPathSegment(pointer.version)) throw new Error(`${join(dir, "current.json")}: "${pointer.version}" is not a single path segment`); + const manifestFile = join(dir, pointer.version, "manifest.json"); + const manifestBytes = await readInside(join(dir, pointer.version), "manifest.json").catch((e: unknown) => { + throw new Error(`cannot read ${manifestFile} (${(e as Error).message}); current.json points at a version that was not bundled here`); + }); + const manifest = JSON.parse(Buffer.from(manifestBytes).toString("utf8")) as Manifest & { runtimeVersion?: string }; + for (const key of ["name", "version", "runtimeVersion"] as const) { + if (typeof manifest[key] !== "string" || manifest[key] === "") throw new Error(`${manifestFile} has no "${key}"; rebuild with a current tinyui-cli`); + } + // the same three checks the server runs (§6.1), so a mismatch costs no upload + if (manifest.version !== pointer.version) throw new Error(`${manifestFile} is version ${manifest.version}, current.json points at ${pointer.version}`); + // these paths pick which files are read and uploaded, so they stay inside / and inside the URL charset + if (typeof manifest.files !== "object" || manifest.files === null) throw new Error(`${manifestFile}: "files" must map module names to paths`); + for (const [module, path] of Object.entries(manifest.files)) { + if (typeof path !== "string" || !path.split("/").every(isPathSegment)) { + throw new Error(`${manifestFile}: files["${module}"] = ${JSON.stringify(path)}; every segment must match [A-Za-z0-9._-]+`); + } + } + return { dir, pointer: pointer as Pointer, manifestBytes, manifest: manifest as Manifest & { runtimeVersion: string } }; +} + +/** Lexical checks do not stop a symlink: a bundle from elsewhere could point at any file on the machine. */ +async function readInside(versionDir: string, path: string): Promise { + const file = join(versionDir, path); + const real = await realpath(file); + const root = await realpath(versionDir); + const rel = relative(root, real); + if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error(`${file} leaves ${versionDir}; a bundle only publishes its own files`); + return readFile(real); +} + +async function subdirectories(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => { + throw new Error(`${dir} does not exist; run tinyui bundle first`); + }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); +} + +async function exists(file: string): Promise { + return readFile(file).then( + () => true, + () => false, + ); +} + +async function inParallel(items: T[], limit: number, run: (item: T) => Promise): Promise { + const queue = [...items]; + const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) await run(next); + }); + await Promise.all(workers); +} diff --git a/packages/cli/test/admin.test.ts b/packages/cli/test/admin.test.ts new file mode 100644 index 0000000..61c5ecb --- /dev/null +++ b/packages/cli/test/admin.test.ts @@ -0,0 +1,122 @@ +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 { 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"; + +describe("management commands", () => { + let server: FakeServer; + let client: UpdatesClient; + + before(async () => { + server = await startFakeServer(); + client = new UpdatesClient({ url: server.url, token: "admin-token" }); + }); + after(() => server.close()); + + function last() { + const request = server.requests.at(-1)!; + return { method: request.method, path: request.path, auth: request.auth, body: request.body.length === 0 ? null : (JSON.parse(request.body.toString("utf8")) as Record) }; + } + + it("creates an app, a package and rotates its key", async () => { + const app = await createApp(client, { id: "demo", name: "Demo", org: "tiny-ui" }); + assert.equal(app.id, "demo"); + assert.deepEqual(last(), { method: "POST", path: "/apps", auth: "Bearer admin-token", body: { id: "demo", name: "Demo", org: "tiny-ui" } }); + + await createPackage(client, "demo", { name: "fixture", publicKey: "BASE64" }); + assert.equal(last().path, "/apps/demo/packages"); + assert.deepEqual(last().body, { name: "fixture", publicKey: "BASE64" }); + + // five segments, same shape as an upload path: the management route has to win + const rotated = await rotatePublicKey(client, "demo", "fixture", "ROTATED"); + assert.equal(last().method, "PUT"); + assert.equal(last().path, "/apps/demo/packages/fixture/publicKey"); + assert.deepEqual(last().body, { publicKey: "ROTATED" }); + assert.equal(rotated.name, "fixture"); + assert.equal(rotated.publicKey, "ROTATED"); + assert.ok(rotated.publicKeyUpdatedAt); + }); + + it("issues a token for the named channels and revokes it by id", async () => { + const issued = await createToken(client, "demo", "fixture", ["staging", "production"]); + assert.equal(issued.token, "publish-token-shown-once"); + assert.equal(last().path, "/apps/demo/packages/fixture/tokens"); + assert.deepEqual(last().body, { channels: ["staging", "production"] }); + + await revokeToken(client, "demo", "fixture", "tok_1"); + assert.deepEqual(last(), { method: "DELETE", path: "/apps/demo/packages/fixture/tokens/tok_1", auth: "Bearer admin-token", body: null }); + }); + + it("lists releases and moves the pointer for a rollback, a promotion and a rollout change", async () => { + const releases = await listReleases(client, "demo", "fixture", "1"); + assert.equal(last().path, "/demo/fixture/1/releases"); + assert.equal(releases.versions.length, 2); + assert.equal(releases.channels["staging"]?.rollout, 20); + + const target = { app: "demo", pkg: "fixture", runtimeVersion: "1" }; + const rolledBack = await movePointer(client, { ...target, channel: "production" }, { version: "20260921T080000Z-aaaaaa" }); + assert.equal(last().path, "/demo/production/fixture/1/pointer"); + assert.equal(rolledBack.version, "20260921T080000Z-aaaaaa"); + + await movePointer(client, { ...target, channel: "staging" }, { rollout: 50 }); + assert.deepEqual(last().body, { rollout: 50 }); + }); + + 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 + assert.equal(segments("demo", "fixture", "1", "订单.bin"), "/demo/fixture/1/%E8%AE%A2%E5%8D%95.bin"); + // encodeURIComponent leaves dots alone, so a traversing value is refused rather than escaped + for (const traversal of ["..", ".", ""]) assert.throws(() => segments("demo", traversal, "1"), /cannot be a path segment/); + assert.throws(() => requireName("--app", ".."), /--app must match \[a-z0-9-\]\+/); + assert.throws(() => requireName("--channel", "Production"), /--channel must match/); + assert.equal(requireName("--app", "trendingai"), "trendingai"); + }); + + it("takes the instance from --url, then the environment, then the hosted default", () => { + const saved = process.env[URL_ENV]; + try { + delete process.env[URL_ENV]; + assert.equal(resolveUrl(undefined), DEFAULT_URL); + assert.equal(resolveUrl("https://ota.example.com"), "https://ota.example.com"); + process.env[URL_ENV] = "https://from-env.example.com"; + assert.equal(resolveUrl(undefined), "https://from-env.example.com"); + assert.equal(resolveUrl("https://ota.example.com"), "https://ota.example.com"); + } finally { + if (saved === undefined) delete process.env[URL_ENV]; + else process.env[URL_ENV] = saved; + } + }); + + it("names the environment variable a command needs instead of taking a token on the command line", () => { + for (const variable of [TOKEN_ENV, ADMIN_TOKEN_ENV] as const) { + const saved = process.env[variable]; + try { + delete process.env[variable]; + assert.throws(() => requireToken(variable), new RegExp(`${variable} is not set`)); + process.env[variable] = "from-env"; + assert.equal(requireToken(variable), "from-env"); + } finally { + if (saved === undefined) delete process.env[variable]; + else process.env[variable] = saved; + } + } + }); + + it("refuses to carry a token over plain http unless the instance is loopback", () => { + assert.throws(() => resolveUrl("http://updates.example.com"), /cleartext/); + assert.throws(() => new UpdatesClient({ url: "http://updates.example.com", token: "t" }), /cleartext/); + assert.throws(() => resolveUrl("not-a-url"), /not an instance url/); + assert.equal(resolveUrl("https://updates.example.com"), "https://updates.example.com"); + assert.equal(resolveUrl("http://localhost:8787"), "http://localhost:8787"); + assert.equal(resolveUrl("http://127.0.0.1:8787"), "http://127.0.0.1:8787"); + }); + + it("strips a trailing slash off the instance url", async () => { + const trailing = new UpdatesClient({ url: `${server.url}/`, token: "admin-token" }); + await createApp(trailing, { id: "demo2", name: "Demo 2" }); + assert.equal(last().path, "/apps"); + }); +}); diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts index 563d816..79785c1 100644 --- a/packages/cli/test/build.test.ts +++ b/packages/cli/test/build.test.ts @@ -146,6 +146,18 @@ describe("tinyui build", () => { } }); + it("refuses a page whose path cannot be a URL segment", async () => { + const project = await mkdtemp(join(tmpdir(), "tinyui-nonascii-")); + try { + await copyFile(join(root, "tinyui.config.json"), join(project, "tinyui.config.json")); + await mkdir(join(project, "src", "pages"), { recursive: true }); + await copyFile(join(root, "src", "pages", "home.tsx"), join(project, "src", "pages", "订单.tsx")); + await assert.rejects(build({ root: project, jsOnly: true }), /"订单" must match \[A-Za-z0-9\._-\]\+; a page path becomes a URL segment/); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + it("compiles every module to bytecode when qjsc-kmp is available", { skip: !process.env["TINYUI_QJSC"] && "TINYUI_QJSC not set" }, async () => { for (const m of [...result.runtime, ...result.pages]) { assert.ok(m.bin, `${m.name} has bytecode`); diff --git a/packages/cli/test/bundle.test.ts b/packages/cli/test/bundle.test.ts index 9104cde..9f1d37d 100644 --- a/packages/cli/test/bundle.test.ts +++ b/packages/cli/test/bundle.test.ts @@ -8,6 +8,7 @@ import { build, type Manifest } from "../src/build.ts"; import { bundle, type Pointer } from "../src/bundle.ts"; import { generateKeyPair, verify } from "../src/keys.ts"; import { findQjsc } from "../src/qjsc.ts"; +import { fakeDist as writeFakeDist } from "./helpers.ts"; const fixtures = join(import.meta.dirname, "fixtures"); const qjsc = await findQjsc(); @@ -15,33 +16,7 @@ const qjsc = await findQjsc(); const pair = generateKeyPair(); const config = { name: "fixture", publicKey: pair.publicKey }; -/** A `tinyui build` output written by hand: bundle only needs the manifest and the `.bin` files it lists. */ -async function fakeDist(dir: string, edit: (m: Manifest) => void = () => {}): Promise { - const files = { "tinyui-core": "runtime/core", "tinyui-native": "runtime/native", "fixture/home": "pages/home", "fixture/订单": "pages/订单" }; - const hashes: Record = {}; - for (const [module, path] of Object.entries(files)) { - const bytes = Buffer.concat([Buffer.from("QJKB"), Buffer.alloc(8), Buffer.from("a".repeat(40)), Buffer.from(module)]); - await mkdir(join(dir, path, ".."), { recursive: true }); - await writeFile(join(dir, path + ".bin"), bytes); - hashes[module] = createHash("sha256").update(bytes).digest("hex"); - } - const manifest: Manifest = { - runtime: ["tinyui-core", "tinyui-native"], - pages: ["fixture/home", "fixture/订单"], - files, - buildIds: Object.fromEntries(Object.keys(files).map((m) => [m, "00000000"])), - name: config.name, - publicKey: config.publicKey, - version: "20260922T090000Z-3f2a1c", - createdAt: "2026-09-22T09:00:00Z", - engine: "a".repeat(40), - protocol: 1, - hashes, - }; - edit(manifest); - await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); - return dir; -} +const fakeDist = (dir: string, edit?: (m: Manifest) => void) => writeFakeDist(dir, config.publicKey, edit); describe("tinyui bundle", () => { let tmp: string; @@ -62,7 +37,7 @@ describe("tinyui bundle", () => { assert.deepEqual((await readdir(versionDir, { recursive: true })).filter((f) => f.includes(".")).sort(), [ "manifest.json", "pages/home.bin", - "pages/订单.bin", + "pages/orders.bin", "runtime/core.bin", "runtime/native.bin", ]); @@ -83,7 +58,7 @@ describe("tinyui bundle", () => { const bytes = await readFile(result.manifest); assert.ok(verify(config.publicKey, bytes, pointer.signature)); // non-ASCII module names survive as written: no canonicalization on any side - assert.match(bytes.toString("utf8"), /"fixture\/订单"/); + assert.match(bytes.toString("utf8"), /"fixture\/orders"/); const reserialized = Buffer.from(JSON.stringify(JSON.parse(bytes.toString("utf8")))); assert.ok(!verify(config.publicKey, reserialized, pointer.signature), "a re-serialization is a different message"); }); @@ -168,6 +143,11 @@ describe("tinyui bundle", () => { await assert.rejects(bundle({ dist, runtimeVersion: "1/2", signingKey }), /runtime version must be a single path segment/); }); + it("refuses an output path that would not survive the delivery URL", async () => { + const chinese = await fakeDist(join(tmp, "dist-nonascii"), (m) => { m.files["fixture/home"] = "pages/订单"; }); + await assert.rejects(bundle({ dist: chinese, runtimeVersion: "1", signingKey }), /every segment must match \[A-Za-z0-9\._-\]\+ to survive the delivery URL/); + }); + it("bundles a real build end to end", { skip: !qjsc && "qjsc-kmp not found" }, async () => { const root = join(tmp, "app"); await cp(join(fixtures, "app"), root, { recursive: true }); diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts new file mode 100644 index 0000000..c034f7e --- /dev/null +++ b/packages/cli/test/helpers.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { join } from "node:path"; +import type { Manifest } from "../src/build.ts"; + +/** A `tinyui build` output written by hand: bundle only needs the manifest and the `.bin` files it lists. */ +export async function fakeDist(dir: string, publicKey: string, edit: (m: Manifest) => void = () => {}): Promise { + const files = { "tinyui-core": "runtime/core", "tinyui-native": "runtime/native", "fixture/home": "pages/home", "fixture/orders": "pages/orders" }; + const hashes: Record = {}; + for (const [module, path] of Object.entries(files)) { + const bytes = Buffer.concat([Buffer.from("QJKB"), Buffer.alloc(8), Buffer.from("a".repeat(40)), Buffer.from(module)]); + await mkdir(join(dir, path, ".."), { recursive: true }); + await writeFile(join(dir, path + ".bin"), bytes); + hashes[module] = createHash("sha256").update(bytes).digest("hex"); + } + const manifest: Manifest = { + runtime: ["tinyui-core", "tinyui-native"], + pages: ["fixture/home", "fixture/orders"], + files, + buildIds: Object.fromEntries(Object.keys(files).map((m) => [m, "00000000"])), + name: "fixture", + publicKey, + version: "20260922T090000Z-3f2a1c", + createdAt: "2026-09-22T09:00:00Z", + engine: "a".repeat(40), + protocol: 1, + hashes, + }; + edit(manifest); + await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); + return dir; +} + +export interface RecordedRequest { + method: string; + path: string; + /** The `Authorization` header verbatim: the CLI must never send a request without one. */ + auth: string | undefined; + contentType: string | undefined; + contentLength: string | undefined; + body: Buffer; +} + +export interface FakeServer { + url: string; + requests: RecordedRequest[]; + /** Queued `{ status, body }` replies; a path with no reply gets the default for its shape. */ + reply(path: string, response: { status: number; body?: unknown }): void; + close(): Promise; +} + +/** + * The response shapes of docs/updates.md §6, and nothing else: it deliberately does not verify + * signatures, hashes or token scope, so it cannot drift from the parts the server's own tests own. + */ +export async function startFakeServer(): Promise { + const requests: RecordedRequest[] = []; + const queued = new Map(); + const uploads = new Map(); + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks); + const path = req.url ?? ""; + requests.push({ + method: req.method ?? "", + path, + auth: req.headers.authorization, + contentType: req.headers["content-type"], + contentLength: req.headers["content-length"], + body, + }); + const send = (status: number, value: unknown) => { + if (value === undefined) return res.writeHead(status).end(); + res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)); + }; + try { + const forced = queued.get(path); + if (forced) { + queued.delete(path); + 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" }); + 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 + return send(500, { error: `fake server: ${(e as Error).message}` }); + } + }); + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("the fake server did not bind a port"); + return { + url: `http://127.0.0.1:${address.port}`, + requests, + reply: (path, response) => queued.set(path, response), + close: () => closeServer(server), + }; +} + +function defaultReply(method: string, path: string, body: Buffer, uploads: Map): [number, unknown] { + const segments = path.split("/").filter((s) => s !== ""); + const last = segments.at(-1); + const parsed = asJson(body); + + if (method === "PUT" && last === "current.json" && segments.length === 5) { + const [app, , pkg, rv] = segments; + const version = parsed["version"] as string; + // content first, pointer last: the one ordering rule a client can get wrong (§6.1) + if (!uploads.has(`/${app}/${pkg}/${rv}/${version}/manifest.json`)) return [409, { error: `${version}/manifest.json is not uploaded yet: content first, pointer last` }]; + return [200, { version, rollout: parsed["rollout"] ?? 100, signature: parsed["signature"] }]; + } + // management routes first, exactly as the server registers them: `/apps//packages/

/publicKey` + // is five segments and would otherwise be swallowed by the upload route + if (segments[0] === "apps") { + if (method === "POST" && path === "/apps") return [201, { ...parsed, createdAt: "2026-09-22T09:00:00Z" }]; + if (method === "POST" && last === "packages") return [201, { ...parsed, createdAt: "2026-09-22T09:00:00Z" }]; + if (method === "PUT" && last === "publicKey") return [200, { name: segments.at(-2), ...parsed, createdAt: "2026-09-22T09:00:00Z", publicKeyUpdatedAt: "2026-09-22T09:05:00Z" }]; + if (method === "POST" && last === "tokens") return [201, { id: "tok_1", token: "publish-token-shown-once", channels: parsed["channels"], createdAt: "2026-09-22T09:00:00Z" }]; + if (method === "DELETE" && segments.at(-2) === "tokens") return [204, undefined]; + return [404, { error: "not found" }]; + } + if (method === "PUT" && segments.length >= 5) { + const sha256 = createHash("sha256").update(body).digest("hex"); + const existing = uploads.get(path); + if (existing === undefined) { + uploads.set(path, sha256); + return [201, { path: segments.slice(4).join("/"), sha256, existing: false }]; + } + if (existing !== sha256) return [409, { error: `${path} already exists with different content` }]; + return [200, { path: segments.slice(4).join("/"), sha256, existing: true }]; + } + if (method === "GET" && last === "releases") { + return [ + 200, + { + versions: [ + { version: "20260922T090000Z-3f2a1c", createdAt: "2026-09-22T09:00:00Z", publishedAt: "2026-09-22T09:05:00Z" }, + { version: "20260921T080000Z-aaaaaa", createdAt: "2026-09-21T08:00:00Z", publishedAt: "2026-09-21T08:05:00Z" }, + ], + channels: { production: { version: "20260921T080000Z-aaaaaa", rollout: 100 }, staging: { version: "20260922T090000Z-3f2a1c", rollout: 20 } }, + }, + ]; + } + if (method === "POST" && last === "pointer") return [200, { version: parsed["version"] ?? "20260921T080000Z-aaaaaa", rollout: parsed["rollout"] ?? 100, signature: "recorded-signature" }]; + return [404, { error: "not found" }]; +} + +/** Uploads carry bytecode, not JSON; only the pointer and the management bodies are objects. */ +function asJson(body: Buffer): Record { + try { + const value: unknown = body.length === 0 ? {} : JSON.parse(body.toString("utf8")); + return typeof value === "object" && value !== null ? (value as Record) : {}; + } catch { + return {}; + } +} + +function closeServer(server: Server): Promise { + return new Promise((done, fail) => { + server.closeAllConnections(); + server.close((e) => (e ? fail(e) : done())); + }); +} diff --git a/packages/cli/test/publish.test.ts b/packages/cli/test/publish.test.ts new file mode 100644 index 0000000..38a4be1 --- /dev/null +++ b/packages/cli/test/publish.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { publish } from "../src/publish.ts"; +import { fakeDist, startFakeServer, type FakeServer } from "./helpers.ts"; + +const pair = generateKeyPair(); +const VERSION = "20260922T090000Z-3f2a1c"; +const FILES = ["runtime/core.bin", "runtime/native.bin", "pages/home.bin", "pages/orders.bin", "manifest.json"]; + +describe("tinyui publish", () => { + let tmp: string; + let signingKey: string; + let server: FakeServer; + let client: UpdatesClient; + + before(async () => { + tmp = await mkdtemp(join(tmpdir(), "tinyui-publish-")); + signingKey = join(tmp, "signing-key.pem"); + await writeFile(signingKey, pair.privateKeyPem); + server = await startFakeServer(); + client = new UpdatesClient({ url: server.url, token: "publish-token" }); + }); + after(async () => { + await server.close(); + await rm(tmp, { recursive: true, force: true }); + }); + + /** One signed bundle under `/