@oj-module/cli 0.2.66 → 0.2.68

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 (200) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +38 -4
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +146 -24
  7. package/devkit/menu-rules.md +19 -4
  8. package/devkit/module-dev-and-cdn-playground.md +4 -0
  9. package/devkit/publish-walkthrough.md +132 -37
  10. package/devkit/wecom-internal-app.md +8 -3
  11. package/devkit/wecom-multi-corp-links.md +163 -0
  12. package/package.json +2 -2
  13. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  14. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
  15. package/shell-dist/index.html +4 -4
  16. package/shell-dist/versions.json +3 -3
  17. package/src/__tests__/cli.test.ts +31 -0
  18. package/src/__tests__/new-scaffold.test.ts +13 -0
  19. package/src/__tests__/publish-keygen.test.ts +157 -0
  20. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  21. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  22. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  23. package/src/build/module-def.ts +1 -0
  24. package/src/builtin.ts +3 -2
  25. package/src/bundle.ts +8 -3
  26. package/src/cli.ts +21 -5
  27. package/src/dev.ts +15 -10
  28. package/src/init.ts +47 -41
  29. package/src/install.ts +11 -4
  30. package/src/keygen.ts +54 -0
  31. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  32. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  33. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  36. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  37. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  38. package/src/manifest-contract.ts +91 -8
  39. package/src/market/bootstrap.ts +26 -2
  40. package/src/market/command.ts +1 -0
  41. package/src/market/publish.ts +8 -1
  42. package/src/market/walkthrough.ts +24 -8
  43. package/src/new.ts +2 -0
  44. package/src/scaffold-guard.ts +227 -0
  45. package/src/site.ts +2 -0
  46. package/src/usage.ts +13 -9
  47. package/src/vendor.ts +5 -1
  48. package/templates/config.yaml +4 -2
  49. package/templates/init.include.yaml +10 -3
  50. package/templates/openapi.json +460 -1
  51. package/templates/src/README.md +37 -0
  52. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  53. package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
  54. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  55. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  56. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  57. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  58. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  59. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  60. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  61. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  62. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  63. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
  64. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  65. package/templates/src/app/api/_platform/market/api.ts +235 -10
  66. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  67. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  68. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  69. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  70. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  71. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  72. package/templates/src/app/api/_platform/schema.yaml +18 -0
  73. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  74. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  75. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  76. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  77. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  78. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  79. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  80. package/templates/src/app/api/home/seed.pg.sql +71 -0
  81. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  82. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  83. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  84. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  85. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  86. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  87. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  88. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  89. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  90. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  91. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  92. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  93. package/templates/src/app/api/publish/api.ts +349 -0
  94. package/templates/src/app/api/publish/manifest.yaml +8 -0
  95. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  96. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  97. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  98. package/templates/src/app/api/publish/schema.yaml +33 -0
  99. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  100. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  101. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  102. package/templates/src/app/web/entry.ts +9 -5
  103. package/templates/src/app/web/home/routes.ts +5 -0
  104. package/templates/src/app/web/login/locales/en-US.json +7 -1
  105. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  106. package/templates/src/app/web/login/pages/login.tsx +79 -86
  107. package/templates/src/app/web/login/routes.ts +54 -1
  108. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  109. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  110. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  111. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  112. package/templates/src/app/web/system/locales/en-US.json +1 -8
  113. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  114. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  115. package/templates/src/app/web/system/routes.ts +0 -6
  116. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  117. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  118. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  119. package/templates/src/oj-market/web/api/client.ts +3 -0
  120. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  121. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  122. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  123. package/templates/src/oj-market/web/entry.ts +31 -2
  124. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  125. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  126. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  127. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  128. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  129. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  130. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  131. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  132. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  133. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  134. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  135. package/templates/src/oj-market/web/types.ts +30 -0
  136. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  137. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  138. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  139. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  140. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  141. package/templates/tests/lifecycle/register.spec.ts +22 -0
  142. package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
  143. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  144. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  145. package/templates/tests/publish-credential.spec.ts +354 -0
  146. package/templates/tests/publish-identity.spec.ts +44 -0
  147. package/templates/tests/rbac-solution-perms.spec.ts +48 -3
  148. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  149. package/templates/tests/wecom-landing.spec.ts +63 -1
  150. package/templates/tsconfig.paths.json +1 -0
  151. package/templates/web.config.ts +8 -4
  152. package/vendor/host-versions.json +3 -3
  153. package/templates/seed.mysql.sql +0 -6
  154. package/templates/seed.pg.sql +0 -6
  155. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  156. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  157. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  158. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  159. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  160. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  161. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  162. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  163. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  164. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  165. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  166. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  168. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  169. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  170. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  171. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  172. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  173. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  174. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  175. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  176. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  177. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  178. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  179. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  180. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  181. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  182. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  183. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  184. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  185. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  186. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  187. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  188. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  189. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  190. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  191. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  192. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  193. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  194. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  195. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  196. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  197. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  198. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  199. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  200. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -108,10 +108,12 @@ const CATALOG = JSON.stringify({
108
108
  { name: "wiki", shards: [1] },
109
109
  ],
110
110
  });
111
+ /** 最新版详细描述(C2-05;喂给 index 聚合断言,验证卡片能读到方案「提供什么」) */
112
+ const LATEST_DESCRIPTION = "演示方案:提供示例 CRUD 与菜单路由,覆盖安装、升级、回滚与历史留痕全链路。";
111
113
  /** 分片正文(纯内容无 meta:版本条目即 Form B manifest——原 manifests.yaml 聚合清单并入分片) */
112
114
  const SHARD_1 = JSON.stringify({
113
115
  solutions: [
114
- { name: "demo-solution", latest: "0.2.0", versions: [manifestFixture("0.2.0"), manifestFixture("0.1.0")] },
116
+ { name: "demo-solution", latest: "0.2.0", versions: [{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION }, manifestFixture("0.1.0")] },
115
117
  { name: "wiki", latest: "1.3.0", versions: [{ name: "wiki", version: "1.3.0", archive: { file: "wiki-1.3.0.tgz", sha256: "x", size: 1 } }] },
116
118
  ],
117
119
  });
@@ -205,9 +207,15 @@ function bundleExec(): ExecFace {
205
207
  };
206
208
  }
207
209
 
208
- const reloadStub: ReloadFace = {
209
- reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }),
210
- };
210
+ // reload 桩:按被安装版本产出 applied 模块符号(+name@ver,v0.1.122 双符号契约),
211
+ // 供 reloadAndAssert 终态校验(R18)。默认 0.1.0 覆盖绝大多数用例;跨片索引用例(REG-CS5)
212
+ // 安装 1.0.2499 / 1.0.0,须按实装版本注入对应桩,否则符号不匹配 → reloading 段判 partial。
213
+ function reloadStubFor(version: string): ReloadFace {
214
+ return {
215
+ reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }),
216
+ };
217
+ }
218
+ const reloadStub: ReloadFace = reloadStubFor("0.1.0");
211
219
 
212
220
  function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
213
221
  return {
@@ -247,8 +255,8 @@ beforeEach(async () => {
247
255
  modulesJsonPath = join(root, "dist", "modules.json");
248
256
  dbPath = join(root, "db.sqlite");
249
257
  await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
250
- // 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
251
- await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
258
+ // 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
259
+ await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
252
260
  g.fs = nodeFsFace();
253
261
  dbo = installDb();
254
262
  dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
@@ -819,7 +827,7 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
819
827
  expect(String(last?.msg)).toContain("未配置市场地址");
820
828
  });
821
829
 
822
- it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
830
+ it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/publisher_key/license/summary/description/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
823
831
  const env = makeEnv();
824
832
  const state = await createStateStore(env.statePath);
825
833
  await state.write({
@@ -835,9 +843,9 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
835
843
  // REG-CS5④:读面不再发 cache-control: no-cache(服务端缓存头 + 条件请求承载新鲜度)
836
844
  expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
837
845
  expect(last?.data).toEqual([
838
- { name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", license: "MIT", trust_state: "trusted", key_id: "test.publisher", operator: null },
839
- { name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
840
- { name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
846
+ { name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", publisher_key: "test.publisher", license: "MIT", summary: "演示方案", description: LATEST_DESCRIPTION, trust_state: "trusted", key_id: "test.publisher", operator: null },
847
+ { name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
848
+ { name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
841
849
  ]);
842
850
  });
843
851
 
@@ -1190,7 +1198,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
1190
1198
  }
1191
1199
 
1192
1200
  it("BDD① 装箱分片:2500 版本跨 3 片,latest 安装只拉最高片(≤ 1 catalog + 1 shard)→ done", async () => {
1193
- const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
1201
+ const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.2499") }));
1194
1202
  fetchCalls.length = 0;
1195
1203
  g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
1196
1204
  await api.install.post();
@@ -1208,7 +1216,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
1208
1216
  });
1209
1217
 
1210
1218
  it("BDD① 回落低片:显式指定最低版本 → 先试最高片未中,逐片回落到 1 号片命中 → done", async () => {
1211
- const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
1219
+ const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.0") }));
1212
1220
  fetchCalls.length = 0;
1213
1221
  g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "1.0.0", source: "cdn" } };
1214
1222
  await api.install.post();
@@ -1554,3 +1562,147 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
1554
1562
  }
1555
1563
  });
1556
1564
  });
1565
+
1566
+ // ── 发布历史(GET /market/releases)────────────────────────────────────────────
1567
+ // 数据源是签名索引本身:每次读先把索引里出现过的 product@version 幂等补进 market_release,
1568
+ // 再按发布时间倒序分页。索引读不到不阻断查询(返回已入库行 + syncError)。
1569
+
1570
+ interface ReleasePageData {
1571
+ list: Array<{ id: number, product: string, version: string, published_at: number, published_at_source: string, yanked: number }>
1572
+ total: number
1573
+ page: number
1574
+ size: number
1575
+ syncError: string | null
1576
+ }
1577
+
1578
+ /** HEAD 可读 Last-Modified 的 CDN 面(其余请求复用 cdnFetch 的完整夹具) */
1579
+ function headAwareFetch(lastModified: string): FetchFace {
1580
+ return (async (url: string, init?: { method?: string }) => {
1581
+ if (init?.method === "HEAD") {
1582
+ return {
1583
+ ok: true,
1584
+ status: 200,
1585
+ arrayBuffer: async () => new ArrayBuffer(0),
1586
+ headers: { get: (name: string) => name.toLowerCase() === "last-modified" ? lastModified : null },
1587
+ };
1588
+ }
1589
+ return cdnFetch(url);
1590
+ }) as FetchFace;
1591
+ }
1592
+
1593
+ /** 换一版分片正文(并按其字节重签,seq 与 catalog 同代),用于构造「索引说已下线」) */
1594
+ function shardWithYanked(): { body: string, sig: string } {
1595
+ const body = JSON.stringify({
1596
+ solutions: [{
1597
+ name: "demo-solution",
1598
+ latest: "0.2.0",
1599
+ versions: [
1600
+ { ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION },
1601
+ { ...manifestFixture("0.1.0"), yanked: true },
1602
+ ],
1603
+ }],
1604
+ });
1605
+ return { body, sig: JSON.stringify(signIndex("shard", SEQ, body, TEST_KEY.privateKeyPem, "test.publisher")) };
1606
+ }
1607
+
1608
+ describe("GET /market/releases(发布历史:索引对账落库 + 分页)", () => {
1609
+ it("首读把索引里全部 product@version 补进历史:产品/版本/发布时间齐全,无 HEAD 头时回落索引 issued_at(source=index)", async () => {
1610
+ const api = createMarketApi(makeEnv());
1611
+ g.http = { user: { id: 1 } };
1612
+ await api.releases.get();
1613
+ expect(last?.status).toBe(200);
1614
+ const page = last?.data as ReleasePageData;
1615
+ expect(page.syncError).toBeNull();
1616
+ expect(page.total).toBe(3);
1617
+ expect(page.list.map(r => `${r.product}@${r.version}`)).toEqual(["wiki@1.3.0", "demo-solution@0.1.0", "demo-solution@0.2.0"]);
1618
+ expect(page.list.every(r => r.published_at === ISSUED_AT && r.published_at_source === "index")).toBe(true);
1619
+ // 每行先试 HEAD(拿真正的落 CDN 时刻),失败才回落——不是完全不问源
1620
+ expect(fetchInits.some(i => (i as { method?: string } | undefined)?.method === "HEAD")).toBe(true);
1621
+ });
1622
+
1623
+ it("HEAD 拿得到 Last-Modified → 用文件时间并标 source=file(索引时间只作兜底,不冒充精确)", async () => {
1624
+ const api = createMarketApi(makeEnv({ fetch: headAwareFetch("Wed, 01 Oct 2025 00:00:00 GMT") }));
1625
+ g.http = { user: { id: 1 } };
1626
+ await api.releases.get();
1627
+ expect(last?.status).toBe(200);
1628
+ const page = last?.data as ReleasePageData;
1629
+ const expected = Math.floor(Date.parse("Wed, 01 Oct 2025 00:00:00 GMT") / 1000);
1630
+ expect(page.list.every(r => r.published_at === expected && r.published_at_source === "file")).toBe(true);
1631
+ });
1632
+
1633
+ it("幂等 + append-only:二次读不重复插入,已入库的 published_at 不被索引时间改写", async () => {
1634
+ // 预置一行「上次记录的历史」:发布时间是事实,哪怕与本次索引的时间不同也不该被刷新
1635
+ dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
1636
+ .run("demo-solution", "0.2.0", 1_600_000_000, "file", 0, 1_600_000_000);
1637
+ const api = createMarketApi(makeEnv());
1638
+ g.http = { user: { id: 1 } };
1639
+ await api.releases.get();
1640
+ await api.releases.get();
1641
+ expect(last?.status).toBe(200);
1642
+ const page = last?.data as ReleasePageData;
1643
+ expect(page.total).toBe(3);
1644
+ const rows = dbo.prepare("SELECT product, version, published_at FROM market_release ORDER BY product, version").all() as Array<{ product: string, version: string, published_at: number }>;
1645
+ expect(rows).toHaveLength(3);
1646
+ expect(rows.find(r => r.product === "demo-solution" && r.version === "0.2.0")?.published_at).toBe(1_600_000_000);
1647
+ });
1648
+
1649
+ it("yanked 跟随索引(0→1 与 1→0 双向):下线是当前状态,不是历史事实的改写", async () => {
1650
+ // 1→0:库里记着已下线,索引又回到在架
1651
+ dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
1652
+ .run("demo-solution", "0.2.0", ISSUED_AT, "index", 1, ISSUED_AT);
1653
+ const api = createMarketApi(makeEnv());
1654
+ g.http = { user: { id: 1 } };
1655
+ await api.releases.get();
1656
+ expect(last?.status).toBe(200);
1657
+ expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.2.0") as { yanked: number }).yanked).toBe(0);
1658
+
1659
+ // 0→1:换一版已签名的分片,其中 0.1.0 标了 yanked
1660
+ const yanked = shardWithYanked();
1661
+ const fetchFn = (async (url: string) => {
1662
+ if (String(url).endsWith("shards/1.yaml.sig"))
1663
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.sig).buffer as ArrayBuffer };
1664
+ if (String(url).endsWith("shards/1.yaml"))
1665
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.body).buffer as ArrayBuffer };
1666
+ return cdnFetch(url);
1667
+ }) as FetchFace;
1668
+ const api2 = createMarketApi(makeEnv({ fetch: fetchFn }));
1669
+ g.http = { user: { id: 1 } };
1670
+ await api2.releases.get();
1671
+ expect(last?.status).toBe(200);
1672
+ expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.1.0") as { yanked: number }).yanked).toBe(1);
1673
+ });
1674
+
1675
+ it("索引读不到(验签失败)→ 200 + 已入库历史 + syncError 人话(观测面不阻断页面)", async () => {
1676
+ const api = createMarketApi(makeEnv());
1677
+ g.http = { user: { id: 1 } };
1678
+ await api.releases.get();
1679
+ expect((last?.data as ReleasePageData).total).toBe(3);
1680
+
1681
+ const brokenFetch = (async (url: string) => {
1682
+ if (String(url).endsWith("shards/1.yaml.sig"))
1683
+ return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
1684
+ return cdnFetch(url);
1685
+ }) as FetchFace;
1686
+ const api2 = createMarketApi(makeEnv({ fetch: brokenFetch }));
1687
+ g.http = { user: { id: 1 } };
1688
+ await api2.releases.get();
1689
+ expect(last?.status).toBe(200);
1690
+ const page = last?.data as ReleasePageData;
1691
+ expect(page.total).toBe(3);
1692
+ expect(page.syncError).toContain("index_sig_missing");
1693
+ });
1694
+
1695
+ it("门与分页:匿名 401;page/limit 透传并按发布时间倒序取页", async () => {
1696
+ const api = createMarketApi(makeEnv());
1697
+ g.http = {};
1698
+ await api.releases.get();
1699
+ expect(last?.status).toBe(401);
1700
+
1701
+ g.http = { user: { id: 1 }, query: { page: "2", limit: "1" } };
1702
+ await api.releases.get();
1703
+ expect(last?.status).toBe(200);
1704
+ const page = last?.data as ReleasePageData;
1705
+ expect(page).toMatchObject({ page: 2, size: 1, total: 3 });
1706
+ expect(page.list).toHaveLength(1);
1707
+ });
1708
+ });
@@ -305,3 +305,71 @@ describe("preflight · C2 夹具(双端唯一真源)", () => {
305
305
  }
306
306
  });
307
307
  });
308
+
309
+ describe("p0 §3.9 宿主能力门禁(写锁前失败,零痕迹)", () => {
310
+ it("老 oj(capabilities 不含所需)+ 新模板声明 requiresCapabilities → 写锁前 failed(host_capability),retryable=false", async () => {
311
+ const m = structuredClone(VALID);
312
+ m.requiresCapabilities = ["reload.modules_in_key", "anon.per_generation"];
313
+ const r = await preflight(ctx({ manifest: m, capabilities: [] }));
314
+ expect(r.ok).toBe(false);
315
+ expect(r.code).toBe("host_capability");
316
+ expect(r.retryable).toBe(false);
317
+ expect(r.message).toContain("reload.modules_in_key");
318
+ });
319
+
320
+ it("宿主 capability 未上报(undefined)+ 模板声明 → fail-closed 同拒", async () => {
321
+ const m = structuredClone(VALID);
322
+ m.requiresCapabilities = ["reload.api"];
323
+ const r = await preflight(ctx({ manifest: m })); // 无 capabilities
324
+ expect(r.ok).toBe(false);
325
+ expect(r.code).toBe("host_capability");
326
+ });
327
+
328
+ it("宿主具备所需能力 → 放行", async () => {
329
+ const m = structuredClone(VALID);
330
+ m.requiresCapabilities = ["reload.api", "reload.modules_in_key"];
331
+ const r = await preflight(ctx({ manifest: m, capabilities: ["reload.api", "reload.modules_in_key", "anon.per_generation"] }));
332
+ expect(r.ok).toBe(true);
333
+ });
334
+
335
+ it("既有模板不声明 requiresCapabilities → 门禁恒放行(不依赖上游 US-1 即先落地)", async () => {
336
+ const r = await preflight(ctx({ capabilities: [] }));
337
+ expect(r.ok).toBe(true);
338
+ });
339
+ });
340
+
341
+ describe("p0 §3.2 质辩1 / E7·E8 匿名保留段与残留检测(写锁前拒装)", () => {
342
+ it("e7 方案声明保留段匿名条目 → 拒装(C2-26)", async () => {
343
+ const m = structuredClone(VALID);
344
+ m.api.anonymous = ["_platform/health"];
345
+ const r = await preflight(ctx({ manifest: m }));
346
+ expect(r.ok).toBe(false);
347
+ expect(r.message).toContain("C2-26");
348
+ expect(r.message).toContain("_platform");
349
+ });
350
+
351
+ it("e8 config 存在未被本 manifest 声明的同 prefix 匿名残留 → 拒装(anonymous_residual)", async () => {
352
+ const m = structuredClone(VALID);
353
+ m.api.anonymous = ["demo-solution/ping"];
354
+ const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/demo-solution/legacy"] }));
355
+ expect(r.ok).toBe(false);
356
+ expect(r.code).toBe("anonymous_residual");
357
+ expect(r.message).toContain("/demo-solution/legacy");
358
+ });
359
+
360
+ it("e8 config 残留条目恰为本 manifest 已声明 → 不误伤放行", async () => {
361
+ const m = structuredClone(VALID);
362
+ m.api.anonymous = ["demo-solution/ping"];
363
+ const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/demo-solution/ping"] }));
364
+ expect(r.ok).toBe(true);
365
+ });
366
+
367
+ it("e8 config 残留指向保留段 → 不误伤放行(保留段属宿主内建,方案无法注入,非方案残留)", async () => {
368
+ const m = structuredClone(VALID);
369
+ m.api.anonymous = ["demo-solution/ping"];
370
+ // /_platform/health 是宿主内建匿名端点(如 /auth/login),方案 C2-26 已挡住声明保留段匿名,
371
+ // 故 config 里的保留段条目绝不会是方案残留——纳入比对只会误伤宿主内建,须放行。
372
+ const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/_platform/health", "/auth/login"] }));
373
+ expect(r.ok).toBe(true);
374
+ });
375
+ });
@@ -8,6 +8,7 @@
8
8
  /* eslint-disable test/prefer-lowercase-title -- 任务/AC 编号是验收锚点,保留大写(DB/P1-5) */
9
9
  import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
10
10
 
11
+ import { declOf } from "../../src/app/api/_platform/_lifecycle/installer";
11
12
  import { checkOccupancy, queryAllRegistered, queryRegistered, registerSolution } from "../../src/app/api/_platform/_lifecycle/steps/register";
12
13
  import { installDb } from "../fixture";
13
14
 
@@ -192,3 +193,24 @@ describe("审计:auditOp 落 solution.register", () => {
192
193
  expect(JSON.parse(String(r.detail))).toEqual({ routes: 2, settings: 2, deprecated: [] });
193
194
  });
194
195
  });
196
+
197
+ describe("C2 manifest → SolutionDecl 映射(declOf,installer 侧纯函数)", () => {
198
+ it("permissions[].title_key 透传为对象数组(R2 展示锚:C2-15 收藏的字段不得在数据面丢弃)", () => {
199
+ const decl = declOf({
200
+ name: "wiki",
201
+ version: "1.0.0",
202
+ manifest: {
203
+ permissions: [
204
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
205
+ { code: "wiki.doc.export", title_key: "wiki:perm.doc_export" },
206
+ ],
207
+ roles: [{ name: "wiki.reader", title_key: "wiki:role.reader", permissions: ["wiki.doc.read"] }],
208
+ },
209
+ } as any);
210
+ expect(decl.permissions).toEqual([
211
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
212
+ { code: "wiki.doc.export", title_key: "wiki:perm.doc_export" },
213
+ ]);
214
+ expect(decl.roles[0].title_key).toBe("wiki:role.reader");
215
+ });
216
+ });
@@ -1,20 +1,11 @@
1
1
  // BDD:solution 角色 title 落库时同步翻译(R1–R6,计划 docs/plans/20261103-0948-role-title-translate)。
2
2
  // title = 解析文本(站点默认语言)、title_key = 原文(UI 实时翻译锚);解析失败回退裸 key,
3
- // 绝不失败安装(R1);升级元数据覆盖 = 重解析(R3);迁移 0018 对存量 solution 角色回填、
4
- // builtin 不动(R2/AC4);sys_permission 不变(R5);name/code/绑定不变(R6)。
3
+ // 绝不失败安装(R1);升级元数据覆盖 = 重解析(R3);title_key 列由 0001 基线建表即带(R2/AC4);sys_permission 不变(R5);name/code/绑定不变(R6)。
5
4
  /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写(register.spec 同约定) */
6
- import { createRequire } from "node:module";
7
- import { fileURLToPath } from "node:url";
8
5
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
6
  import { registerSolution, resolveDeclTitles, siteLanguage } from "../../src/app/api/_platform/_lifecycle/steps/register";
10
7
  import { installDb } from "../fixture";
11
8
 
12
- const req = createRequire(import.meta.url);
13
- const { DatabaseSync } = req("node:sqlite");
14
- const { readFileSync } = req("node:fs");
15
- const { dirname, join } = req("node:path");
16
-
17
- const HERE = dirname(fileURLToPath(import.meta.url));
18
9
  const enc = new TextEncoder();
19
10
 
20
11
  let dbo: ReturnType<typeof installDb>;
@@ -60,8 +51,8 @@ function pmDecl(title: string) {
60
51
  return {
61
52
  module: "pm",
62
53
  version: "1.0.0",
63
- roles: [{ name: "pm.sales", title, title_key: title, permissions: ["pm.doc.create"] as string[] }],
64
- permissions: ["pm.doc.create"],
54
+ roles: [{ name: "pm.sales", title, title_key: title, permissions: ["pm.doc.create"] }],
55
+ permissions: [{ code: "pm.doc.create" }],
65
56
  };
66
57
  }
67
58
 
@@ -142,30 +133,9 @@ describe("R1–R3 落库(registerSolution 消费解析后的 decl)", () => {
142
133
  });
143
134
  });
144
135
 
145
- describe("R2 迁移 0018(sys_role.title_key 回填)", () => {
136
+ describe("R2 sys_role.title_key 列(基线建表即带)", () => {
146
137
  it("AC4 夹具真实迁移后 sys_role 带 title_key 列(列进库 ↔ schema.yaml 一致守卫的库侧半边)", () => {
147
138
  const cols = (dbo.prepare("PRAGMA table_info(sys_role)").all() as Array<{ name: string }>).map(c => c.name);
148
139
  expect(cols).toContain("title_key");
149
140
  });
150
-
151
- it("AC4 存量 solution 角色按 key 形状回填,builtin 角色不动、title 原文不改", () => {
152
- const raw = new DatabaseSync(":memory:");
153
- raw.exec(`
154
- CREATE TABLE sys_role (
155
- name TEXT PRIMARY KEY, module TEXT NOT NULL, title TEXT, description TEXT,
156
- builtin INTEGER NOT NULL DEFAULT 0, deprecated INTEGER NOT NULL DEFAULT 0
157
- );
158
- INSERT INTO sys_role (name, module, title, builtin) VALUES
159
- ('pm.sales', 'pm', 'pm:role.sales', 0),
160
- ('wiki.admin', 'wiki', 'wiki:role.admin', 0),
161
- ('admin', 'framework', '系统管理员', 1);
162
- `);
163
- raw.exec(readFileSync(join(HERE, "../../src/app/api/_platform/migrations/0018__sys_role_title_key.sql"), "utf8"));
164
- const rows = raw.prepare("SELECT name, title, title_key FROM sys_role ORDER BY name").all() as Array<{ name: string, title: string, title_key: string | null }>;
165
- expect(rows).toEqual([
166
- { name: "admin", title: "系统管理员", title_key: null },
167
- { name: "pm.sales", title: "pm:role.sales", title_key: "pm:role.sales" },
168
- { name: "wiki.admin", title: "wiki:role.admin", title_key: "wiki:role.admin" },
169
- ]);
170
- });
171
141
  });
@@ -5,7 +5,7 @@
5
5
  import * as fsp from "node:fs/promises";
6
6
  import { describe, expect, it } from "vitest";
7
7
 
8
- import { appendAnonymousPaths, bumpInstallSeq, ensureReloadApi, hasAnchor, insertAnchor } from "../../src/app/api/_platform/_lifecycle/text-edit";
8
+ import { appendAnonymousPaths, ensureReloadApi, hasAnchor, insertAnchor, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
9
9
 
10
10
  describe("insertAnchor(R18)", () => {
11
11
  it("带注释的 config.yaml 插入后注释/缩进/空行/键序逐字保留", () => {
@@ -105,32 +105,6 @@ describe("ensureReloadApi(P1-10,D3 一次性注入集收口:S1-1 定稿无
105
105
  });
106
106
  });
107
107
 
108
- describe("bumpInstallSeq(B-11 reload 触发面:vars 安装序号自增,PRD E5 实测 vars 改动可热生效)", () => {
109
- it("0 → 1 自增,其余行(注释/缩进/键序)逐字保留", () => {
110
- const content = "mounts: []\nvars:\n ojm_install_seq: 0\n# 尾注释保留";
111
- const r = bumpInstallSeq(content);
112
- expect(r.seq).toBe(1);
113
- expect(r.content).toBe("mounts: []\nvars:\n ojm_install_seq: 1\n# 尾注释保留");
114
- });
115
-
116
- it("多次调用累加(N → N+1),行内尾注释原样保留", () => {
117
- const content = "vars:\n ojm_install_seq: 41 # 安装序号";
118
- const r1 = bumpInstallSeq(content);
119
- expect(r1.seq).toBe(42);
120
- expect(r1.content).toBe("vars:\n ojm_install_seq: 42 # 安装序号");
121
- const r2 = bumpInstallSeq(r1.content);
122
- expect(r2.seq).toBe(43);
123
- });
124
-
125
- it("键缺失 → 可读报错(用户删行 = 显式退出热更触发,安装器不猜位置)", () => {
126
- expect(() => bumpInstallSeq("vars:\n other: 1")).toThrowError(/ojm_install_seq/);
127
- });
128
-
129
- it("值非数字(行存在但形状不符)→ 视同缺失,可读报错不猜", () => {
130
- expect(() => bumpInstallSeq("vars:\n ojm_install_seq: abc")).toThrowError(/ojm_install_seq/);
131
- });
132
- });
133
-
134
108
  describe("模板 config.yaml 随发锚点(P1-16 接线②:ensureReloadApi 键缺失插入路径的闭环依据)", () => {
135
109
  it("模板 config.yaml 带整行 # ojm:reload 锚点;键在 → ensureReloadApi no-op", async () => {
136
110
  const cfg = await fsp.readFile(new URL("../../config.yaml", import.meta.url), "utf8");
@@ -175,3 +149,47 @@ describe("p2-15③ C2-26 anonymous 运行时接线(appendAnonymousPaths:conf
175
149
  expect(() => appendAnonymousPaths("server:\n reload_api: true\n", ["x/y"])).toThrow(/anonymous_paths/);
176
150
  });
177
151
  });
152
+
153
+ describe("removeAnonymousPaths(P0 §3.5 补偿清单:appendAnonymousPaths 逆操作)", () => {
154
+ const BASE = "auth:\n anonymous_paths:\n - /auth/login\n # 行内注释保留\n - /system/login-methods\n";
155
+
156
+ it("追加后移除 = 还原到追加前形态(注释/缩进/既有项逐字保留)", () => {
157
+ const added = appendAnonymousPaths(BASE, ["demo-solution/ping", "demo-solution/health"]);
158
+ const r = removeAnonymousPaths(added.content, ["demo-solution/ping", "demo-solution/health"]);
159
+ expect(r.removed).toEqual(["/demo-solution/ping", "/demo-solution/health"]);
160
+ expect(r.content).toBe(BASE);
161
+ });
162
+
163
+ it("只移除指定项,其余匿名条目不动", () => {
164
+ const added = appendAnonymousPaths(BASE, ["demo-solution/ping", "demo-solution/health"]);
165
+ const r = removeAnonymousPaths(added.content, ["demo-solution/ping"]);
166
+ expect(r.removed).toEqual(["/demo-solution/ping"]);
167
+ expect(r.content).toContain("- /demo-solution/health");
168
+ expect(r.content).toContain("- /auth/login");
169
+ expect(r.content).toContain("- /system/login-methods");
170
+ });
171
+
172
+ it("幂等:未含的路径不在列表内 → removed=[] 且内容不变", () => {
173
+ const added = appendAnonymousPaths(BASE, ["demo-solution/ping"]);
174
+ const r = removeAnonymousPaths(added.content, ["other/not-present"]);
175
+ expect(r.removed).toEqual([]);
176
+ expect(r.content).toBe(added.content);
177
+ });
178
+
179
+ it("连续两次移除同一批路径,第二次为 no-op(防重复副作用)", () => {
180
+ const added = appendAnonymousPaths(BASE, ["demo-solution/ping"]);
181
+ const once = removeAnonymousPaths(added.content, ["demo-solution/ping"]);
182
+ const twice = removeAnonymousPaths(once.content, ["demo-solution/ping"]);
183
+ expect(twice.removed).toEqual([]);
184
+ expect(twice.content).toBe(once.content);
185
+ });
186
+
187
+ it("键缺失 = 没东西可删,no-op(不报错)", () => {
188
+ const r = removeAnonymousPaths("server:\n reload_api: true\n", ["x/y"]);
189
+ expect(r.removed).toEqual([]);
190
+ });
191
+
192
+ it("空路径列表 = no-op", () => {
193
+ expect(removeAnonymousPaths(BASE, []).removed).toEqual([]);
194
+ });
195
+ });
@@ -196,6 +196,42 @@ describe("POST uninstall 卸载编排(superadmin,R9)", () => {
196
196
  });
197
197
  });
198
198
 
199
+ describe("Phase 4:卸载 reload 失败回滚(P0 §3.4 第5步,依赖 US-1 manifests 摘要驱动的装配面,本仓先落编排)", () => {
200
+ it("卸载摘锁后 reload 失败 → 锁写回(模块仍挂载) + ok:false + 版本目录不删(fail-closed 一致回滚)", async () => {
201
+ await seedInstalled();
202
+ const api = createMarketApi(makeEnv({
203
+ reload: {
204
+ reloadConfig: async () => {
205
+ throw new Error("reload boom");
206
+ },
207
+ },
208
+ }));
209
+ g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
210
+ await api.uninstall.post();
211
+ // 锁写回:模块仍挂载(other 保留 + demo-solution 恢复)
212
+ expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0", "other": "1.0.0" });
213
+ // 版本目录未删(先失可达性再失文件;reload 失败即停,不删文件)
214
+ await expect(fsp.readdir(join(sharedRoot, "demo-solution-0.1.0"))).resolves.toBeDefined();
215
+ // sidecar 未删(元数据一致回滚,非半卸)
216
+ const state = await createStateStore(statePath);
217
+ expect(state.read().solutions["demo-solution"]).toBeDefined();
218
+ expect(last?.status).toBe(200);
219
+ expect(last?.data).toMatchObject({ ok: false });
220
+ expect(String((last?.data as { message?: string } | undefined)?.message)).toContain("reload 失败");
221
+ });
222
+
223
+ it("卸载 reload 成功 → 版本目录正常移除、锁摘除(既有行为不破坏)", async () => {
224
+ await seedInstalled();
225
+ const api = createMarketApi(makeEnv({ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["-demo-solution@0.1.0"], restart_pending: [] }) } }));
226
+ g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
227
+ await api.uninstall.post();
228
+ expect(last?.status).toBe(200);
229
+ expect(last?.data).toMatchObject({ ok: true });
230
+ expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ other: "1.0.0" });
231
+ await expect(fsp.readdir(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toBeTruthy();
232
+ });
233
+ });
234
+
199
235
  describe("P3-3 卸载级联单一事务(fail-closed:中段失败整段回滚、锁不摘、无半残)", () => {
200
236
  it("权限级联中段失败 → 事务回滚(route/角色/权限零残)、锁/sidecar/modules/版本目录原样保留", async () => {
201
237
  await seedInstalled();