Skip to content

Commit 7d1d49d

Browse files
HarlonWangclaude
andauthored
feat(cli): 热下发 M1——tinyui.config.json、包名进模块名、manifest 扩展、keys generate 与 bundle 签名 (#13)
- build 读工程根的 tinyui.config.json(name / publicKey / pages),页面模块名改为 <pkg>/<相对路径>, manifest 加 name / publicKey / version / createdAt / engine / protocol / hashes;core 新增 tinyui-core/protocol 子路径导出 - keys generate:P-256 密钥对,私钥 PKCS#8 PEM、公钥 X9.63 未压缩点 base64 - bundle:写定 <pkg>/<rv>/<version>/manifest.json 后对文件原始字节签名,签名与 version / rollout 写 current.json; version / runtimeVersion / name / files / pages 全部限定为合法路径段或根内路径,先校验后落盘, version 目录与指针经同目录 staging + rename 原子落地,已有 version 目录只接受字节一致的同一 build - sample 包名 sample(配置与演示密钥进仓),sample/shared 与 Android host 测试的模块名改为 sample/todos Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d1b2364 commit 7d1d49d

23 files changed

Lines changed: 714 additions & 63 deletions

File tree

‎compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class BundleSmokeTest {
3838
fun stacksFromABuiltPageMapBackToTheSource() = runBlocking {
3939
if (!out.isDirectory) return@runBlocking
4040
val manifest = BuildManifest.parse(out.resolve("manifest.json").readText())
41-
val maps = SourceMaps(mapOf("pages/todos" to out.resolve("pages/todos.js.map").readText(), "tinyui-core" to out.resolve("runtime/core.js.map").readText()))
41+
val maps = SourceMaps(mapOf("sample/todos" to out.resolve("pages/todos.js.map").readText(), "tinyui-core" to out.resolve("runtime/core.js.map").readText()))
4242
val errors = mutableListOf<PageError>()
4343
val sink = object : PageSink {
4444
override fun error(error: PageError) { errors += error }
@@ -50,7 +50,7 @@ class BundleSmokeTest {
5050
else throw HostException("E_HTTP", "500")
5151
}
5252
val runtime = RuntimeBundle(out.resolve("runtime/core.bin").readBytes(), out.resolve("runtime/native.bin").readBytes())
53-
val page = PageModule("pages/todos", out.resolve("pages/todos.bin").readBytes(), manifest.buildId("pages/todos"))
53+
val page = PageModule("sample/todos", out.resolve("pages/todos.bin").readBytes(), manifest.buildId("sample/todos"))
5454
val host = PageHost(runtime, page, ComponentRegistry().registerBuiltins(), sink, HostServices(http = http), sourceMaps = maps)
5555
host.start()
5656
try {
@@ -62,7 +62,7 @@ class BundleSmokeTest {
6262
while (errors.none { it.kind == "E7" }) delay(20)
6363
} }
6464
val e = errors.single { it.kind == "E7" }
65-
assertEquals(manifest.buildId("pages/todos"), e.buildId)
65+
assertEquals(manifest.buildId("sample/todos"), e.buildId)
6666
val own = e.frames.firstOrNull { it.mapped && it.file == "src/pages/todos.tsx" } ?: error("no mapped frame in ${e.frames}; stack=${e.jsStack}")
6767
val source = File("../sample/js/src/pages/todos.tsx").readLines()
6868
assertTrue("await http.get" in source[own.line - 1], "frame $own points at the await; stack=${e.jsStack}")

‎compose/src/commonMain/kotlin/app/tinyui/PageHost.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import app.tinyui.schema.SizeValue
6363
/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */
6464
class RuntimeBundle(val core: ByteArray, val native: ByteArray)
6565

66-
/** One page's bytecode; [name] is the module name (`pages/todos`) and [buildId] comes from `manifest.json`. */
66+
/** One page's bytecode; [name] is the module name (`sample/todos`) and [buildId] comes from `manifest.json`. */
6767
class PageModule(val name: String, val bytecode: ByteArray, val buildId: String = "")
6868

6969
/** Everything the host learns about a page's health, in one place (docs/adr-002 §3.5). */

‎compose/src/commonMain/kotlin/app/tinyui/SourceMaps.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import kotlinx.serialization.json.jsonObject
66
import kotlinx.serialization.json.jsonPrimitive
77

88
/**
9-
* The `.js.map` files `tinyui build` wrote, keyed by module name (`pages/todos`, `tinyui-core`), parsed on first use.
9+
* The `.js.map` files `tinyui build` wrote, keyed by module name (`sample/todos`, `tinyui-core`), parsed on first use.
1010
* Ship them in debug builds only (docs/build-chain.md); release stacks are mapped offline with the build id.
1111
*/
1212
class SourceMaps(maps: Map<String, String>) {

‎docs/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ tinyui/
7272
├── packages/ 所有 npm 包,目录名 = 包名去掉 scope(pnpm workspace)
7373
│ ├── core/ tinyui-core JS 运行时(signal / effect / owner / h / For / Show / ref + cmd)+ 内置组件的 TS 类型(由 schema/ 生成,无运行时代码);作为 ES 模块字节码内置
7474
│ ├── native/ tinyui-native 业务调用的宿主能力 API(http / storage / toast / navigation / i18n …,即 ADR-002 的 J2 / J3)
75-
│ └── cli/ tinyui-cli 构建工具:TSX → h()(ES2025)→ 每页一个 ESM 模块字节码,`tinyui build`
75+
│ └── cli/ tinyui-cli 构建工具:TSX → h()(ES2025)→ 每页一个 ESM 模块字节码,`tinyui build`;`tinyui bundle` 产签名后的热下发目录,`tinyui keys generate` 产签名密钥对
7676
├── compose/ app.tinyui:tinyui KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp
7777
├── schema/ 内置组件 schema 的唯一真值(TS DSL)→ `pnpm schema` 生成 packages/core 的类型与 compose/ 的注册 schema
7878
├── sample/ 示例 App,M1 Counter / M2 列表页在这里跑

‎docs/updates-plan.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ M1 之后 M2 → M3 与 M4 并行;M6 要等 M3 与 M5。
9696

9797
| 里程碑 | 状态 |
9898
|---|---|
99-
| M1 CLI 产包与签名(含配置文件与模块名改名) | 待开 |
99+
| M1 CLI 产包与签名(含配置文件与模块名改名) | 已完成(PR #13,2026-09-22):`tinyui.config.json`、模块名 `<pkg>/…`、manifest 扩展、`keys generate`、`bundle` 签原始字节写 `current.json`;sample 包名 `sample` |
100100
| M2 core `Bundle` | 待开 |
101101
| M3 `updates/` 模块 | 待开 |
102102
| M4 服务 MVP | 待开 |

‎packages/cli/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# tinyui-cli
22

3-
[TinyUI](https://github.com/tiny-ui/tinyui) 的构建工具:`tinyui build` 把 `src/pages/**/*.tsx` 编成每页一个 ESM 模块字节码(含运行时模块与 `manifest.json`),`tinyui schema` 从组件 schema 生成 TS 类型与 Kotlin 侧代码。
3+
[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 侧代码。
44

5-
字节码编译需要 quickjs-kmp 的宿主工具 `qjsc-kmp`(`--qjsc` 或 `TINYUI_QJSC`)。用法见仓库 `docs/build-chain.md`;与 `tinyui-core` 同版本号。
5+
字节码编译需要 quickjs-kmp 的宿主工具 `qjsc-kmp`(`--qjsc` 或 `TINYUI_QJSC`)。用法见仓库 `docs/build-chain.md` 与 `docs/updates.md`;与 `tinyui-core` 同版本号。

‎packages/cli/src/bin.ts‎

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
11
#!/usr/bin/env node
2-
import { parseArgs } from "node:util";
2+
import { parseArgs, type ParseArgsConfig } from "node:util";
33
import { mkdir, readFile, writeFile } from "node:fs/promises";
4-
import { dirname } from "node:path";
4+
import { dirname, join, resolve } from "node:path";
55
import { build } from "./build.ts";
6+
import { bundle } from "./bundle.ts";
7+
import { generateKeyPair } from "./keys.ts";
68
import { generateKt, generateTs } from "./schema/generate.ts";
79
import { loadSchema } from "./schema/load.ts";
810

9-
const USAGE = `usage: tinyui build [--root <dir>] [--out <dir>] [--qjsc <path>] [--js-only]
11+
const USAGE = `usage: tinyui build [--root <dir>] [--out <dir>] [--qjsc <path>] [--js-only] [--version <v>]
12+
tinyui bundle --runtime-version <rv> --signing-key <pem> [--dist <dir>] [--rollout <0-100>] [--out <dir>]
13+
tinyui keys generate [--out <pem>]
1014
tinyui schema --entry <schema.ts> [--ts <file>] [--kt <file> --package <pkg> [--object <Name>]] [--check]
1115
12-
build
13-
--root project root, pages under <root>/src/pages (default: cwd)
16+
build compile the pages of the package described by <root>/tinyui.config.json
17+
--root project root (default: cwd)
1418
--out output directory (default: <root>/dist)
1519
--qjsc path to qjsc-kmp (default: $TINYUI_QJSC, then PATH)
1620
--js-only emit ESM sources and source maps only, skip bytecode
21+
--version package version to record (default: <createdAt>-<git sha>)
22+
23+
bundle turn a build into the signed upload directory <out>/<pkg>/<rv>/ (docs/updates.md)
24+
--runtime-version the host's declared runtimeVersion
25+
--signing-key PKCS#8 PEM whose public key is the one in tinyui.config.json
26+
--dist tinyui build output (default: ./dist)
27+
--rollout percentage written to current.json (default: 100)
28+
--out output root (default: <dist>/ota)
29+
30+
keys generate
31+
--out where to write the private key (default: ./tinyui-signing-key.pem); the public key is printed
1732
1833
schema
1934
--entry TS module whose default export is the component list
@@ -23,40 +38,77 @@ schema
2338
--check exit 1 if a target is not already up to date, write nothing
2439
`;
2540

26-
async function main(argv: string[]): Promise<number> {
27-
const { values, positionals } = parseArgs({
28-
args: argv,
29-
allowPositionals: true,
41+
type Options = NonNullable<ParseArgsConfig["options"]>;
42+
43+
const COMMANDS: Record<string, { options: Options; run: (v: Record<string, unknown>, positionals: string[]) => Promise<number> }> = {
44+
build: {
45+
options: { root: { type: "string" }, out: { type: "string" }, qjsc: { type: "string" }, "js-only": { type: "boolean", default: false }, version: { type: "string" } },
46+
run: async (v) => {
47+
const result = await build({
48+
root: (v["root"] as string | undefined) ?? process.cwd(),
49+
...(v["out"] !== undefined && { out: v["out"] as string }),
50+
...(v["qjsc"] !== undefined && { qjsc: v["qjsc"] as string }),
51+
...(v["version"] !== undefined && { version: v["version"] as string }),
52+
jsOnly: v["js-only"] as boolean,
53+
});
54+
for (const m of [...result.runtime, ...result.pages]) process.stdout.write(`${m.name} -> ${m.bin ?? m.js}\n`);
55+
return 0;
56+
},
57+
},
58+
bundle: {
59+
options: { "runtime-version": { type: "string" }, "signing-key": { type: "string" }, dist: { type: "string" }, rollout: { type: "string" }, out: { type: "string" } },
60+
run: async (v) => {
61+
const runtimeVersion = v["runtime-version"] as string | undefined;
62+
const signingKey = v["signing-key"] as string | undefined;
63+
if (!runtimeVersion) throw new Error("bundle: --runtime-version is required");
64+
if (!signingKey) throw new Error("bundle: --signing-key is required");
65+
const result = await bundle({
66+
dist: (v["dist"] as string | undefined) ?? resolve("dist"),
67+
runtimeVersion,
68+
signingKey,
69+
...(v["rollout"] !== undefined && { rollout: Number(v["rollout"]) }),
70+
...(v["out"] !== undefined && { out: v["out"] as string }),
71+
});
72+
process.stdout.write(`${result.version} -> ${result.dir}\n`);
73+
return 0;
74+
},
75+
},
76+
keys: {
77+
options: { out: { type: "string" } },
78+
run: async (v, positionals) => {
79+
if (positionals[1] !== "generate") throw new Error("keys: expected `keys generate`");
80+
const out = (v["out"] as string | undefined) ?? resolve("tinyui-signing-key.pem");
81+
const pair = generateKeyPair();
82+
await mkdir(dirname(out), { recursive: true });
83+
// the private key must never silently replace one already in use
84+
await writeFile(out, pair.privateKeyPem, { flag: "wx", mode: 0o600 });
85+
process.stderr.write(`private key written to ${out}; keep it with the publisher only\n`);
86+
process.stdout.write(`${pair.publicKey}\n`);
87+
return 0;
88+
},
89+
},
90+
schema: {
3091
options: {
31-
root: { type: "string" },
32-
out: { type: "string" },
33-
qjsc: { type: "string" },
34-
"js-only": { type: "boolean", default: false },
3592
entry: { type: "string" },
3693
ts: { type: "string" },
3794
kt: { type: "string" },
3895
package: { type: "string" },
3996
object: { type: "string", default: "BuiltinSchemas" },
4097
"node-import": { type: "string", default: "tinyui-core" },
4198
check: { type: "boolean", default: false },
42-
help: { type: "boolean", short: "h", default: false },
4399
},
44-
});
45-
if (values.help || (positionals[0] !== "build" && positionals[0] !== "schema")) {
100+
run: (v) => schema(v as Parameters<typeof schema>[0]),
101+
},
102+
};
103+
104+
async function main(argv: string[]): Promise<number> {
105+
const command = COMMANDS[argv[0] ?? ""];
106+
if (!command || argv.includes("--help") || argv.includes("-h")) {
46107
process.stdout.write(USAGE);
47-
return values.help ? 0 : 2;
48-
}
49-
if (positionals[0] === "schema") return schema(values);
50-
const result = await build({
51-
root: values.root ?? process.cwd(),
52-
...(values.out !== undefined && { out: values.out }),
53-
...(values.qjsc !== undefined && { qjsc: values.qjsc }),
54-
jsOnly: values["js-only"],
55-
});
56-
for (const m of [...result.runtime, ...result.pages]) {
57-
process.stdout.write(`${m.name} -> ${m.bin ?? m.js}\n`);
108+
return argv.includes("--help") || argv.includes("-h") ? 0 : 2;
58109
}
59-
return 0;
110+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: command.options });
111+
return command.run(values, positionals);
60112
}
61113

62114
async function schema(v: { entry?: string; ts?: string; kt?: string; package?: string; object: string; check: boolean; "node-import": string }): Promise<number> {

0 commit comments

Comments
 (0)