@oj-module/cli 0.2.6 → 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 (169) hide show
  1. package/README.md +1 -1
  2. package/devkit/README.md +70 -24
  3. package/devkit/framework-dev.md +14 -5
  4. package/devkit/manual.md +28 -23
  5. package/devkit/module-dev-and-cdn-playground.md +197 -0
  6. package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +130 -45
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +64 -0
  12. package/src/__tests__/versions.test.ts +26 -0
  13. package/src/build.ts +9 -0
  14. package/src/bundle.ts +8 -5
  15. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  16. package/src/contract/__tests__/market-client.integration.test.ts +170 -0
  17. package/src/contract/handler-scan.ts +35 -4
  18. package/src/dev.ts +10 -2
  19. package/src/index.ts +9 -3
  20. package/src/init.ts +24 -0
  21. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +177 -0
  47. package/src/preview.ts +106 -56
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +22 -2
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +24 -2
  54. package/templates/api/src/_platform/README.md +31 -0
  55. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  56. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  57. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  58. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  59. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  60. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  61. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  62. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  63. package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
  64. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  65. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
  66. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  67. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  68. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
  69. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  70. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  71. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  72. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
  73. package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
  74. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
  75. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  76. package/templates/api/src/_platform/contacts/api.ts +2 -2
  77. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  78. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  79. package/templates/api/src/_platform/manifest.yaml +4 -1
  80. package/templates/api/src/_platform/market/api.ts +54 -16
  81. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  82. package/templates/api/src/_platform/schema.yaml +26 -0
  83. package/templates/api/src/_platform/solutions/api.ts +13 -12
  84. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  85. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  86. package/templates/api/src/auth/_shared/session.ts +26 -22
  87. package/templates/api/src/auth/api.ts +1 -1
  88. package/templates/api/src/demo/todos/api.ts +8 -8
  89. package/templates/api/src/home/line/api.ts +4 -4
  90. package/templates/api/src/home/pie/api.ts +4 -4
  91. package/templates/api/src/notification/_shared/notify.ts +97 -57
  92. package/templates/api/src/notification/api.ts +1 -1
  93. package/templates/api/src/notification/manifest.yaml +1 -0
  94. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  95. package/templates/api/src/notification/schema.yaml +7 -0
  96. package/templates/api/src/notification/seed.sql +5 -4
  97. package/templates/api/src/personal-center/profile/api.ts +63 -0
  98. package/templates/api/src/personal-center/upload/api.ts +1 -1
  99. package/templates/api/src/system/login-methods/api.ts +1 -1
  100. package/templates/api/src/system/oplog/api.ts +3 -3
  101. package/templates/api/src/system/permissions/api.ts +2 -2
  102. package/templates/api/src/system/roles/api.ts +2 -2
  103. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  104. package/templates/api/src/system/settings/api.ts +2 -2
  105. package/templates/api/src/system/users/api.ts +2 -2
  106. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  107. package/templates/api/src/system/users/password/api.ts +2 -2
  108. package/templates/api/src/system/users/roles/api.ts +2 -2
  109. package/templates/api/src/web/user-info/api.ts +4 -5
  110. package/templates/api/tests/auth-session.spec.ts +1 -1
  111. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  112. package/templates/api/tests/fixture.ts +8 -16
  113. package/templates/api/tests/guard.spec.ts +1 -1
  114. package/templates/api/tests/l1-builder.test.ts +91 -0
  115. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  116. package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
  117. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  118. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  119. package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
  120. package/templates/api/tests/query-utils.spec.ts +85 -1
  121. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  122. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  123. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  124. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  125. package/templates/api/tests/rbac-settings.spec.ts +66 -21
  126. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  127. package/templates/api/tests/rbac-users.spec.ts +1 -1
  128. package/templates/api/tsconfig.json +8 -0
  129. package/templates/cdn/README.md +48 -0
  130. package/templates/env.d.ts +2 -0
  131. package/templates/global.d.ts +850 -0
  132. package/templates/init.include.yaml +14 -2
  133. package/templates/logs/task-events.jsonl +0 -0
  134. package/templates/openapi.json +580 -23
  135. package/templates/package.json +2 -1
  136. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  137. package/templates/web/src/_platform/__client/api.ts +152 -39
  138. package/templates/web/src/notification/entry.ts +46 -9
  139. package/templates/web/src/notification/locales/en-US.json +19 -0
  140. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  141. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  142. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  143. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  144. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  145. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  146. package/templates/web/src/oj-market/api/client.ts +5 -105
  147. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  148. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  149. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  150. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  151. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  152. package/templates/web/src/oj-market/entry.ts +8 -2
  153. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  154. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  155. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  156. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  157. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  158. package/templates/web/src/personal-center/__client/api.ts +25 -1
  159. package/templates/web/src/personal-center/entry.ts +5 -0
  160. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  161. package/templates/web/src/system/entry.ts +2 -1
  162. package/templates/web.config.ts +13 -10
  163. package/vendor/host-versions.json +3 -3
  164. package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
  165. package/templates/api/src/notifications/api.ts +0 -32
  166. package/templates/api/src/notifications/manifest.yaml +0 -5
  167. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  168. package/templates/web/src/notifications/__client/api.ts +0 -80
  169. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
@@ -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
+ });
@@ -0,0 +1,96 @@
1
+ // market CDN 布局 spec(T2):cdn 目录与 `/cdn` 基址的单一真相。
2
+ //
3
+ // 逐条钉死「不许硬编码」——config 里的主机 / 端口 / 挂载路径任一改动,解析结果必须跟随。
4
+ // 之所以值得单独一组用例:dev 形态下 `/cdn` 只在 oj 子进程(9778)上成立,
5
+ // 一旦有人图省事写相对路径或硬编码端口,市场页拿到的会是 index.html 而不是 YAML
6
+ // (设计 §3.1.1)。合规与否由这组用例兜住,而不是靠注释提醒。
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { CDN_MOUNT_PREFIX, resolveCdnLayout, resolveCdnLayoutFromConfig } from "../cdn-layout";
13
+
14
+ // 绝对路径:真实调用里 configPath 是绝对的,这里不能拿相对路径假装(否则 path.resolve 会相对 cwd 解析)
15
+ const PROJECT = resolve("proj", "my-app");
16
+ const CONFIG_PATH = join(PROJECT, "api", "config.yaml");
17
+
18
+ /** 拼一份 config:默认端口与主机取自模板实际值 */
19
+ function configOf(mounts: string, server = " host: 127.0.0.1\n port: 9778"): string {
20
+ return `mounts:\n${mounts}server:\n${server}\n`;
21
+ }
22
+
23
+ const STD_MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
24
+
25
+ describe("resolveCdnLayoutFromConfig:一切取值都跟随 config", () => {
26
+ it("given 模板默认挂载 When 解析 Then 目录在工程根 cdn/、基址无尾斜杠", () => {
27
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS), CONFIG_PATH);
28
+
29
+ expect(layout.dir).toBe(join(PROJECT, "cdn"));
30
+ expect(layout.prefix).toBe(CDN_MOUNT_PREFIX);
31
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
32
+ });
33
+
34
+ it("given server.port 改成 9799 When 解析 Then 基址端口跟随(不硬编码 9778)", () => {
35
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.1\n port: 9799"), CONFIG_PATH);
36
+
37
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9799/cdn");
38
+ });
39
+
40
+ it("given server.host 改成 127.0.0.2 When 解析 Then 基址主机跟随", () => {
41
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.2\n port: 9778"), CONFIG_PATH);
42
+
43
+ expect(layout.baseUrl).toBe("http://127.0.0.2:9778/cdn");
44
+ });
45
+
46
+ it("given 挂载的 web 改成 ../static-cdn When 解析 Then 目录跟随(单一真相在 config)", () => {
47
+ const mounts = " - prefix: /cdn\n web: ../static-cdn\n";
48
+
49
+ const layout = resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
50
+
51
+ expect(layout.dir).toBe(join(PROJECT, "static-cdn"));
52
+ });
53
+
54
+ it("given 挂载前缀带尾斜杠 When 解析 Then 前缀归一化掉尾斜杠(拼接口径统一)", () => {
55
+ const mounts = " - prefix: /cdn/\n web: ../cdn\n";
56
+
57
+ const layout = resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
58
+
59
+ expect(layout.prefix).toBe(CDN_MOUNT_PREFIX);
60
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
61
+ });
62
+ });
63
+
64
+ describe("resolveCdnLayoutFromConfig:出错要给得出手", () => {
65
+ it("given mounts 里没有 /cdn 条目 When 解析 Then 抛错且给出要粘回 config 的两行", () => {
66
+ const mounts = " - prefix: /api\n api: src\n";
67
+
68
+ expect(() => resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH)).toThrow(/cdn/);
69
+
70
+ try {
71
+ resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
72
+ }
73
+ catch (error) {
74
+ // 人话错误里必须给出可直接粘贴的挂载声明,而不是只抱怨
75
+ expect(String(error)).toContain("prefix: /cdn");
76
+ expect(String(error)).toContain("web: ../cdn");
77
+ }
78
+ });
79
+
80
+ it("given server.port 缺失 When 解析 Then 抛人话错误(与 oj-config.readOjPort 同口径)", () => {
81
+ expect(() => resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.1"), CONFIG_PATH)).toThrow(/server\.port/);
82
+ });
83
+ });
84
+
85
+ describe("resolveCdnLayout:从工程根出发", () => {
86
+ it("given 一个带 api/config.yaml 的工程 When 解析 Then 与文本版结果一致", () => {
87
+ const root = mkdtempSync(join(tmpdir(), "ojm-cdn-layout-"));
88
+ mkdirSync(join(root, "api"), { recursive: true });
89
+ writeFileSync(join(root, "api", "config.yaml"), configOf(STD_MOUNTS), "utf-8");
90
+
91
+ const layout = resolveCdnLayout(root);
92
+
93
+ expect(layout.dir).toBe(join(root, "cdn"));
94
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
95
+ });
96
+ });
@@ -0,0 +1,33 @@
1
+ // market/failure-playbook spec(T12 收尾):失败文案表的 traceability——
2
+ // 每个 neg 场景预期命中的失败码都必须有双段式条目(成因 + 下一步),漏一条即失败。
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { FAILURE_PLAYBOOK } from "../failure-playbook";
6
+
7
+ /** 各 neg op 预期命中的失败码(补新 neg op 时同步加行,保持可追溯) */
8
+ const REQUIRED_CODES = [
9
+ "sig_missing", // neg-nosig
10
+ "tampered", // neg-tamper
11
+ "unknown_key", // neg-stranger
12
+ "rollback_guard", // rollback
13
+ "index_sig", // neg-badindex
14
+ "zip_slip", // neg-evil
15
+ ] as const;
16
+
17
+ describe("given 失败文案表 When 逐条核对", () => {
18
+ it("then 每个 neg 场景的预期失败码都有条目,且双段式字段非空", () => {
19
+ const byCode = new Map(FAILURE_PLAYBOOK.map(e => [e.code, e]));
20
+ for (const code of REQUIRED_CODES) {
21
+ const entry = byCode.get(code);
22
+ expect(entry, `缺失败码 ${code} 的文案条目`).toBeDefined();
23
+ expect(entry!.stage.trim()).not.toBe("");
24
+ expect(entry!.cause.trim()).not.toBe("");
25
+ expect(entry!.next.trim()).not.toBe("");
26
+ }
27
+ });
28
+
29
+ it("then code 无重复(表是字典不是日志)", () => {
30
+ const codes = FAILURE_PLAYBOOK.map(e => e.code);
31
+ expect(new Set(codes).size).toBe(codes.length);
32
+ });
33
+ });
@@ -0,0 +1,48 @@
1
+ // market/fixture spec(T12):合成方案树的版本差异——迁移阶梯是 upgrade/rollback op 的物理前提。
2
+ //
3
+ // 0.2.0 起(minor ≥ 2)多一段 0002__more.sql:upgrade 必须真的多走一段迁移,
4
+ // rollback 才有「包内最大 seq < 账本 head」可拦。确定性:同 name@version 输出恒同。
5
+ import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { gunzipSync } from "node:zlib";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { buildEvilZipSlipTgz, buildFixturePackage } from "../fixture";
12
+
13
+ describe("given 合成 fixture 方案树 When 看 migrations 目录", () => {
14
+ it("then 0.1.0 只有 0001(head 上限 1)", () => {
15
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.1.0" });
16
+ expect(readdirSync(join(pkg.dir, "api", "demo-solution-0.1.0", "migrations")).sort()).toEqual(["0001__init.sql"]);
17
+ });
18
+
19
+ it("then 0.2.0 多一段 0002(升级真的多走一段迁移)", () => {
20
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.2.0" });
21
+ const files = readdirSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations")).sort();
22
+ expect(files).toEqual(["0001__init.sql", "0002__more.sql"]);
23
+ // 0001 与 0.1.0 逐字节一致(upgrade op 断言依据)
24
+ const pkg010 = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.1.0" });
25
+ expect(readFileSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations", "0001__init.sql")))
26
+ .toEqual(readFileSync(join(pkg010.dir, "api", "demo-solution-0.1.0", "migrations", "0001__init.sql")));
27
+ });
28
+
29
+ it("then 0002 是合法 ALTER(作用于 fixture 声明的表)", () => {
30
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.2.0" });
31
+ const sql = readFileSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations", "0002__more.sql"), "utf-8");
32
+ expect(sql).toContain("ALTER TABLE demo_solution_note");
33
+ });
34
+ });
35
+
36
+ describe("given 手造 zip-slip 恶意 tgz When 检查字节", () => {
37
+ it("then 是合法 gzip/tar 且含 ../ 越界成员,同输入恒同字节(确定性)", () => {
38
+ const a = join(mkdtempSync(join(tmpdir(), "ojm-evil-")), "evil.tgz");
39
+ const b = join(mkdtempSync(join(tmpdir(), "ojm-evil-")), "evil.tgz");
40
+ buildEvilZipSlipTgz(a);
41
+ buildEvilZipSlipTgz(b);
42
+ const raw = readFileSync(a);
43
+ expect([raw[0], raw[1]]).toEqual([0x1F, 0x8B]); // gzip magic
44
+ expect(readFileSync(b)).toEqual(raw); // 确定性(mtime 置 0)
45
+ const tar = gunzipSync(raw).toString("utf-8");
46
+ expect(tar).toContain("../evil.sql"); // R1 必须能检出这个成员
47
+ });
48
+ });
@@ -0,0 +1,109 @@
1
+ // ojm market publish spec(T3):落包到 cdn/ —— 只产出 `.tgz` 与包级 `.tgz.sig`。
2
+ //
3
+ // 钉死 D-1 的职责边界:publish **不碰索引**(不得生成 bundles.yaml / manifests.yaml),
4
+ // 也不得把私钥写进任何目录(私钥是否入盘由隔离场景纪律兜,这里断言 cdn 与暂存区无 PEM)。
5
+ import { Buffer } from "node:buffer";
6
+ import { generateKeyPairSync } from "node:crypto";
7
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { verifyCanonical } from "../../lifecycle/sign";
13
+ import { publishPackage } from "../publish";
14
+
15
+ const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
16
+ const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
17
+ const KEY_ID = "demo.publisher";
18
+
19
+ /** Ed25519 测试身份:私钥只在内存,路径从不落盘 */
20
+ function testIdentity(): { privateKeyPem: string, publicKeyHex: string } {
21
+ const kp = generateKeyPairSync("ed25519");
22
+ return {
23
+ privateKeyPem: kp.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
24
+ publicKeyHex: Buffer.from(kp.publicKey.export({ format: "der", type: "spki" }).subarray(-32)).toString("hex"),
25
+ };
26
+ }
27
+
28
+ function newProject(): string {
29
+ const root = mkdtempSync(join(tmpdir(), "ojm-market-publish-"));
30
+ mkdirSync(join(root, "api"), { recursive: true });
31
+ writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
32
+ mkdirSync(join(root, "cdn"), { recursive: true });
33
+ return root;
34
+ }
35
+
36
+ describe("given cdn/ 为空 When 发布 fixture", () => {
37
+ it("then 产出包与其包级签名,且不碰索引", async () => {
38
+ const root = newProject();
39
+ const identity = testIdentity();
40
+
41
+ const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
42
+
43
+ expect(existsSync(result.tgzPath)).toBe(true);
44
+ expect(existsSync(result.sigPath)).toBe(true);
45
+ // D-1:索引不是 publish 的事
46
+ expect(existsSync(join(root, "cdn", "bundles.yaml"))).toBe(false);
47
+ expect(existsSync(join(root, "cdn", "manifests.yaml"))).toBe(false);
48
+ });
49
+
50
+ it("then .sig 是可复核的合法 detached 签名(横向复核)", async () => {
51
+ const root = newProject();
52
+ const identity = testIdentity();
53
+
54
+ const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
55
+ const sig = JSON.parse(readFileSync(result.sigPath, "utf-8")) as { key_id: string, public_key: string, value: string, canon: string };
56
+
57
+ expect(sig.key_id).toBe(KEY_ID);
58
+ expect(sig.public_key).toBe(identity.publicKeyHex);
59
+ expect(sig.canon).toBe("v1");
60
+ expect(verifyCanonical(sig.public_key, result.canonical, sig.value)).toBe(true);
61
+ });
62
+
63
+ it("then 私钥不落盘:cdn 与工程根都查不到 PEM", async () => {
64
+ const root = newProject();
65
+ const identity = testIdentity();
66
+
67
+ await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
68
+
69
+ const pemInCdn = readdirSync(join(root, "cdn")).filter(f => f.endsWith(".pem"));
70
+ expect(pemInCdn).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("given 同 name@version 再次发布", () => {
75
+ it("when 内容一致 Then 幂等覆盖(sha256 不变)", async () => {
76
+ const root = newProject();
77
+ const identity = testIdentity();
78
+ const base = { projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem };
79
+
80
+ const first = await publishPackage(base);
81
+ const second = await publishPackage(base);
82
+
83
+ expect(second.sha256).toBe(first.sha256);
84
+ expect(second.tgzPath).toBe(first.tgzPath);
85
+ });
86
+
87
+ it("when 已有同版本文件但字节不同 Then 报冲突并提示升版本", async () => {
88
+ const root = newProject();
89
+ const identity = testIdentity();
90
+ const target = join(root, "cdn", "demo-solution-0.1.0.tgz");
91
+ writeFileSync(target, "not-a-real-tgz-but-different-bytes", "utf-8");
92
+
93
+ await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem }))
94
+ .rejects
95
+ .toThrow(/version/);
96
+ });
97
+ });
98
+
99
+ describe("given 签名身份缺失 When 发布", () => {
100
+ it("then 抛人话错误,不产出半截包", async () => {
101
+ const root = newProject();
102
+
103
+ await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0" }))
104
+ .rejects
105
+ .toThrow(/-id/);
106
+
107
+ expect(existsSync(join(root, "cdn", "demo-solution-0.1.0.tgz"))).toBe(false);
108
+ });
109
+ });