From afc44df72b46b5f7661d30efb0529596be68c205 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Tue, 22 Sep 2026 22:35:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20=E7=83=AD=E4=B8=8B=E5=8F=91=20?= =?UTF-8?q?M5=E2=80=94=E2=80=94publish=20=E4=B8=8E=20apps=20/=20packages?= =?UTF-8?q?=20/=20tokens=20/=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 M4 服务端端点的薄封装:publish 发现 bundle 目录、并发上传内容、 最后切指针;管理面覆盖建 app、登记与轮换验签公钥、签发吊销 token、 列 release 与回滚 / 晋级 / 改灰度。 凭据只从 $TINYUI_TOKEN / $TINYUI_ADMIN_TOKEN 读,不给 --token; --app 可用 $TINYUI_APP,--channel 每次显式写;app / channel / 包名 按服务端同一条 [a-z0-9-]+ 规则先校验,避免值撑开新的路径段。 测试对着本仓 node:http 起的薄 fake 跑:只回应协议的形状,不复刻 验签与 sha256 校验,那些归服务端仓的测试,复刻只会两边一起漂。 另对本地 wrangler dev 起的真服务端跑通了发布、幂等重发、晋级、 改灰度、回滚、跨 channel 被拒与吊销即时生效。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/updates-plan.md | 4 +- docs/updates.md | 2 +- packages/cli/README.md | 2 + packages/cli/src/admin.ts | 64 +++++++++++ packages/cli/src/bin.ts | 177 +++++++++++++++++++++++++++++- packages/cli/src/client.ts | 104 ++++++++++++++++++ packages/cli/src/config.ts | 6 + packages/cli/src/index.ts | 8 +- packages/cli/src/publish.ts | 134 ++++++++++++++++++++++ packages/cli/test/admin.test.ts | 108 ++++++++++++++++++ packages/cli/test/bundle.test.ts | 29 +---- packages/cli/test/helpers.ts | 163 +++++++++++++++++++++++++++ packages/cli/test/publish.test.ts | 161 +++++++++++++++++++++++++++ 13 files changed, 930 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/admin.ts create mode 100644 packages/cli/src/client.ts create mode 100644 packages/cli/src/publish.ts create mode 100644 packages/cli/test/admin.test.ts create mode 100644 packages/cli/test/helpers.ts create mode 100644 packages/cli/test/publish.test.ts 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..f344c3f 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -246,7 +246,7 @@ 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 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..bc915a8 --- /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..f0e242c 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 { 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) => { @@ -99,8 +128,154 @@ 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: Number(v["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 = required(v["id"], "tokens revoke: --id is required (tinyui releases list does not show it; keep the id from tokens create)"); + 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 = required(v["runtime-version"], `releases ${action ?? ""}: --runtime-version is required`); + 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 release of found.versions) process.stdout.write(`${release.version.padEnd(width)} ${release.publishedAt.padEnd(24)} ${at(release.version)}\n`); + return 0; + } + const target = { app, pkg, runtimeVersion, channel: "" }; + let body: { version?: string; rollout?: number }; + if (action === "rollback" || action === "promote") { + const version = required(positionals[2], `releases ${action}: a version is required`); + 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: Number(required(positionals[2], "releases rollout: a percentage is required")) }; + } 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 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/client.ts b/packages/cli/src/client.ts new file mode 100644 index 0000000..fdc970d --- /dev/null +++ b/packages/cli/src/client.ts @@ -0,0 +1,104 @@ +// 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; + } +} + +export class UpdatesClient { + readonly url: string; + readonly #token: string; + readonly #fetch: typeof globalThis.fetch; + + constructor(options: ClientOptions) { + this.url = 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 explicit ?? process.env[URL_ENV] ?? DEFAULT_URL; +} + +/** 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: a segment can never open a new one. */ +export function segments(...parts: string[]): string { + 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..0541291 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 { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; +export type { AppRecord, IssuedToken, PackageRecord, Releases } from "./admin.ts"; export { bundle } from "./bundle.ts"; export type { BundleOptions, BundleResult, Pointer } from "./bundle.ts"; -export { loadConfig, CONFIG_FILE } from "./config.ts"; +export { ADMIN_TOKEN_ENV, DEFAULT_URL, 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..4ec7d05 --- /dev/null +++ b/packages/cli/src/publish.ts @@ -0,0 +1,134 @@ +import { readFile, readdir } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import type { Manifest } from "./build.ts"; +import 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 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[] = []; + await inParallel(files, options.concurrency ?? 4, async (path) => { + const bytes = path === "manifest.json" ? local.manifestBytes : await readFile(join(local.dir, version, 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. + * Only the second form says anything about the package: a directory named directly may be a CI artifact + * unpacked under any name. + */ +async function discover(dir: string): Promise<{ dir: string; laidOut: boolean }> { + if (await exists(join(dir, "current.json"))) return { dir, laidOut: false }; + 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 { dir: found[0]!, laidOut: true }; + 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, laidOut }: { dir: string; laidOut: boolean }): 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`); + } + const manifestFile = join(dir, pointer.version, "manifest.json"); + const manifestBytes = await readFile(manifestFile).catch(() => { + throw new Error(`${manifestFile} is missing; 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}`); + if (laidOut) { + const rv = basename(dir); + const pkg = basename(dirname(dir)); + if (manifest.runtimeVersion !== rv || manifest.name !== pkg) { + throw new Error(`${dir} is laid out as ${pkg}/${rv} but holds ${manifest.name}/${manifest.runtimeVersion}; re-run tinyui bundle instead of moving directories`); + } + } + return { dir, pointer: pointer as Pointer, manifestBytes, manifest: manifest as Manifest & { runtimeVersion: string } }; +} + +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..19a1e43 --- /dev/null +++ b/packages/cli/test/admin.test.ts @@ -0,0 +1,108 @@ +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" }); + + 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" }); + }); + + 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"); + 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 has to be refused, not escaped + assert.equal(segments("demo", "..", "1"), "/demo/../1"); + 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("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/bundle.test.ts b/packages/cli/test/bundle.test.ts index 9104cde..ebe1802 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; diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts new file mode 100644 index 0000000..8b50b88 --- /dev/null +++ b/packages/cli/test/helpers.ts @@ -0,0 +1,163 @@ +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/订单": "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: "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"] }]; + } + 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 === "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, publicKeyUpdatedAt: "2026-09-22T09:00: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]; + 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..80f365f --- /dev/null +++ b/packages/cli/test/publish.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, readFile, rm, 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/订单.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 `/