@oj-module/cli 0.2.69 → 0.2.71

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 (147) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/init.include.yaml +16 -11
  55. package/templates/openapi.json +3948 -3655
  56. package/templates/src/.ojm-builtin.json +13 -0
  57. package/templates/src/README.md +5 -2
  58. package/templates/src/app/api/_platform/README.md +3 -2
  59. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  60. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  61. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  62. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  63. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  64. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  65. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  66. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  67. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  68. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  69. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  70. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  71. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  72. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  73. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  75. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  79. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  81. package/templates/src/app/api/_platform/schema.yaml +8 -11
  82. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  83. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  84. package/templates/src/app/api/_platform/seed.sql +1 -1
  85. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  86. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
  87. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  88. package/templates/src/app/web/system/locales/en-US.json +15 -1
  89. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  90. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  91. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  92. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  93. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  94. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  95. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  96. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  97. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  98. package/templates/src/app/web/system/routes.ts +18 -1
  99. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  100. package/templates/src/oj-market/api/backfill.ts +221 -0
  101. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  102. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  103. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  104. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  105. package/templates/src/oj-market/api/manifest.yaml +8 -0
  106. package/templates/src/oj-market/solution.yaml +21 -0
  107. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  108. package/templates/src/oj-market/web/__client/index.ts +509 -0
  109. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  110. package/templates/src/oj-market/web/api/client.ts +6 -6
  111. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  112. package/templates/src/oj-market/web/entry.ts +5 -3
  113. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  114. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  115. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  116. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  117. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  118. package/templates/src/oj-market/web/routes.ts +12 -0
  119. package/templates/src/oj-market/web/types.ts +14 -8
  120. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  121. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  122. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  123. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  124. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  125. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  126. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  127. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  128. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  129. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  130. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  131. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  132. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  133. package/templates/tests/solutions-entry.spec.ts +55 -0
  134. package/templates/tsconfig.paths.json +1 -0
  135. package/templates/web.config.ts +6 -6
  136. package/vendor/host-versions.json +3 -3
  137. package/bin/oj +0 -4
  138. package/src/market/__tests__/build-index.test.ts +0 -641
  139. package/src/market/__tests__/sharding.test.ts +0 -90
  140. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  141. package/src/market/build-index.ts +0 -526
  142. package/src/market/index-builder.ts +0 -149
  143. package/src/market/sharding.ts +0 -94
  144. package/src/market/yank-ledger.ts +0 -91
  145. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  146. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  147. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -1,641 +0,0 @@
1
- // ojm market build-index spec(T4 + REG-CS5 三级化):扫描 cdn/*.tgz → 全量重建三级索引。
2
- //
3
- // 三条必守:① 结果只取决于目录里有什么(多次执行字节一致);② archive.file 逐字
4
- // `<name>-<version>.tgz`——安装侧 P2-11 会拿它挡索引投毒,写歪一个字符就会装不上;
5
- // ③ catalog 是提交点(红队 F2):写完分片还没提交时,盘上仍是上一代、且不残留半文件。
6
- import { Buffer } from "node:buffer";
7
- import { generateKeyPairSync } from "node:crypto";
8
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { gzipSync } from "node:zlib";
12
- import { describe, expect, it } from "vitest";
13
- import { parse as yamlParse, stringify as yamlStringify } from "yaml";
14
-
15
- import { createDeterministicTgz } from "../../deterministic-tar";
16
- import { verifyCanonical } from "../../lifecycle/sign";
17
- import { INDEX_TTL_SECONDS, indexCanonical } from "../../publish";
18
- import { buildIndex } from "../build-index";
19
- import { buildFixturePackage, solutionManifest } from "../fixture";
20
- import { compareSemver } from "../index-builder";
21
- import { markYanked, readYankLedger } from "../yank-ledger";
22
-
23
- const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
24
- const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
25
- const KEY_ID = "cdn.maintainer";
26
- /** 注入的重建时刻(epoch 秒;固定值使时效字段可断言,不随 wall clock 漂) */
27
- const NOW = 1_800_000_000;
28
-
29
- /** catalog 正文(三级索引的提交点:片钉 + 包名→片号映射 + seq/时效) */
30
- interface CatalogDoc {
31
- seq?: number
32
- issued_at?: number
33
- expires_at?: number
34
- shards?: Record<string, number>
35
- solutions?: Array<{ name: string, shards: number[] }>
36
- }
37
-
38
- /** 分片正文(纯内容:方案条目 + Form B 版本明细,无任何元字段) */
39
- interface ShardDoc {
40
- solutions?: Array<{ name: string, latest: string, versions: Array<Record<string, unknown>> }>
41
- }
42
-
43
- /** 索引 .sig 六字段(与 archive .sig 同构 + seq;REG-CS1 签名覆盖 kind 与 seq) */
44
- interface IndexSig {
45
- alg: string
46
- key_id: string
47
- public_key: string
48
- value: string
49
- canon: string
50
- seq: number
51
- }
52
-
53
- function testPem(): string {
54
- return generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
55
- }
56
-
57
- function newProject(): { root: string, cdn: string } {
58
- const root = mkdtempSync(join(tmpdir(), "ojm-market-index-"));
59
- mkdirSync(join(root, "api"), { recursive: true });
60
- writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
61
- const cdn = join(root, "cdn");
62
- mkdirSync(cdn, { recursive: true });
63
- return { root, cdn };
64
- }
65
-
66
- /** 把一个 fixture 方案打进 cdn 目录,返回 tgz 文件名 */
67
- async function putPackage(cdn: string, staging: string, name: string, version: string): Promise<string> {
68
- const pkg = buildFixturePackage({ stagingDir: staging, name, version });
69
- const tgz = join(cdn, `${name}-${version}.tgz`);
70
- await createDeterministicTgz(pkg.dir, tgz);
71
- return `${name}-${version}.tgz`;
72
- }
73
-
74
- /**
75
- * 手造「只含一份真实形态 manifest.yaml」的 tgz(体积实测用)。
76
- *
77
- * 为什么不全真打包:需求 §7 的 Form B 体积(535–684B/条)由 manifest 形状决定,
78
- * 与方案树里的 api/web 文件无关——readEmbeddedManifest 只读包内 manifest.yaml。
79
- * 2500 个全真 fixture 要把整棵树写 2500 遍,等的是 IO 不是被测的体积口径。
80
- * ustar 布局照抄 fixture.ts 的 buildEvilZipSlipTgz(mtime=0 确定性);readTgz 只读
81
- * name/size/typeflag、不校验 checksum,这份最小包与真包在读面等价。
82
- */
83
- function putManifestPackage(cdn: string, name: string, version: string): void {
84
- const content = Buffer.from(yamlStringify(solutionManifest(name, version), { lineWidth: 0 }), "utf-8");
85
- const header = Buffer.alloc(512);
86
- header.write("manifest.yaml", 0, "utf-8"); // name[100]
87
- header.write("0000644\0", 100); // mode
88
- header.write("0000000\0", 108); // uid
89
- header.write("0000000\0", 116); // gid
90
- header.write(`${content.length.toString(8).padStart(11, "0")}\0`, 124); // size[12]
91
- header.write("00000000000\0", 136); // mtime[12] 置 0(确定性)
92
- header.fill(0x20, 148, 156); // chksum 先填空格
93
- header.write("0", 156); // typeflag:普通文件
94
- header.write("ustar\0", 257); // magic
95
- header.write("00", 263); // version
96
- let sum = 0;
97
- for (const b of header)
98
- sum += b;
99
- header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148); // chksum[8]
100
- const pad = Math.ceil(content.length / 512) * 512 - content.length;
101
- const tar = Buffer.concat([header, content, Buffer.alloc(pad), Buffer.alloc(1024)]);
102
- writeFileSync(join(cdn, `${name}-${version}.tgz`), gzipSync(tar));
103
- }
104
-
105
- function readCatalog(cdn: string): CatalogDoc {
106
- return yamlParse(readFileSync(join(cdn, "catalog.yaml"), "utf-8")) as CatalogDoc;
107
- }
108
-
109
- function readShard(cdn: string, no: number): ShardDoc {
110
- return yamlParse(readFileSync(join(cdn, "shards", `${no}.yaml`), "utf-8")) as ShardDoc;
111
- }
112
-
113
- /**
114
- * 按 catalog 映射合并某方案的全部版本(读方口径:片号倒序,最高片优先——
115
- * latest 只从最高片取,版本明细跨片合并后按 semver 升序)。
116
- */
117
- function solutionOf(cdn: string, name: string): { latest: string, versions: Array<Record<string, unknown>> } {
118
- const entry = readCatalog(cdn).solutions!.find(s => s.name === name)!;
119
- const versions: Array<Record<string, unknown>> = [];
120
- let latest = "";
121
- for (const no of [...entry.shards].sort((a, b) => b - a)) {
122
- const sol = readShard(cdn, no).solutions!.find(s => s.name === name)!;
123
- if (latest === "")
124
- latest = sol.latest;
125
- versions.push(...sol.versions);
126
- }
127
- return { latest, versions: versions.sort((a, b) => compareSemver(String(a.version), String(b.version))) };
128
- }
129
-
130
- function sigOf(indexFile: string): IndexSig {
131
- return JSON.parse(readFileSync(`${indexFile}.sig`, "utf-8")) as IndexSig;
132
- }
133
-
134
- describe("compareSemver(S2-0 prerelease 序,与 _lifecycle/semver.ts 同源算法)", () => {
135
- it("预发布数字标识符按数值:1.0.0-10 > 1.0.0-2", () => {
136
- expect(compareSemver("1.0.0-10", "1.0.0-2")).toBe(1);
137
- expect(compareSemver("1.0.0-2", "1.0.0-10")).toBe(-1);
138
- });
139
-
140
- it("正式版 > 预发布版:1.0.0 > 1.0.0-rc.1", () => {
141
- expect(compareSemver("1.0.0", "1.0.0-rc.1")).toBe(1);
142
- expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(-1);
143
- });
144
-
145
- it("无预发布相等:1.0.0 == 1.0.0", () => {
146
- expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
147
- });
148
-
149
- it("缺省段视为 0:1.0 == 1.0.0", () => {
150
- expect(compareSemver("1.0", "1.0.0")).toBe(0);
151
- });
152
-
153
- it("预发布字母按字典序、数字 < 字母", () => {
154
- expect(compareSemver("1.0.0-alpha", "1.0.0-beta")).toBe(-1);
155
- expect(compareSemver("1.0.0-rc.1", "1.0.0-zeta")).toBe(-1);
156
- });
157
- });
158
-
159
- describe("given cdn/ 里有预发布版本 When 重建索引", () => {
160
- it("then latest 取最大(含预发布序:1.0.0 > 1.0.0-rc.1 > 1.0.0-2)", async () => {
161
- const { root, cdn } = newProject();
162
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
163
- await putPackage(cdn, staging, "demo-solution", "1.0.0-rc.1");
164
- await putPackage(cdn, staging, "demo-solution", "1.0.0-2");
165
- await putPackage(cdn, staging, "demo-solution", "1.0.0");
166
- await putPackage(cdn, staging, "demo-solution", "0.9.0");
167
-
168
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
169
-
170
- const demo = solutionOf(cdn, "demo-solution");
171
- expect(demo.latest).toBe("1.0.0");
172
- expect(demo.versions).toHaveLength(4);
173
- });
174
- });
175
-
176
- describe("given cdn/ 里有多个包与多个版本 When 重建索引", () => {
177
- it("then 按 semver 聚合:版本全留、latest 取最大", async () => {
178
- const { root, cdn } = newProject();
179
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
180
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
181
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
182
- await putPackage(cdn, staging, "other-solution", "0.3.0");
183
-
184
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
185
-
186
- expect(result.solutions).toBe(2);
187
- // 三个版本一片装得下:两个包同指 1 号片
188
- expect(readCatalog(cdn).solutions).toEqual([
189
- { name: "demo-solution", shards: [1] },
190
- { name: "other-solution", shards: [1] },
191
- ]);
192
- const demo = solutionOf(cdn, "demo-solution");
193
- expect(demo.latest).toBe("0.2.0");
194
- expect(demo.versions).toHaveLength(2);
195
- });
196
-
197
- it("then archive.file 逐字为 <name>-<version>.tgz(安装侧 P2-11 加固前置)", async () => {
198
- const { root, cdn } = newProject();
199
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
200
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
201
-
202
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
203
-
204
- const hit = solutionOf(cdn, "demo-solution").versions[0]!;
205
- const archive = hit.archive as Record<string, unknown>;
206
- expect(archive.file).toBe("demo-solution-0.1.0.tgz");
207
- expect(archive.sha256).toMatch(/^[0-9a-f]{64}$/);
208
- expect(archive.size).toBeGreaterThan(0);
209
- });
210
-
211
- it("then catalog 只记 包名→片号、版本明细全在片里(装一个包不拉全量)", async () => {
212
- const { root, cdn } = newProject();
213
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
214
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
215
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
216
- await putPackage(cdn, staging, "other-solution", "0.3.0");
217
-
218
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
219
-
220
- // 映射条目只有 name/shards 两个键——没有 versions、没有 latest
221
- for (const entry of readCatalog(cdn).solutions!)
222
- expect(Object.keys(entry).sort()).toEqual(["name", "shards"]);
223
- // 片正文是纯内容:无 seq / 时效字段(代次只由 .sig 与 catalog 承载)
224
- const shardText = readFileSync(join(cdn, "shards", "1.yaml"), "utf-8");
225
- for (const field of ["seq:", "issued_at:", "expires_at:"])
226
- expect(shardText).not.toContain(field);
227
- });
228
-
229
- it("then 索引只取决于目录内容:连续两次执行字节一致(幂等;时效按注入时刻,不随 wall clock 漂)", async () => {
230
- const { root, cdn } = newProject();
231
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
232
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
233
- const key = testPem();
234
-
235
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
236
- const first = {
237
- catalog: readFileSync(join(cdn, "catalog.yaml"), "utf-8"),
238
- catalogSig: readFileSync(join(cdn, "catalog.yaml.sig"), "utf-8"),
239
- shard: readFileSync(join(cdn, "shards", "1.yaml"), "utf-8"),
240
- shardSig: readFileSync(join(cdn, "shards", "1.yaml.sig"), "utf-8"),
241
- };
242
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
243
-
244
- expect(readFileSync(join(cdn, "catalog.yaml"), "utf-8")).toBe(first.catalog);
245
- expect(readFileSync(join(cdn, "catalog.yaml.sig"), "utf-8")).toBe(first.catalogSig);
246
- expect(readFileSync(join(cdn, "shards", "1.yaml"), "utf-8")).toBe(first.shard);
247
- expect(readFileSync(join(cdn, "shards", "1.yaml.sig"), "utf-8")).toBe(first.shardSig);
248
- });
249
- });
250
-
251
- describe("given 一个已重建过的索引 When 目录内容发生变化后再次重建", () => {
252
- it("then seq 单调 +1(REG-CS2),变了的片取新号、旧号提交后清扫", async () => {
253
- const { root, cdn } = newProject();
254
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
255
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
256
- const key = testPem();
257
-
258
- const first = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
259
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
260
- const second = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 3600 });
261
-
262
- expect(first.seq).toBe(1);
263
- expect(second.seq).toBe(2); // 只增不减——读方据此挡回放
264
- expect(second.issuedAt).toBe(NOW + 3600);
265
- const catalog = readCatalog(cdn);
266
- expect(catalog.seq).toBe(2);
267
- expect(catalog.shards).toEqual({ 2: 2 }); // 内容变了的片取全新号
268
- expect(existsSync(join(cdn, "shards", "1.yaml"))).toBe(false); // 旧号不再被引用,提交后清扫
269
- });
270
-
271
- it("then 内容未变则 seq 不变(幂等不产生代次漂移),时效仍刷新", async () => {
272
- const { root, cdn } = newProject();
273
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
274
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
275
- const key = testPem();
276
-
277
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
278
- const again = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 86400 });
279
-
280
- expect(again.seq).toBe(1);
281
- expect(again.issuedAt).toBe(NOW + 86400);
282
- expect(again.expiresAt).toBe(NOW + 86400 + INDEX_TTL_SECONDS);
283
- });
284
- });
285
-
286
- describe("given 一次普通重建 When 检查产出索引的时效字段", () => {
287
- it("then catalog 盖有 issued_at / expires_at,跨度恰为 INDEX_TTL_SECONDS(REG-CS2);分片是纯内容不盖", async () => {
288
- const { root, cdn } = newProject();
289
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
290
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
291
-
292
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW });
293
-
294
- const catalog = readCatalog(cdn);
295
- expect(catalog.issued_at).toBe(NOW);
296
- expect(catalog.expires_at).toBe(NOW + INDEX_TTL_SECONDS);
297
- expect(catalog.seq).toBe(1);
298
- expect(result.issuedAt).toBe(NOW);
299
- expect(result.expiresAt).toBe(NOW + INDEX_TTL_SECONDS);
300
- expect(readFileSync(join(cdn, "shards", "1.yaml"), "utf-8")).not.toContain("issued_at");
301
- });
302
- });
303
-
304
- describe("given cdn/ 为空 When 重建索引", () => {
305
- it("then 产出空索引(不是缺 Solutions 段)——空目录也能验、也能装", async () => {
306
- const { root, cdn } = newProject();
307
-
308
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
309
-
310
- expect(result.solutions).toBe(0);
311
- const catalog = readCatalog(cdn);
312
- expect(catalog.solutions).toEqual([]);
313
- expect(catalog.shards).toEqual({ 1: 1 }); // 空索引也产一个空片,读方「拉 catalog 即知天下」
314
- expect(readShard(cdn, 1).solutions).toEqual([]);
315
- });
316
- });
317
-
318
- describe("given cdn/ 里有一个没有内嵌 manifest 的包 When 重建索引", () => {
319
- it("then 跳过该包并给出可读警告,其余包照常入索引", async () => {
320
- const { root, cdn } = newProject();
321
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
322
- writeFileSync(join(cdn, "broken-0.1.0.tgz"), "garbage-not-a-tgz", "utf-8");
323
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
324
-
325
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
326
-
327
- expect(result.skipped).toContain("broken-0.1.0.tgz");
328
- expect(result.solutions).toBe(1);
329
- });
330
- });
331
-
332
- describe("given 包带包级 .sig 侧车 When 重建索引", () => {
333
- it("then 版本条目的 archive.signature 带上它(否则安装器按 sig_missing 拒装)", async () => {
334
- const { root, cdn } = newProject();
335
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
336
- const file = await putPackage(cdn, staging, "demo-solution", "0.1.0");
337
- writeFileSync(`${join(cdn, file)}.sig`, JSON.stringify({ alg: "Ed25519", key_id: KEY_ID, public_key: "ab", value: "cd", canon: "v2", registry_id: "test.registry" }), "utf-8");
338
-
339
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
340
-
341
- const hit = solutionOf(cdn, "demo-solution").versions[0]!;
342
- expect((hit.archive as Record<string, unknown>).signature).toMatchObject({ key_id: KEY_ID });
343
- });
344
- });
345
-
346
- describe("given 给了签名身份 When 重建索引", () => {
347
- it("then catalog 与各分片都产出可复核的 .sig(kind 各归各、片签代次 = catalog 钉值)", async () => {
348
- const { root, cdn } = newProject();
349
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
350
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
351
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
352
-
353
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
354
-
355
- const catalogText = readFileSync(join(cdn, "catalog.yaml"), "utf-8");
356
- const catalogSig = sigOf(join(cdn, "catalog.yaml"));
357
- expect(catalogSig.key_id).toBe(KEY_ID);
358
- expect(catalogSig.seq).toBe(1);
359
- expect(verifyCanonical(catalogSig.public_key, indexCanonical("catalog", catalogSig.seq, catalogText), catalogSig.value)).toBe(true);
360
- // 换代即失效——同内容换 seq 签不出来
361
- expect(verifyCanonical(catalogSig.public_key, indexCanonical("catalog", catalogSig.seq + 1, catalogText), catalogSig.value)).toBe(false);
362
- // kind 各归各(REG-CS1):catalog 正文按 shard 的 kind 验不过
363
- expect(verifyCanonical(catalogSig.public_key, indexCanonical("shard", catalogSig.seq, catalogText), catalogSig.value)).toBe(false);
364
-
365
- for (const [no, pin] of Object.entries(readCatalog(cdn).shards!)) {
366
- const shardText = readFileSync(join(cdn, "shards", `${no}.yaml`), "utf-8");
367
- const sig = sigOf(join(cdn, "shards", `${no}.yaml`));
368
- expect(sig.seq).toBe(pin); // 片签代次 = catalog 钉值(F2 同代校验的写侧保证)
369
- expect(verifyCanonical(sig.public_key, indexCanonical("shard", sig.seq, shardText), sig.value)).toBe(true);
370
- }
371
- });
372
- });
373
-
374
- describe("given 误删最高版本包 When 重建索引", () => {
375
- it("then 拒建:latest 回退违反 REG-CS2 单调不降(宁可拒建,也不产往回走的索引)", async () => {
376
- const { root, cdn } = newProject();
377
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
378
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
379
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
380
- const key = testPem();
381
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
382
- const before = readFileSync(join(cdn, "catalog.yaml"), "utf-8");
383
-
384
- rmSync(join(cdn, "demo-solution-0.2.0.tgz"));
385
- await expect(buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 60 }))
386
- .rejects
387
- .toThrow("latest 回退");
388
- // 拒建发生在落盘之前:上一代原样可读
389
- expect(readFileSync(join(cdn, "catalog.yaml"), "utf-8")).toBe(before);
390
- });
391
- });
392
-
393
- describe("rEG-CS4 yank(下线某版本:索引标记 + latest 跳过)", () => {
394
- it("yank 后该版本带 yanked:true + yanked_at,且 latest 跳到下一个未下线版本", async () => {
395
- const { root, cdn } = newProject();
396
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
397
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
398
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
399
- await putPackage(cdn, staging, "demo-solution", "0.3.0");
400
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
401
-
402
- const r = markYanked(root, "demo-solution", "0.3.0", { by: "admin", reason: "安全漏洞" });
403
- expect(r.created).toBe(true);
404
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW });
405
-
406
- const demo = solutionOf(cdn, "demo-solution");
407
- // latest 跳过已下线版本
408
- expect(demo.latest).toBe("0.2.0");
409
- const v030 = demo.versions.find(v => v.version === "0.3.0")!;
410
- expect(v030.yanked).toBe(true);
411
- expect(typeof v030.yanked_at).toBe("number");
412
- const v020 = demo.versions.find(v => v.version === "0.2.0")!;
413
- expect(v020.yanked).toBeUndefined();
414
- });
415
-
416
- it("重复 yank 幂等(不重复写、created=false),且 latest 仍正确跳过", async () => {
417
- const { root, cdn } = newProject();
418
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
419
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
420
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
421
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
422
-
423
- expect(markYanked(root, "demo-solution", "0.2.0", { by: "admin" }).created).toBe(true);
424
- expect(markYanked(root, "demo-solution", "0.2.0", { by: "admin" }).created).toBe(false);
425
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW });
426
-
427
- expect(solutionOf(cdn, "demo-solution").latest).toBe("0.1.0");
428
- // 账本文件未被改写(幂等):仍是单条
429
- expect(readYankLedger(root).size).toBe(1);
430
- });
431
-
432
- it("对 cdn 中不存在的版本 yank → 抛明确错误(不污染账本)", async () => {
433
- const { root, cdn } = newProject();
434
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
435
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
436
-
437
- let thrown: unknown;
438
- try {
439
- await markYanked(root, "demo-solution", "9.9.9", { by: "admin" });
440
- }
441
- catch (e) {
442
- thrown = e;
443
- }
444
- expect(thrown).toBeInstanceOf(Error);
445
- expect(String((thrown as Error).message)).toContain("无法下线");
446
- expect(readYankLedger(root).size).toBe(0);
447
- });
448
-
449
- it("全部版本下线 → latest 降级取最高版(索引不空,避免 latest 缺失引发下游崩溃)", async () => {
450
- const { root, cdn } = newProject();
451
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
452
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
453
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
454
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
455
-
456
- markYanked(root, "demo-solution", "0.1.0", { by: "admin" });
457
- markYanked(root, "demo-solution", "0.2.0", { by: "admin" });
458
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW });
459
-
460
- const demo = solutionOf(cdn, "demo-solution");
461
- expect(demo.latest).toBe("0.2.0");
462
- for (const v of demo.versions)
463
- expect(v.yanked).toBe(true);
464
- });
465
- });
466
-
467
- describe("given 2500 个版本、1 个包 When 重建索引(S2-1 BDD ①:超阈值自动分片)", () => {
468
- it("then 生成 3 个 shard 且每片 ≤1000 条,catalog 指向该包所在三片、latest 住在最高片;体积与内存有实测记录", async () => {
469
- const { root, cdn } = newProject();
470
- for (let i = 0; i < 2500; i++)
471
- putManifestPackage(cdn, "mega", `1.0.${i}`);
472
-
473
- // 内存实测(计划 §S2-1 验收「峰值内存有实测记录」):buildIndex 全程同步,进程内无法采样峰值;
474
- // 能量到也最有意义的两个数——① 构建后存活集(构建前后各收一次垃圾):中间对象是否真的被释放,
475
- // 这是需求 §7「中间对象 <100MB」的**可判定口径**(构建中最坏时点 packages + solutions + bodies
476
- // 同时存活,量级几十 MB;能全部释放即证明没有把全量数据攥在手里不放);② 构建引起的堆增长
477
- // (不做事后 gc):含未回收垃圾、随 V8 回收时机波动(155–242MB),**只记录不设闸**——
478
- // isolate 里堆逼近上限时 V8 会主动回收,预测 OOM 的是存活集不是增长量。
479
- // gc 由 vitest.config.ts 的 execArgv: ["--expose-gc"] 注入;lint 禁裸 global,走 globalThis
480
- const gc = (globalThis as { gc?: () => void }).gc;
481
- gc?.();
482
- const heapBefore = process.memoryUsage().heapUsed;
483
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW });
484
- const heapGrowth = process.memoryUsage().heapUsed - heapBefore;
485
- gc?.();
486
- const retained = process.memoryUsage().heapUsed - heapBefore;
487
-
488
- expect(result.shards).toBe(3);
489
- const catalog = readCatalog(cdn);
490
- expect(catalog.shards).toEqual({ 1: 1, 2: 1, 3: 1 });
491
- expect(catalog.solutions).toEqual([{ name: "mega", shards: [1, 2, 3] }]);
492
-
493
- const shard1 = readShard(cdn, 1).solutions![0]!;
494
- const shard2 = readShard(cdn, 2).solutions![0]!;
495
- const shard3 = readShard(cdn, 3).solutions![0]!;
496
- expect(shard1.versions).toHaveLength(1000);
497
- expect(shard2.versions).toHaveLength(1000);
498
- expect(shard3.versions).toHaveLength(500);
499
- // 最高片装最新版:latest 落在最后一片(安装 latest 只拉这一片,不请求另两片)
500
- expect(shard3.latest).toBe("1.0.2499");
501
- expect(shard1.versions.at(-1)!.version).toBe("1.0.999");
502
- expect(shard2.versions[0]!.version).toBe("1.0.1000");
503
- expect(shard2.versions.at(-1)!.version).toBe("1.0.1999");
504
- expect(shard3.versions[0]!.version).toBe("1.0.2000");
505
-
506
- // 合并视图:全集升序、跨片边界不重不漏
507
- const mega = solutionOf(cdn, "mega");
508
- expect(mega.latest).toBe("1.0.2499");
509
- expect(mega.versions).toHaveLength(2500);
510
- expect(mega.versions[0]!.version).toBe("1.0.0");
511
- expect(mega.versions[999]!.version).toBe("1.0.999");
512
- expect(mega.versions[1000]!.version).toBe("1.0.1000");
513
- expect(mega.versions[1999]!.version).toBe("1.0.1999");
514
- expect(mega.versions[2000]!.version).toBe("1.0.2000");
515
- expect(mega.versions.at(-1)!.version).toBe("1.0.2499");
516
- const asVersions = mega.versions.map(v => String(v.version));
517
- expect([...asVersions].sort((a, b) => compareSemver(a, b))).toEqual(asVersions);
518
-
519
- // 验收(计划 §S2-1):单片体积实测 ≤1.5MB(目标 1.2MB)——两个数都打进测试输出留痕
520
- const size = statSync(join(cdn, "shards", "1.yaml")).size;
521
- expect(size).toBeLessThanOrEqual(1.5 * 1024 * 1024);
522
- expect(retained).toBeLessThan(100 * 1024 * 1024); // 需求 §7:中间对象 <100MB(存活口径,构建后即释)
523
- console.log(`[REG-CS5 实测] 单片 ${(size / 1024).toFixed(0)}KB / 1000 条(目标 1.2MB、上限 1.5MB);构建存活集 ${(retained / 1048576).toFixed(1)}MB(<100MB)、堆增长 ${(heapGrowth / 1048576).toFixed(0)}MB(含未回收垃圾,仅记录)`);
524
- }, 30_000); // 体积用例:2500 个包一次全量重建,远超单测默认 5s
525
- });
526
-
527
- describe("given 内容未变的分片 When 追加新包后再次重建(REG-CS5 内容匹配编号)", () => {
528
- it("then 字节一致的片沿用旧号与旧代次(连签名都不重写),变了的片取新号、旧号提交后清扫", async () => {
529
- const { root, cdn } = newProject();
530
- const key = testPem();
531
- for (let i = 0; i < 600; i++)
532
- putManifestPackage(cdn, "aaa-solution", `1.0.${i}`);
533
- for (let i = 0; i < 400; i++)
534
- putManifestPackage(cdn, "bbb-solution", `1.0.${i}`);
535
-
536
- // 第 1 代:aaa+bbb 恰满 1000 条,同住 1 号片
537
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
538
- expect(readCatalog(cdn).shards).toEqual({ 1: 1 });
539
- expect(readCatalog(cdn).solutions).toEqual([
540
- { name: "aaa-solution", shards: [1] },
541
- { name: "bbb-solution", shards: [1] },
542
- ]);
543
- const shard1Before = readFileSync(join(cdn, "shards", "1.yaml"));
544
- const shard1Mtime = statSync(join(cdn, "shards", "1.yaml")).mtimeMs;
545
- const shard1SigBefore = readFileSync(join(cdn, "shards", "1.yaml.sig"), "utf-8");
546
-
547
- // 第 2 代:追加 ccc(500) 放不进 1 号片 → 开 2 号片;1 号片内容未变
548
- for (let i = 0; i < 500; i++)
549
- putManifestPackage(cdn, "ccc-solution", `1.0.${i}`);
550
- const second = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 60 });
551
- expect(second.seq).toBe(2);
552
- expect(readCatalog(cdn).shards).toEqual({ 1: 1, 2: 2 });
553
- expect(readCatalog(cdn).solutions).toEqual([
554
- { name: "aaa-solution", shards: [1] },
555
- { name: "bbb-solution", shards: [1] },
556
- { name: "ccc-solution", shards: [2] },
557
- ]);
558
- // 沿用片:正文、mtime、签名都没动(缓存热、ETag 稳定)
559
- expect(readFileSync(join(cdn, "shards", "1.yaml"))).toEqual(shard1Before);
560
- expect(statSync(join(cdn, "shards", "1.yaml")).mtimeMs).toBe(shard1Mtime);
561
- expect(readFileSync(join(cdn, "shards", "1.yaml.sig"), "utf-8")).toBe(shard1SigBefore);
562
-
563
- // 第 3 代:追加 ddd(500) 与 ccc 同槽 → 2 号片内容变了,取新号 3;旧 2 号片提交后清扫
564
- for (let i = 0; i < 500; i++)
565
- putManifestPackage(cdn, "ddd-solution", `1.0.${i}`);
566
- const third = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 120 });
567
- expect(third.seq).toBe(3);
568
- const catalog = readCatalog(cdn);
569
- expect(catalog.shards).toEqual({ 1: 1, 3: 3 }); // 未变片沿用 1 号旧代;变了的片取新号 3、接新代
570
- expect(catalog.solutions).toEqual([
571
- { name: "aaa-solution", shards: [1] },
572
- { name: "bbb-solution", shards: [1] },
573
- { name: "ccc-solution", shards: [3] },
574
- { name: "ddd-solution", shards: [3] },
575
- ]);
576
- // 1 号片自始至终没被重写
577
- expect(readFileSync(join(cdn, "shards", "1.yaml"))).toEqual(shard1Before);
578
- // 旧 2 号片不再被引用,提交后清扫;新 3 号片签名代次 = 钉值
579
- expect(existsSync(join(cdn, "shards", "2.yaml"))).toBe(false);
580
- expect(existsSync(join(cdn, "shards", "2.yaml.sig"))).toBe(false);
581
- expect(sigOf(join(cdn, "shards", "3.yaml")).seq).toBe(3);
582
- // 合并视图:四个包都读得到、latest 正确
583
- expect(solutionOf(cdn, "aaa-solution").latest).toBe("1.0.599");
584
- expect(solutionOf(cdn, "ccc-solution").versions).toHaveLength(500);
585
- expect(solutionOf(cdn, "ddd-solution").latest).toBe("1.0.499");
586
- }, 30_000); // 体积用例:2000 个包 + 3 次全量重建,远超单测默认 5s
587
- });
588
-
589
- describe("given 重建在写完分片、提交 catalog 前被打断 When 客户端此时来拉(红队 F2 / 计划 BDD ③)", () => {
590
- it("then 盘上仍是上一代:catalog 与旧分片字节不变、签名仍可复核,新分片回滚、无 tmp 残片", async () => {
591
- const { root, cdn } = newProject();
592
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
593
- const key = testPem();
594
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
595
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW });
596
- const gen1 = {
597
- catalog: readFileSync(join(cdn, "catalog.yaml"), "utf-8"),
598
- shard: readFileSync(join(cdn, "shards", "1.yaml"), "utf-8"),
599
- };
600
-
601
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
602
- await expect(buildIndex({
603
- projectRoot: root,
604
- id: KEY_ID,
605
- privateKeyPem: key,
606
- now: NOW + 60,
607
- afterShards: () => { throw new Error("模拟进程在分片写完后、catalog 提交前被杀"); },
608
- })).rejects.toThrow("模拟进程在分片写完后、catalog 提交前被杀");
609
-
610
- // 上一代原样可读、可验(catalog 未动 = 提交点语义)
611
- expect(readFileSync(join(cdn, "catalog.yaml"), "utf-8")).toBe(gen1.catalog);
612
- expect(readFileSync(join(cdn, "shards", "1.yaml"), "utf-8")).toBe(gen1.shard);
613
- const sig = sigOf(join(cdn, "catalog.yaml"));
614
- expect(verifyCanonical(sig.public_key, indexCanonical("catalog", sig.seq, gen1.catalog), sig.value)).toBe(true);
615
- // 新分片按「从未发生」回滚;tmp 残片扫净(没有可被当作索引读取的半文件)
616
- expect(existsSync(join(cdn, "shards", "2.yaml"))).toBe(false);
617
- for (const dir of [cdn, join(cdn, "shards")]) {
618
- for (const f of readdirSync(dir))
619
- expect(f).not.toContain(".tmp.");
620
- }
621
-
622
- // 恢复:干净重跑成功并进入第 2 代(旧代没被半写楔死)
623
- const recovered = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: key, now: NOW + 120 });
624
- expect(recovered.seq).toBe(2);
625
- expect(solutionOf(cdn, "demo-solution").latest).toBe("0.2.0");
626
- });
627
- });
628
-
629
- describe("given 重建后要离线自检 When 带 check", () => {
630
- it("then catalog 与各分片签名全部复核通过(checkPassed,且不污染进程退出码)", async () => {
631
- const { root, cdn } = newProject();
632
- const staging = mkdtempSync(join(tmpdir(), "ojm-stage-"));
633
- await putPackage(cdn, staging, "demo-solution", "0.1.0");
634
- await putPackage(cdn, staging, "demo-solution", "0.2.0");
635
-
636
- const result = await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem(), now: NOW, check: true });
637
-
638
- expect(result.checkPassed).toBe(true);
639
- expect(process.exitCode ?? 0).toBe(0);
640
- });
641
- });