@oj-module/cli 0.2.8 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +9 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +130 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/market/__tests__/bootstrap.test.ts +204 -0
  19. package/src/market/__tests__/build-index.test.ts +154 -0
  20. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  21. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  22. package/src/market/__tests__/fixture.test.ts +48 -0
  23. package/src/market/__tests__/publish.test.ts +109 -0
  24. package/src/market/__tests__/report.test.ts +51 -0
  25. package/src/market/__tests__/setup.test.ts +114 -0
  26. package/src/market/__tests__/verify.test.ts +111 -0
  27. package/src/market/bootstrap.ts +255 -0
  28. package/src/market/build-index.ts +159 -0
  29. package/src/market/cdn-layout.ts +122 -0
  30. package/src/market/command.ts +139 -0
  31. package/src/market/failure-playbook.ts +57 -0
  32. package/src/market/fixture.ts +138 -0
  33. package/src/market/index-builder.ts +76 -0
  34. package/src/market/publish.ts +156 -0
  35. package/src/market/report.ts +67 -0
  36. package/src/market/scan.ts +99 -0
  37. package/src/market/setup.ts +144 -0
  38. package/src/market/trial.ts +121 -0
  39. package/src/market/verify.ts +298 -0
  40. package/src/market/walkthrough.ts +506 -0
  41. package/src/oj.ts +3 -1
  42. package/src/preview-config.ts +14 -0
  43. package/src/preview.ts +4 -3
  44. package/src/sign.ts +21 -6
  45. package/src/usage.ts +19 -0
  46. package/src/vendor.ts +4 -2
  47. package/src/versions.ts +28 -0
  48. package/templates/api/.ojm-api-exempt.json +6 -2
  49. package/templates/api/config.yaml +20 -0
  50. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  51. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  52. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  53. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  54. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  55. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  56. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  57. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  58. package/templates/api/src/_platform/manifest.yaml +1 -0
  59. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  60. package/templates/api/src/_platform/schema.yaml +12 -0
  61. package/templates/api/src/auth/_shared/session.ts +2 -2
  62. package/templates/api/src/demo/todos/api.ts +8 -9
  63. package/templates/api/src/notification/_shared/notify.ts +64 -26
  64. package/templates/api/src/notification/api.ts +1 -1
  65. package/templates/api/src/notification/manifest.yaml +1 -0
  66. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  67. package/templates/api/src/notification/schema.yaml +7 -0
  68. package/templates/api/src/notification/seed.sql +5 -4
  69. package/templates/api/src/personal-center/profile/api.ts +63 -0
  70. package/templates/api/src/web/user-info/api.ts +3 -1
  71. package/templates/api/tests/fixture.ts +8 -16
  72. package/templates/api/tests/l1-builder.test.ts +91 -0
  73. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  74. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  75. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  76. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  77. package/templates/api/tests/query-utils.spec.ts +85 -1
  78. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  79. package/templates/cdn/README.md +48 -0
  80. package/templates/global.d.ts +36 -2
  81. package/templates/init.include.yaml +3 -2
  82. package/templates/openapi.json +55 -10
  83. package/templates/web/src/notification/entry.ts +46 -9
  84. package/templates/web/src/notification/locales/en-US.json +19 -0
  85. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  86. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  87. package/templates/web/src/oj-market/entry.ts +8 -2
  88. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  89. package/templates/web/src/personal-center/__client/api.ts +25 -1
  90. package/templates/web/src/personal-center/entry.ts +5 -0
  91. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  92. package/templates/web/src/system/entry.ts +2 -1
  93. package/templates/web.config.ts +10 -7
  94. package/vendor/host-versions.json +3 -3
  95. package/templates/api/src/notifications/api.ts +0 -29
  96. package/templates/api/src/notifications/manifest.yaml +0 -5
  97. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  98. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.8",
11
- "@oj-module/runtime/contract": "0.2.8",
12
- "@oj-module/runtime/contract/errors": "0.2.8",
10
+ "@oj-module/runtime": "0.2.14",
11
+ "@oj-module/runtime/contract": "0.2.14",
12
+ "@oj-module/runtime/contract/errors": "0.2.14",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,64 @@
1
+ // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
2
+ //
3
+ // 安装器按 B-11 布局读 `<api>/dist/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
+ // 强制比对;而站点壳的 versions.json 落在 web/dist。两者不同源时,新工程 `ojm build`
5
+ // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { buildBackend } from "../build";
12
+
13
+ /**
14
+ * 假 oj 二进制:只需成功退出——本 spec 验的是 buildBackend 在 oj build 之后的收尾动作
15
+ * (versions.json 同源落地),oj build 自身的产物由用例预先放好模拟。
16
+ */
17
+ function fakeOj(projectRoot: string): void {
18
+ mkdirSync(join(projectRoot, "bin"), { recursive: true });
19
+ const bin = join(projectRoot, "bin", "oj");
20
+ writeFileSync(bin, "#!/bin/sh\nexit 0\n");
21
+ chmodSync(bin, 0o755);
22
+ }
23
+
24
+ function newProject(): string {
25
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-"));
26
+ mkdirSync(join(root, "api", "src", "demo"), { recursive: true });
27
+ writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
28
+ writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
+ // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
+ mkdirSync(join(root, "api", "dist"), { recursive: true });
31
+ writeFileSync(join(root, "api", "dist", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
+ fakeOj(root);
33
+ return root;
34
+ }
35
+
36
+ describe("given 宿主工程有 api/src When ojm build 后端", () => {
37
+ it("then api/dist/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
38
+ const root = newProject();
39
+
40
+ await buildBackend(root);
41
+
42
+ const hostVersions = join(root, "api", "dist", "versions.json");
43
+ expect(existsSync(hostVersions)).toBe(true);
44
+ const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
+ expect(typeof parsed["@oj-module/runtime"]).toBe("string");
46
+ });
47
+
48
+ it("then 既有 oj build 产物不被覆盖(manifests.yaml 原样保留)", async () => {
49
+ const root = newProject();
50
+
51
+ await buildBackend(root);
52
+
53
+ expect(readFileSync(join(root, "api", "dist", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
54
+ });
55
+ });
56
+
57
+ describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
+ it("then 跳过构建、不产出 api/dist", async () => {
59
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
+
61
+ expect(await buildBackend(root)).toBe(false);
62
+ expect(existsSync(join(root, "api", "dist"))).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { hostArtifactFreshnessError } from "../versions";
4
+
5
+ describe("hostArtifactFreshnessError(源码形态宿主产物过期守卫)", () => {
6
+ it("版本一致 → 通过", () => {
7
+ expect(hostArtifactFreshnessError("0.2.11", "0.2.11")).toBeUndefined();
8
+ });
9
+
10
+ it("任一侧缺失 → 跳过守卫", () => {
11
+ expect(hostArtifactFreshnessError(undefined, "0.2.11")).toBeUndefined();
12
+ expect(hostArtifactFreshnessError("0.2.11", undefined)).toBeUndefined();
13
+ });
14
+
15
+ it("宿主产物落后于 workspace runtime → 报真因并指向 build:shell", () => {
16
+ const err = hostArtifactFreshnessError("0.2.10", "0.2.11");
17
+ expect(err).toContain("shell-dist/versions.json)过期");
18
+ expect(err).toContain("0.2.10");
19
+ expect(err).toContain("0.2.11");
20
+ expect(err).toContain("build:shell");
21
+ });
22
+
23
+ it("宿主产物领先(跨仓消费旧版 runtime)→ 同样拦截", () => {
24
+ expect(hostArtifactFreshnessError("0.2.12", "0.2.11")).toContain("过期");
25
+ });
26
+ });
package/src/build.ts CHANGED
@@ -511,6 +511,15 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
511
511
  // SyntaxError: Invalid left-hand side in assignment(端点 500)。
512
512
  // 0.1.32 / 0.1.50 均复现。产物体积换正确性,等上游修好再撤这个旗标。
513
513
  execFileSync(oj, ["build", ...configArgs, "-d", apiSrc, "-o", apiDist, "--no-minify"], { stdio: "inherit" });
514
+ // 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 web/dist(mergeShellSite),
515
+ // 而方案安装器读的是 `<api>/dist/versions.json`(B-11:与 manifests.yaml 同层,用于
516
+ // peerRuntime 强制比对)。两处必须同源,否则新工程 `ojm build` 完仍装不上方案——
517
+ // preflight 直接 C2-09 fail-closed「无法读取宿主 runtime 版本」。
518
+ const versionsSrc = path.join(resolveShellDist(), "versions.json");
519
+ if (fs.existsSync(versionsSrc)) {
520
+ fs.mkdirSync(apiDist, { recursive: true });
521
+ fs.copyFileSync(versionsSrc, path.join(apiDist, "versions.json"));
522
+ }
514
523
  return true;
515
524
  }
516
525
 
package/src/bundle.ts CHANGED
@@ -116,8 +116,14 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
116
116
  }
117
117
  }
118
118
 
119
- /** C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version/desc 与 bundle 级逐一相等(oj 启动强校验同源) */
120
- function checkModuleManifest(built: string, name: string, version: string, summary: string): string[] {
119
+ /**
120
+ * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
121
+ * (oj 启动强校验同源)。
122
+ *
123
+ * 变更(2026-10-21):`desc === summary` 的等值要求已移除(与安装侧 extract.ts 同步)——
124
+ * 那是构建期映射的产物而不是安全边界,保留它会让 desc 与 summary 一字之差的包直接装不上。
125
+ */
126
+ function checkModuleManifest(built: string, name: string, version: string, _summary: string): string[] {
121
127
  const p = path.join(built, "manifest.yaml");
122
128
  let doc: unknown;
123
129
  try {
@@ -137,9 +143,6 @@ function checkModuleManifest(built: string, name: string, version: string, summa
137
143
  if (mm.version !== version) {
138
144
  out.push(`模块级 version「${String(mm.version)}」必须 === bundle version「${version}」`);
139
145
  }
140
- if (mm.desc !== summary) {
141
- out.push(`模块级 desc「${String(mm.desc)}」必须 === bundle summary「${summary}」(安装器写 desc 即取 summary)`);
142
- }
143
146
  return out;
144
147
  }
145
148
 
@@ -1,4 +1,6 @@
1
- import type { ScopedRequestLike } from "../../../templates/web/src/_platform/__client/api";
1
+ // ScopedRequestLike 真源在 runtime contract(生成物 __client/api.ts 也从这里 import,
2
+ // 自身不再 re-export——旧写法 type import 自 __client 会在再生后 TS2305)。
3
+ import type { ScopedRequestLike } from "@oj-module/runtime/contract/errors";
2
4
 
3
5
  // 集成回归:真实 httpMarketClient(统一后委托 ojm 生成 client)的委托行为。
4
6
  // 经 bindRequest 注入 mock request,验证每条 MarketClient 方法 → 正确 URL/body/query + 返回形状;
@@ -43,9 +45,8 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
43
45
  calls.push({ method, url, opts });
44
46
  const r = responder({ method, url, opts });
45
47
  if (r.status && r.status >= 400) {
46
- const err: { response?: { status?: number } } = new Error(`HTTP ${r.status}`);
47
- err.response = { status: r.status };
48
- throw err;
48
+ // ky 形态:HTTPError 携带 response.status(httpMarketClient.isUnconfigured 依赖它)
49
+ throw Object.assign(new Error(`HTTP ${r.status}`), { response: { status: r.status } });
49
50
  }
50
51
  return { json: () => Promise.resolve({ code: 0, data: r.data ?? null }) };
51
52
  };
@@ -61,14 +62,12 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
61
62
  }
62
63
 
63
64
  // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
65
+ // 桥接:占位方法零参签名 ↔ ScopedRequestLike 真实签名,经 any 双向打通(测试桩,无运行时差异)。
64
66
  function useMock(m: ReturnType<typeof mockRequest>) {
65
67
  bindRequest(m.req);
66
- requestImpl.get = m.req.get;
67
- requestImpl.post = m.req.post;
68
- requestImpl.put = m.req.put;
69
- requestImpl.delete = m.req.delete;
70
- requestImpl.patch = m.req.patch;
71
- requestImpl.head = m.req.head;
68
+ const req = m.req as unknown as Record<keyof typeof requestImpl, () => never>;
69
+ for (const k of Object.keys(requestImpl) as Array<keyof typeof requestImpl>)
70
+ requestImpl[k] = req[k];
72
71
  }
73
72
 
74
73
  describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
@@ -101,6 +100,10 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
101
100
  useMock(mock);
102
101
  const r = await httpMarketClient.index();
103
102
  expect(r).toEqual([]);
103
+ // 503 是配置态而非故障:silentError 抑制全局 toast + retry:0 不重试(原
104
+ // market-client-unconfigured 用例的断言并入此处——工厂形态退役后唯一活口)
105
+ expect(mock.calls).toHaveLength(1);
106
+ expect(mock.calls[0].opts).toMatchObject({ silentError: true, retry: 0 });
104
107
  });
105
108
 
106
109
  it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
package/src/index.ts CHANGED
@@ -161,6 +161,12 @@ async function main() {
161
161
  publishCommand(positionals[0] ?? "", { id, key, check });
162
162
  break;
163
163
  }
164
+ case "market": {
165
+ // 本地模拟 CDN 与市场链路(setup / publish / build-index)
166
+ const { runMarket } = await import("./market/command");
167
+ await runMarket(process.argv.slice(3));
168
+ break;
169
+ }
164
170
  default: {
165
171
  if (command && OJ_PASSTHROUGH.has(command)) {
166
172
  const { runOjSubcommand } = await import("./oj-cmd");
package/src/init.ts CHANGED
@@ -98,6 +98,13 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
98
98
  fs.mkdirSync(skillDir, { recursive: true });
99
99
  for (const name of ["SKILL.md", "api-manual.md"])
100
100
  copyIfAbsent(path.join(devkit, name), path.join(skillDir, name), report);
101
+ // v0.1.82 起 devkit 新增两份专题指南(api-manual/SKILL 均引用它们):
102
+ // SQL 构造器能力矩阵/方言差异、oj test L1 测试指南。它们是**增量文档**、
103
+ // 不影响 ojm 运行,故「有则拷、无则记跳过」——bin/devkit 来自用户本地
104
+ // oj 版本(MIN_OJ_VERSION 只钉到 0.1.73),旧版 devkit 没有这两个文件,
105
+ // 为两份文档把整个 init 打挂不值当(用户升 oj 后重跑 init 即补上)。
106
+ for (const name of ["sql-builder-guide.md", "oj-test-guide.md"])
107
+ copyIfPresent(path.join(devkit, name), path.join(skillDir, name), report);
101
108
  copyIfAbsent(path.join(devkit, "global.d.ts"), path.join(destDir, "global.d.ts"), report);
102
109
  }
103
110
  else {
@@ -367,6 +374,21 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
367
374
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
368
375
  }
369
376
 
377
+ /**
378
+ * 同 copyIfAbsent,但**源缺失**时记一条跳过而非抛错。
379
+ *
380
+ * 用于从 bin/devkit 拷第三方版本化内容(两份专题指南):devkit 随用户本地 oj
381
+ * 版本而变,旧版没有新文件属正常版本 skew,不是安装包损坏——缺了只少份文档,
382
+ * 不该让 init 失败。源在而目标已存在仍走 copyIfAbsent 的幂等跳过。
383
+ */
384
+ function copyIfPresent(src: string, dest: string, report: InitReport): void {
385
+ if (!fs.existsSync(src)) {
386
+ report.skipped.push(`${path.basename(src)}(bin/devkit 无此文件,跳过)`);
387
+ return;
388
+ }
389
+ copyIfAbsent(src, dest, report);
390
+ }
391
+
370
392
  /**
371
393
  * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
372
394
  *
@@ -33,7 +33,7 @@ afterEach(() => {
33
33
 
34
34
  /** 预置「已构建」产物:oj build(api/dist/<name>-<version>/)与站点构建(web/dist/modules/) */
35
35
  function makePrebuiltArtifacts(): void {
36
- // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version/desc 与 bundle 级逐一相等,C2-31)
36
+ // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version 与 bundle 级逐一相等,C2-31)
37
37
  const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
38
  mkdirSync(join(apiDist, "ping"), { recursive: true });
39
39
  mkdirSync(join(apiDist, "migrations"), { recursive: true });
@@ -101,9 +101,9 @@ describe("buildBundle · 校验先于打包(fail-fast)", () => {
101
101
  await expect(pack()).rejects.toThrow(/manifest\.yaml/);
102
102
  });
103
103
 
104
- it("c2-31 分层核对:模块级 desc ≠ bundle summary → 拒绝", async () => {
104
+ it("c2-31 分层核对:模块级 desc ≠ bundle summary → 放行(等值要求已移除)", async () => {
105
105
  writeFileSync(join(project, "api/dist/demo-solution-0.1.0/manifest.yaml"), "name: demo-solution\ndesc: 不一致描述\nversion: 0.1.0\n");
106
- await expect(pack()).rejects.toThrow(/C2-31/);
106
+ await expect(pack()).resolves.toMatchObject({ name: "demo-solution" });
107
107
  });
108
108
 
109
109
  it("c2-31 分层核对:api/dist 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {
@@ -0,0 +1,204 @@
1
+ // market/bootstrap spec(T8):隔离站点自举——「试装不心疼」的物理基础。
2
+ //
3
+ // 钉死隔离六项(端口 / DB / 密钥 / 凭据 / 信任库 / CDN 目录)逐项与真站点物理分离:
4
+ // 任一项漏掉,trial 就不再是「隔离试装」而是「往自己工程里装」,信任库污染与端口
5
+ // 冲突都是不可逆后果。故每条断言都直接查文件系统与配置内容,不靠口头约定。
6
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { bootstrapIsolatedSite } from "../bootstrap";
12
+
13
+ /** 假 oj 进程:不起真进程,ready 立即就绪(与 dev.ts 的 ojStarter 注入同形态) */
14
+ function fakeStarter() {
15
+ const started: Array<{ configPath: string, base: string, apiSrc: string }> = [];
16
+ const starter = (configPath: string, base: string, apiSrc: string) => {
17
+ started.push({ configPath, base, apiSrc });
18
+ return {
19
+ port: Number(/port: (\d+)/.exec(readFileSync(configPath, "utf-8"))?.[1] ?? "0"),
20
+ ready: Promise.resolve(),
21
+ stop: () => Promise.resolve(),
22
+ };
23
+ };
24
+ return { started, starter };
25
+ }
26
+
27
+ /** 造一个「有 oj 二进制与模板」的源工程:bin/oj + bin/plugins + api/config.yaml + api/src */
28
+ function newSourceProject(): string {
29
+ const root = mkdtempSync(join(tmpdir(), "ojm-bootstrap-src-"));
30
+ mkdirSync(join(root, "bin", "plugins"), { recursive: true });
31
+ writeFileSync(join(root, "bin", "oj"), "#!/bin/sh\necho fake-oj\n", { mode: 0o755 });
32
+ writeFileSync(join(root, "bin", ".oj-version"), "v0.1.84\n");
33
+ writeFileSync(join(root, "bin", "plugins", "auth.wasm"), "plugin", "utf-8");
34
+ mkdirSync(join(root, "api", "src", "_platform"), { recursive: true });
35
+ writeFileSync(join(root, "api", "src", "_platform", "seed.sql"), "-- seed\n", "utf-8");
36
+ writeFileSync(
37
+ join(root, "api", "config.yaml"),
38
+ [
39
+ "mounts:",
40
+ " - prefix: /api",
41
+ " api: src",
42
+ "server:",
43
+ " host: 127.0.0.1",
44
+ " port: 9778",
45
+ " jwt_secret: __JWT_SECRET__",
46
+ "exec:",
47
+ " _platform:",
48
+ " allow: []",
49
+ "",
50
+ ].join("\n"),
51
+ "utf-8",
52
+ );
53
+ return root;
54
+ }
55
+
56
+ describe("given 一个装了 oj 二进制的源工程 When 自举隔离站点", () => {
57
+ it("then 产物全在临时根内:api 树 / bin/oj / bin/plugins 各就各位", async () => {
58
+ const src = newSourceProject();
59
+ const { starter } = fakeStarter();
60
+
61
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
62
+
63
+ expect(existsSync(join(site.root, "api", "src", "_platform", "seed.sql"))).toBe(true);
64
+ expect(existsSync(join(site.root, "bin", "oj"))).toBe(true);
65
+ expect(existsSync(join(site.root, "bin", "plugins", "auth.wasm"))).toBe(true);
66
+ expect(site.root).not.toBe(src);
67
+ await site.cleanup();
68
+ });
69
+
70
+ it("then 端口取自空闲端口且绝不与源工程 config 的 9778 相同", async () => {
71
+ const src = newSourceProject();
72
+ const { started, starter } = fakeStarter();
73
+
74
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
75
+
76
+ expect(site.port).toBeGreaterThan(1024);
77
+ expect(site.port).not.toBe(9778);
78
+ expect(started[0]?.configPath).toBe(join(site.root, "api", "config.yaml"));
79
+ const cfg = readFileSync(join(site.root, "api", "config.yaml"), "utf-8");
80
+ expect(cfg).toContain(`port: ${site.port}`);
81
+ await site.cleanup();
82
+ });
83
+
84
+ it("then jwt_secret 被随机替换(模板占位符不得残留)", async () => {
85
+ const src = newSourceProject();
86
+ const { starter } = fakeStarter();
87
+
88
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
89
+
90
+ const cfg = readFileSync(join(site.root, "api", "config.yaml"), "utf-8");
91
+ expect(cfg).not.toContain("__JWT_SECRET__");
92
+ await site.cleanup();
93
+ });
94
+
95
+ it("then CDN 目录在临时根内且已落好 fixture 包与索引", async () => {
96
+ const src = newSourceProject();
97
+ const { starter } = fakeStarter();
98
+
99
+ const site = await bootstrapIsolatedSite({ projectRoot: src, fixture: "demo-solution@0.1.0", starter });
100
+
101
+ expect(site.cdnDir.startsWith(site.root)).toBe(true);
102
+ expect(existsSync(join(site.cdnDir, "demo-solution-0.1.0.tgz"))).toBe(true);
103
+ expect(existsSync(join(site.cdnDir, "bundles.yaml"))).toBe(true);
104
+ expect(site.cdnBaseUrl).toBe(`${site.baseUrl}/cdn`);
105
+ await site.cleanup();
106
+ });
107
+
108
+ it("then 发布方私钥只在内存:临时根内 CDN 与站点树查不到发布方 PEM", async () => {
109
+ const src = newSourceProject();
110
+ const { starter } = fakeStarter();
111
+
112
+ const site = await bootstrapIsolatedSite({ projectRoot: src, fixture: "demo-solution@0.1.0", starter });
113
+
114
+ expect(site.publisher.privateKeyPem).toContain("PRIVATE KEY");
115
+ const pemFiles = listFiles(site.root).filter(f => f.endsWith(".pem"));
116
+ const outside = pemFiles.filter(f => !f.includes("api/config"));
117
+ expect(outside).toEqual([]);
118
+ await site.cleanup();
119
+ });
120
+
121
+ it("then 信任库隔离:临时根内无 .solutions 信任库文件", async () => {
122
+ const src = newSourceProject();
123
+ const { starter } = fakeStarter();
124
+
125
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
126
+
127
+ expect(existsSync(join(site.root, ".solutions"))).toBe(false);
128
+ expect(listFiles(site.root).some(f => f.includes("trusted-keys"))).toBe(false);
129
+ await site.cleanup();
130
+ });
131
+
132
+ it("then DB 隔离:库文件路径落在临时根内(config 目录相对解析)", async () => {
133
+ const src = newSourceProject();
134
+ const { starter } = fakeStarter();
135
+
136
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
137
+
138
+ expect(site.dbPath.startsWith(site.root)).toBe(true);
139
+ expect(site.dbPath).toContain("db.sqlite");
140
+ await site.cleanup();
141
+ });
142
+ });
143
+
144
+ describe("given 源工程没装 oj 二进制 When 自举", () => {
145
+ it("then 人话报错指向 ojm vendor,且不留半成品临时根", async () => {
146
+ const root = mkdtempSync(join(tmpdir(), "ojm-bootstrap-nooj-"));
147
+ mkdirSync(join(root, "api"), { recursive: true });
148
+ writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
149
+ const { starter } = fakeStarter();
150
+
151
+ await expect(bootstrapIsolatedSite({ projectRoot: root, starter }))
152
+ .rejects
153
+ .toThrow(/ojm vendor/);
154
+ });
155
+ });
156
+
157
+ describe("given 自举成功 When 执行 cleanup", () => {
158
+ it("then 临时根被销毁(默认不保留现场)", async () => {
159
+ const src = newSourceProject();
160
+ const { starter } = fakeStarter();
161
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
162
+
163
+ await site.cleanup();
164
+
165
+ expect(existsSync(site.root)).toBe(false);
166
+ });
167
+
168
+ it("then keep 模式下保留现场并打出销毁清单", async () => {
169
+ const src = newSourceProject();
170
+ const { starter } = fakeStarter();
171
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter, keep: true });
172
+
173
+ await site.cleanup();
174
+
175
+ expect(existsSync(site.root)).toBe(true);
176
+ expect(site.teardownReport().join("\n")).toContain(site.root);
177
+ });
178
+
179
+ it("then 重复 cleanup 幂等(不抛错)", async () => {
180
+ const src = newSourceProject();
181
+ const { starter } = fakeStarter();
182
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
183
+
184
+ await site.cleanup();
185
+ await expect(site.cleanup()).resolves.toBeUndefined();
186
+ });
187
+ });
188
+
189
+ /** 递归列出根下所有文件(相对路径) */
190
+ function listFiles(root: string): string[] {
191
+ const out: string[] = [];
192
+ const walk = (dir: string, prefix: string) => {
193
+ for (const entry of readdirSync(dir)) {
194
+ const rel = prefix === "" ? entry : `${prefix}/${entry}`;
195
+ const abs = join(dir, entry);
196
+ if (statSync(abs).isDirectory())
197
+ walk(abs, rel);
198
+ else
199
+ out.push(rel);
200
+ }
201
+ };
202
+ walk(root, "");
203
+ return out;
204
+ }
@@ -0,0 +1,154 @@
1
+ // ojm market build-index spec(T4):扫描 cdn/*.tgz → 全量重建 Form B 索引。
2
+ //
3
+ // 两条必守:① 结果只取决于目录里有什么(多次执行字节一致);② bundles 的 file 必须逐字
4
+ // `<name>-<version>.tgz`——安装侧 P2-11 会拿它挡索引投毒,写歪一个字符就会装不上。
5
+ import { generateKeyPairSync } from "node:crypto";
6
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import { parse as yamlParse } from "yaml";
11
+
12
+ import { createDeterministicTgz } from "../../deterministic-tar";
13
+ import { verifyCanonical } from "../../lifecycle/sign";
14
+ import { indexCanonical } from "../../publish";
15
+ import { buildIndex } from "../build-index";
16
+ import { buildFixturePackage } from "../fixture";
17
+
18
+ const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
19
+ const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
20
+ const KEY_ID = "cdn.maintainer";
21
+
22
+ function testPem(): string {
23
+ return generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
24
+ }
25
+
26
+ function newProject(): { root: string, cdn: string } {
27
+ const root = mkdtempSync(join(tmpdir(), "ojm-market-index-"));
28
+ mkdirSync(join(root, "api"), { recursive: true });
29
+ writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
30
+ const cdn = join(root, "cdn");
31
+ mkdirSync(cdn, { recursive: true });
32
+ return { root, cdn };
33
+ }
34
+
35
+ /** 把一个 fixture 方案打进 cdn 目录,返回 tgz 文件名 */
36
+ async function putPackage(cdn: string, staging: string, name: string, version: string): Promise<string> {
37
+ const pkg = buildFixturePackage({ stagingDir: staging, name, version });
38
+ const tgz = join(cdn, `${name}-${version}.tgz`);
39
+ await createDeterministicTgz(pkg.dir, tgz);
40
+ return `${name}-${version}.tgz`;
41
+ }
42
+
43
+ function readBundles(cdn: string): { solutions?: Array<Record<string, unknown>> } {
44
+ return yamlParse(readFileSync(join(cdn, "bundles.yaml"), "utf-8")) as { solutions?: Array<Record<string, unknown>> };
45
+ }
46
+
47
+ describe("given cdn/ 里有多个包与多个版本 When 重建索引", () => {
48
+ it("then 按 semver 聚合:版本全留、latest 取最大", async () => {
49
+ const { root, cdn } = newProject();
50
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
51
+ await putPackage(cdn, staging, "demo-solution", "0.1.0");
52
+ await putPackage(cdn, staging, "demo-solution", "0.2.0");
53
+ await putPackage(cdn, staging, "other-solution", "0.3.0");
54
+
55
+ const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
56
+
57
+ const bundles = readBundles(cdn);
58
+ expect(result.solutions).toBe(2);
59
+ const demo = bundles.solutions!.find(s => s.name === "demo-solution")!;
60
+ expect(demo.latest).toBe("0.2.0");
61
+ expect(demo.versions as unknown[]).toHaveLength(2);
62
+ const manifests = yamlParse(readFileSync(join(cdn, "manifests.yaml"), "utf-8")) as unknown[];
63
+ expect(manifests).toHaveLength(3);
64
+ });
65
+
66
+ it("then bundles 的 file 逐字为 <name>-<version>.tgz(安装侧 P2-11 加固前置)", async () => {
67
+ const { root, cdn } = newProject();
68
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
69
+ await putPackage(cdn, staging, "demo-solution", "0.1.0");
70
+
71
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
72
+
73
+ const demo = readBundles(cdn).solutions![0]!;
74
+ const hit = (demo.versions as Array<Record<string, unknown>>)[0]!;
75
+ expect(hit.file).toBe("demo-solution-0.1.0.tgz");
76
+ expect(hit.sha256).toMatch(/^[0-9a-f]{64}$/);
77
+ expect(hit.size).toBeGreaterThan(0);
78
+ });
79
+
80
+ it("then 索引只取决于目录内容:连续两次执行字节一致(幂等)", async () => {
81
+ const { root, cdn } = newProject();
82
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
83
+ await putPackage(cdn, staging, "demo-solution", "0.1.0");
84
+ const key = testPem();
85
+
86
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key });
87
+ const first = readFileSync(join(cdn, "bundles.yaml"), "utf-8");
88
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key });
89
+ const second = readFileSync(join(cdn, "bundles.yaml"), "utf-8");
90
+
91
+ expect(second).toBe(first);
92
+ });
93
+ });
94
+
95
+ describe("given cdn/ 为空 When 重建索引", () => {
96
+ it("then 产出空索引(不是缺 Solutions 段)——空目录也能验、也能装", async () => {
97
+ const { root, cdn } = newProject();
98
+
99
+ const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
100
+
101
+ expect(result.solutions).toBe(0);
102
+ const bundles = readBundles(cdn);
103
+ expect(bundles.solutions).toEqual([]);
104
+ expect(yamlParse(readFileSync(join(cdn, "manifests.yaml"), "utf-8"))).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("given cdn/ 里有一个没有内嵌 manifest 的包 When 重建索引", () => {
109
+ it("then 跳过该包并给出可读警告,其余包照常入索引", async () => {
110
+ const { root, cdn } = newProject();
111
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
112
+ writeFileSync(join(cdn, "broken-0.1.0.tgz"), "garbage-not-a-tgz", "utf-8");
113
+ await putPackage(cdn, staging, "demo-solution", "0.1.0");
114
+
115
+ const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
116
+
117
+ expect(result.skipped).toContain("broken-0.1.0.tgz");
118
+ expect(result.solutions).toBe(1);
119
+ });
120
+ });
121
+
122
+ describe("given 包带包级 .sig 侧车 When 重建索引", () => {
123
+ it("then manifests 的 archive.signature 带上它(否则安装器按 sig_missing 拒装)", async () => {
124
+ const { root, cdn } = newProject();
125
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
126
+ const file = await putPackage(cdn, staging, "demo-solution", "0.1.0");
127
+ writeFileSync(`${join(cdn, file)}.sig`, JSON.stringify({ alg: "Ed25519", key_id: KEY_ID, public_key: "ab", value: "cd", canon: "v1" }), "utf-8");
128
+
129
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
130
+
131
+ const manifests = yamlParse(readFileSync(join(cdn, "manifests.yaml"), "utf-8")) as Array<Record<string, unknown>>;
132
+ const archive = manifests[0]!.archive as Record<string, unknown>;
133
+ expect(archive.signature).toMatchObject({ key_id: KEY_ID });
134
+ });
135
+ });
136
+
137
+ describe("given 给了签名身份 When 重建索引", () => {
138
+ it("then 两份索引各产出可复核的 .sig", async () => {
139
+ const { root, cdn } = newProject();
140
+ const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
141
+ await putPackage(cdn, staging, "demo-solution", "0.1.0");
142
+
143
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
144
+
145
+ for (const kind of ["bundles", "manifests"] as const) {
146
+ const sigPath = join(cdn, `${kind}.yaml.sig`);
147
+ expect(existsSync(sigPath)).toBe(true);
148
+ const sig = JSON.parse(readFileSync(sigPath, "utf-8")) as { public_key: string, value: string, key_id: string };
149
+ expect(sig.key_id).toBe(KEY_ID);
150
+ const text = readFileSync(join(cdn, `${kind}.yaml`), "utf-8");
151
+ expect(verifyCanonical(sig.public_key, indexCanonical(kind, text), sig.value)).toBe(true);
152
+ }
153
+ });
154
+ });