@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
@@ -0,0 +1,52 @@
1
+ // 「发布管理」页的纯逻辑(无 React、无 IO)——状态颜色、可撤销判定、给用户的命令行。
2
+ // 抽出来的唯一理由:这三件事是页面里最该被钉死的口径,且都必须能脱离 antd 断言。
3
+
4
+ export type CredentialStatus = "active" | "used" | "revoked" | "expired";
5
+
6
+ /** 状态 → antd Tag 颜色。Record 全量约束:新增状态时这里编译期就缺键。 */
7
+ const STATUS_COLOR: Record<CredentialStatus, string> = {
8
+ active: "processing",
9
+ used: "success",
10
+ revoked: "default",
11
+ expired: "warning",
12
+ };
13
+
14
+ export function statusColor(status: CredentialStatus): string {
15
+ return STATUS_COLOR[status];
16
+ }
17
+
18
+ /**
19
+ * 能否撤销:只有「待兑换」可以。
20
+ *
21
+ * 兑换只下发 key_id,公钥是发布方本机生成的——站点手里没有那把钥匙,也就没有「撤回」可言;
22
+ * 撤回一条已兑换的凭证只会制造「以为撤了其实没撤」的错觉(后端的 400 文案就是这个意思,
23
+ * 真要停用只能到宿主信任库移除对应公钥)。按钮仍要渲染(禁用态),不用隐藏:
24
+ * 隐藏会让用户以为页面没做完。
25
+ */
26
+ export function canRevoke(status: string): boolean {
27
+ return status === "active";
28
+ }
29
+
30
+ /**
31
+ * 复制给用户的一行命令。
32
+ *
33
+ * 只带凭证,不带 `--site`:站点地址由 CLI 从本机配置或上次发布记录里取,
34
+ * 页面拼一个 origin 反而在反代/多域名部署下更容易错。
35
+ */
36
+ export function buildKeygenCommand(token: string): string {
37
+ return `ojm keygen --credential ${token}`;
38
+ }
39
+
40
+ /**
41
+ * 统一信封错误 → 人话。后端 fail(status, message) 的文案本身就是给用户看的,
42
+ * 故优先取 `msg`(ContractApiError),其次 Error.message。
43
+ *
44
+ * 与 system/pages/errors.ts 的 errMsg 同构但不互相 import:那是系统管理根内部模块,
45
+ * 让「发布管理」依赖它会把两个根的加载顺序绑在一起(多根聚合下互不依赖是硬约定)。
46
+ */
47
+ export function errorText(e: unknown): string {
48
+ const msg = (e as { msg?: unknown } | null)?.msg;
49
+ if (typeof msg === "string" && msg !== "")
50
+ return msg;
51
+ return e instanceof Error ? e.message : String(e);
52
+ }
@@ -0,0 +1,82 @@
1
+ import { Alert, Button, Input, Modal, Space, Typography } from "antd";
2
+ import { useState } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { buildKeygenCommand } from "./model";
6
+
7
+ /** 申请成功的返回值:明文只在这一刻存在 */
8
+ export interface IssuedCredential {
9
+ token: string
10
+ key_id: string
11
+ product: string
12
+ token_hint?: string
13
+ expires_at: number
14
+ }
15
+
16
+ /** 复制到剪贴板:非 https / 无权限时静默失败(输入框里的文本本来就能手动选中) */
17
+ async function copy(text: string): Promise<void> {
18
+ try {
19
+ await navigator.clipboard.writeText(text);
20
+ }
21
+ catch {
22
+ // 不打断流程:用户可直接在只读输入框里选中复制
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 一次性明文弹窗。
28
+ *
29
+ * 三条不可退让的约束:
30
+ * · 不渲染 = 没有。`issued` 为 null 时整个 Modal 不挂载,关闭后 DOM 里不再有明文
31
+ * (服务端只存 sha256,关掉就真的找不回来——所以文案必须先说清楚)。
32
+ * · 不可点遮罩/ESC 关闭:误触的代价是重新申请一张凭证。
33
+ * · 凭证与命令都是只读输入框 + 复制按钮:64 位 hex 手抄必错。
34
+ */
35
+ export function TokenModal({ issued, onClose }: { issued: IssuedCredential | null, onClose: () => void }) {
36
+ const { t } = useTranslation();
37
+ const [copied, setCopied] = useState<string | null>(null);
38
+ if (!issued)
39
+ return null;
40
+ const command = buildKeygenCommand(issued.token);
41
+ const copyRow = (testId: string, value: string) => (
42
+ <Space.Compact style={{ width: "100%", marginBottom: 16 }}>
43
+ <Input readOnly value={value} data-testid={testId} />
44
+ <Button
45
+ onClick={() => {
46
+ void copy(value);
47
+ setCopied(testId);
48
+ }}
49
+ >
50
+ {copied === testId ? t("publish:issued.copied") : t("publish:issued.copy")}
51
+ </Button>
52
+ </Space.Compact>
53
+ );
54
+
55
+ return (
56
+ <Modal
57
+ open
58
+ closable={false}
59
+ maskClosable={false}
60
+ keyboard={false}
61
+ title={t("publish:issued.title")}
62
+ footer={<Button type="primary" onClick={onClose}>{t("publish:issued.done")}</Button>}
63
+ >
64
+ <div data-testid="publish-issued">
65
+ <Alert type="warning" showIcon message={t("publish:issued.warning")} style={{ marginBottom: 16 }} />
66
+ <Typography.Paragraph>
67
+ <Typography.Text type="secondary">{t("publish:issued.identity")}</Typography.Text>
68
+ <br />
69
+ <Typography.Text copyable={{ text: issued.key_id }} code>{issued.key_id}</Typography.Text>
70
+ </Typography.Paragraph>
71
+ <Typography.Paragraph style={{ marginBottom: 4 }}>
72
+ <Typography.Text type="secondary">{t("publish:issued.token")}</Typography.Text>
73
+ </Typography.Paragraph>
74
+ {copyRow("publish-token", issued.token)}
75
+ <Typography.Paragraph style={{ marginBottom: 4 }}>
76
+ <Typography.Text type="secondary">{t("publish:issued.command")}</Typography.Text>
77
+ </Typography.Paragraph>
78
+ {copyRow("publish-command", command)}
79
+ </div>
80
+ </Modal>
81
+ );
82
+ }
@@ -0,0 +1,41 @@
1
+ import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
2
+
3
+ import { HistoryOutlined, KeyOutlined } from "@ant-design/icons";
4
+
5
+ import { createElement, lazy } from "react";
6
+
7
+ import { createPublishClient } from "./__client";
8
+
9
+ const PublishPage = lazy(() => import("./pages"));
10
+ const ReleaseHistoryPage = lazy(() => import("./pages/history"));
11
+
12
+ /**
13
+ * 「方案市场」下的两个子页(单模单根:菜单只有 `/oj-market` 一个根)。
14
+ *
15
+ * 路径是**相对父路由**声明的:`publish` → `/oj-market/publish`,`history` → `/oj-market/history`。
16
+ * 发布管理不挂 handle.roles:申请发布凭证是普通登录用户的能力,超管多出来的只是
17
+ * 「看到所有租户的凭证」,由后端按角色决定返回范围,前端不重复判权。
18
+ */
19
+ export const routes: AppRouteRecordRaw[] = [
20
+ {
21
+ path: "publish",
22
+ Component: PublishPage,
23
+ handle: {
24
+ title: "publish:menu.publish",
25
+ icon: createElement(KeyOutlined),
26
+ },
27
+ },
28
+ {
29
+ path: "history",
30
+ Component: ReleaseHistoryPage,
31
+ handle: {
32
+ title: "publish:menu.history",
33
+ icon: createElement(HistoryOutlined),
34
+ },
35
+ },
36
+ ];
37
+
38
+ /** 构造即接线:登记契约前缀 /publish + 绑定 scoped request,页面直接 import 的函数才有通道 */
39
+ export function onInit(ctx: ModuleContext): void {
40
+ createPublishClient(ctx);
41
+ }
@@ -61,6 +61,12 @@ export interface IndexRow {
61
61
  upgradable: boolean
62
62
  /** 最新版许可(SPDX 简化式,来自分片条目的 manifest.license;未声明 → null) */
63
63
  license?: string | null
64
+ /** 发布者(分片条目 archive.signature.key_id,经验签的签名身份;与后端 trust_state/key_id 同用 snake_case) */
65
+ publisher_key?: string | null
66
+ /** 方案简介(manifest.summary,C2-04 单行 1..200;索引未带该字段 → undefined/null) */
67
+ summary?: string | null
68
+ /** 方案详细描述(manifest.description,C2-05 ≤2000;索引未带该字段 → undefined/null) */
69
+ description?: string | null
64
70
  }
65
71
 
66
72
  /** 安装输入(POST /_platform/market/install) */
@@ -97,3 +103,27 @@ export interface UninstallImpact {
97
103
  affectedRoles: number
98
104
  affectedUsers: number
99
105
  }
106
+
107
+ /** 发布历史一行(`market_release`;来源是签名索引,追加式记录) */
108
+ export interface ReleaseRow {
109
+ id: number
110
+ product: string
111
+ version: string
112
+ key_id: string | null
113
+ sha256: string | null
114
+ size: number | null
115
+ published_at: number
116
+ /** file = 分发包 Last-Modified;index = 索引生成时间兜底 */
117
+ published_at_source: "file" | "index"
118
+ yanked: number
119
+ }
120
+
121
+ /** 发布历史分页结果 */
122
+ export interface ReleasePage {
123
+ list: ReleaseRow[]
124
+ total: number
125
+ page: number
126
+ size: number
127
+ /** 本次同步索引失败的人话(页面照常显示已入库历史) */
128
+ syncError: string | null
129
+ }
@@ -0,0 +1,207 @@
1
+ // BDD:安装补偿清单(P0 §3.5)。锁后失败把站点恢复到「装前代」——旧代继续服务、任务 partial+可重试。
2
+ // 直接驱动 compensate()(不重跑整个状态机),配真实内存库夹具(installDb)+ 真实临时文件系统 +
3
+ // stub reload 面,覆盖:完整回滚、幂等、部分失败仍尽力回滚、匿名面不随失败放大(验收 4)。
4
+ //
5
+ // 资产回滚原语:removeAnonymousPaths(text-edit)/ revertLock(lock)/ removeModulesJsonEntry
6
+ // (deploy-web)/ revertRegistrations(uninstall,复用卸载事务)。各原语幂等(缺则 no-op),
7
+ // 故补偿可安全被重试端点反复调用。
8
+
9
+ import * as fsp from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { atomicWriteText } from "../../src/app/api/_platform/_lifecycle/atomic";
14
+ import { compensate } from "../../src/app/api/_platform/_lifecycle/compensate";
15
+ import { parseLock, writeLock } from "../../src/app/api/_platform/_lifecycle/lock";
16
+ import { appendAnonymousPaths, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
17
+ import { installDb } from "../fixture";
18
+
19
+ const g = globalThis as any;
20
+
21
+ const BASE_CONFIG = "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n - /system/login-methods\n";
22
+
23
+ function nodeFsFace() {
24
+ return {
25
+ writeFile: (p: string, d: any) => fsp.writeFile(p, d),
26
+ readFile: (p: string) => fsp.readFile(p),
27
+ mkdir: (p: string, o: any) => fsp.mkdir(p, o),
28
+ remove: (p: string, o: any) => fsp.rm(p, o),
29
+ rename: (o: string, n: string) => fsp.rename(o, n),
30
+ copyFile: (s: string, d: string) => fsp.copyFile(s, d),
31
+ readdir: (p: string) => fsp.readdir(p),
32
+ };
33
+ }
34
+
35
+ let root: string;
36
+ let configPath: string;
37
+ let previewPath: string;
38
+ let sharedRoot: string;
39
+ let lockPath: string;
40
+ let modulesJsonPath: string;
41
+ let dbo: any;
42
+ let reloadCalls: number;
43
+ let reloadThrow: boolean;
44
+
45
+ const LOCK_BEFORE: Record<string, string> = { existing: "1.0.0" };
46
+
47
+ /** 把站点摆成「demo-solution 已锁后落位」状态,返回本次新增的匿名路径(= 补偿的输入 deferredAnonymous) */
48
+ async function seedInstalled(anonPaths: string[]): Promise<string[]> {
49
+ const added = appendAnonymousPaths(BASE_CONFIG, anonPaths);
50
+ await atomicWriteText(configPath, added.content);
51
+ await atomicWriteText(previewPath, added.content);
52
+ // 锁:装前快照 + 本次模块条目
53
+ await writeLock(lockPath, { ...LOCK_BEFORE, "demo-solution": "0.1.0" });
54
+ // modules.json:含本次模块条目
55
+ await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
56
+ // DB:路由/角色/权限各一行为 demo-solution(registering 段已落位)
57
+ await g.db.table("sys_solution_route").insert({ path: "/demo-solution", module: "demo-solution", category: "main" }).run();
58
+ await g.db.table("sys_role").insert({ name: "demo-solution.admin", module: "demo-solution" }).run();
59
+ await g.db.table("sys_permission").insert({ code: "demo-solution.view", module: "demo-solution" }).run();
60
+ return added.added;
61
+ }
62
+
63
+ function countAnon(cfg: string): number {
64
+ const lines = cfg.split("\n");
65
+ const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
66
+ if (idx === -1)
67
+ return 0;
68
+ let n = 0;
69
+ for (let i = idx + 1; i < lines.length; i++) {
70
+ const line = lines[i]!;
71
+ if (line.trim() === "" || /^\s*#/.test(line))
72
+ continue;
73
+ if (!line.replace(/#.*$/, "").trim().startsWith("- "))
74
+ break;
75
+ n++;
76
+ }
77
+ return n;
78
+ }
79
+
80
+ beforeEach(async () => {
81
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-compensate-"));
82
+ configPath = join(root, "config.yaml");
83
+ previewPath = join(root, ".ojm-preview.yaml");
84
+ sharedRoot = join(root, "modules");
85
+ lockPath = join(sharedRoot, "manifests.yaml");
86
+ modulesJsonPath = join(root, "dist", "modules.json");
87
+ await fsp.mkdir(sharedRoot, { recursive: true });
88
+ await fsp.mkdir(join(root, "dist"), { recursive: true });
89
+ g.fs = nodeFsFace();
90
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
91
+ g.http = {};
92
+ dbo = installDb();
93
+ reloadCalls = 0;
94
+ reloadThrow = false;
95
+ });
96
+
97
+ afterEach(async () => {
98
+ g.fs = undefined;
99
+ g.http = undefined;
100
+ g.db = undefined;
101
+ await fsp.rm(root, { recursive: true, force: true });
102
+ });
103
+
104
+ function makeInput(deferredAnonymous: string[]): any {
105
+ return {
106
+ name: "demo-solution",
107
+ version: "0.1.0",
108
+ configPath,
109
+ deferredAnonymous,
110
+ sharedRoot,
111
+ modulesJsonPath,
112
+ lockBefore: LOCK_BEFORE,
113
+ lockExisted: true,
114
+ reload: {
115
+ reloadConfig: async () => {
116
+ reloadCalls++;
117
+ if (reloadThrow)
118
+ throw new Error("reload 模拟失败");
119
+ return { generation: 2, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] };
120
+ },
121
+ },
122
+ };
123
+ }
124
+
125
+ describe("compensate 完整回滚到装前代", () => {
126
+ it("锁/config匿名/modules.json/DB 全部回退,旧代仍服务(reload 已调用)", async () => {
127
+ const added = await seedInstalled(["demo-solution/ping", "demo-solution/health"]);
128
+ const r = await compensate(makeInput(added));
129
+
130
+ expect(r.allOk).toBe(true);
131
+ expect(r.steps.map(s => s.step)).toEqual(["config.anonymous", "lock", "modules.json", "db.registrations", "reload"]);
132
+
133
+ // 锁回到装前快照(无 demo-solution)
134
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
135
+ // config:新增匿名条目被精确移除,基线两条保留;seq 不被回滚(单调无害)
136
+ const cfg = await fsp.readFile(configPath, "utf8");
137
+ expect(cfg).toContain("- /auth/login");
138
+ expect(cfg).toContain("- /system/login-methods");
139
+ expect(cfg).not.toContain("demo-solution/ping");
140
+ expect(cfg).not.toContain("demo-solution/health");
141
+ expect(countAnon(cfg)).toBe(2);
142
+ // preview 派生同样被清理
143
+ const prev = await fsp.readFile(previewPath, "utf8");
144
+ expect(prev).not.toContain("demo-solution/ping");
145
+ // modules.json 无 demo-solution 条目
146
+ const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
147
+ expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
148
+ // DB 注册被级联清掉
149
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
150
+ expect(dbo.prepare("SELECT * FROM sys_role WHERE module='demo-solution'").all()).toHaveLength(0);
151
+ expect(dbo.prepare("SELECT * FROM sys_permission WHERE module='demo-solution'").all()).toHaveLength(0);
152
+ // reload 被调用一次(活动代回到装前态)
153
+ expect(reloadCalls).toBe(1);
154
+ });
155
+ });
156
+
157
+ describe("compensate 幂等(可重试)", () => {
158
+ it("连续两次补偿:第二次全 no-op,终态不变,reload 仍各调用一次", async () => {
159
+ const added = await seedInstalled(["demo-solution/ping"]);
160
+ const first = await compensate(makeInput(added));
161
+ const second = await compensate(makeInput(added));
162
+ expect(first.allOk).toBe(true);
163
+ expect(second.allOk).toBe(true);
164
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
165
+ expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
166
+ const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
167
+ expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
168
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
169
+ expect(reloadCalls).toBe(2); // 两次各触发一次 reload(幂等但不跳过 reload)
170
+ });
171
+ });
172
+
173
+ describe("compensate 部分失败仍尽力回滚", () => {
174
+ it("reload 步抛错 → 该步标记失败、其余四步仍生效、allOk=false", async () => {
175
+ const added = await seedInstalled(["demo-solution/ping"]);
176
+ reloadThrow = true;
177
+ const r = await compensate(makeInput(added));
178
+
179
+ expect(r.allOk).toBe(false);
180
+ const reloadStep = r.steps.find(s => s.step === "reload")!;
181
+ expect(reloadStep.ok).toBe(false);
182
+ // 其余步成功
183
+ for (const s of r.steps.filter(s => s.step !== "reload")) {
184
+ expect(s.ok).toBe(true);
185
+ }
186
+ // 即便 reload 失败,已落位资产仍被回退
187
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
188
+ expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
189
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
190
+ });
191
+ });
192
+
193
+ describe("compensate 匿名面不随失败放大(验收 4)", () => {
194
+ it("同一方案连续两次失败安装:config 匿名条目数不增长", async () => {
195
+ // 第一次失败:装前基线 2 条,新增 1 条后补偿
196
+ const added = await seedInstalled(["demo-solution/ping"]);
197
+ await compensate(makeInput(added));
198
+ const afterFirst = await fsp.readFile(configPath, "utf8");
199
+ expect(countAnon(afterFirst)).toBe(2); // 回到基线
200
+
201
+ // 第二次失败:再次以同样 deferredAnonymous 补偿(模拟又失败一次)
202
+ await compensate(makeInput(added));
203
+ const afterSecond = await fsp.readFile(configPath, "utf8");
204
+ expect(countAnon(afterSecond)).toBe(2); // 不增长
205
+ expect(removeAnonymousPaths(afterSecond, added).removed).toEqual([]); // 已无残留可删
206
+ });
207
+ });
@@ -1,7 +1,7 @@
1
1
  // BDD:部署/热更/冒烟三步(P1-9,十阶段 stage 8~10)。
2
2
  // deploy-web = staging/web 产物树真复制到站点 modules 目录 + modules.json 读-改-原子写 upsert
3
3
  // (D13:读者永不见半截 JSON;R30:entry/css 路径 POSIX 分隔符序列化;同名整体替换、他条目原样保留);
4
- // reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 改写未生效判失败(R18 终态断言),
4
+ // reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 安装幂等成功 / 卸载恒严格(R18 修订——加载靠锁 diff 触发,不再依赖 vars 哨兵),
5
5
  // expected 段键(缺省 ["vars"],B-11 reload 触发面)须逐一 ∈ applied;
6
6
  // smoke = 探活(health 2xx / 缺省首个非匿名路由连通即可)+ integrity(部署落位后重算 sha384 vs
7
7
  // modules.json 条目,R10 包内容口径;缺失 fail-closed)+ 注册一致性(升级态「包含非相等」:
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
21
  import { deployWeb, posixPath, upsertModulesJson } from "../../src/app/api/_platform/_lifecycle/steps/deploy-web";
22
- import { reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
22
+ import { assertReloadApplied, reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
23
23
  import { probeUrlOf, smoke } from "../../src/app/api/_platform/_lifecycle/steps/smoke";
24
24
 
25
25
  const g = globalThis as any;
@@ -260,31 +260,42 @@ describe("deployWeb(stage=deploying:staging/web 树 → 站点 modules 目
260
260
  });
261
261
  });
262
262
 
263
- describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
263
+ describe("reloadAndAssert(stage=reloading:R18 修订——安装 unchanged=幂等成功且默认不强制模块符号;卸载 unchanged/缺 -name@ver=严格失败;force+unchanged=失败)", () => {
264
264
  function rlCtx(result?: ReloadResult, throws = false): ReloadContext {
265
265
  const face: ReloadFace = {
266
266
  reloadConfig: throws
267
267
  ? async () => {
268
268
  throw new Error("server.reloadConfig: reload in progress (busy)");
269
269
  }
270
- : async () => result ?? { generation: 8, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] },
270
+ : async () => result ?? { generation: 8, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: [], kept_listener: [] },
271
271
  };
272
272
  return { manifest: solManifest(), name: "demo-solution", version: "0.1.0", state: stateStore(), reload: face };
273
273
  }
274
274
 
275
- it("成功:applied 含 vars(缺省 expected)→ ok=true,message 含 generation", async () => {
275
+ it("成功:applied 含 +name@ver(api 模块装配)→ ok=true,message 含 generation", async () => {
276
276
  const r = await reloadAndAssert(rlCtx());
277
277
  expect(r.stage).toBe("reloading");
278
278
  expect(r.ok).toBe(true);
279
279
  expect(r.message).toContain("8");
280
280
  });
281
281
 
282
- it("unchanged → ok=false(R18:幂等回显 = config 改写未生效)", async () => {
282
+ it("unchanged → ok=true(R18 修订:安装幂等成功 = 已是最新装配,不再视为失败)", async () => {
283
283
  const r = await reloadAndAssert(rlCtx({ generation: 7, unchanged: true, applied: [] }));
284
+ expect(r.ok).toBe(true);
285
+ });
286
+
287
+ it("force + unchanged → ok=false(--force 要求强制重装配却无变更可重做)", async () => {
288
+ const r = await reloadAndAssert({ ...rlCtx({ generation: 7, unchanged: true, applied: [] }), force: true });
284
289
  expect(r.ok).toBe(false);
285
290
  expect(r.message).toContain("unchanged");
286
291
  });
287
292
 
293
+ it("卸载 unchanged → ok=false(锁未摘除 = 卸载空转,恒严格)", async () => {
294
+ const r = assertReloadApplied({ generation: 7, unchanged: true, applied: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
295
+ expect(r.ok).toBe(false);
296
+ expect(r.message).toContain("卸载空转");
297
+ });
298
+
288
299
  it("face 抛错(并发 busy)→ ok=false retryable=true", async () => {
289
300
  const r = await reloadAndAssert(rlCtx(undefined, true));
290
301
  expect(r.ok).toBe(false);
@@ -292,10 +303,15 @@ describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
292
303
  expect(r.message).toContain("busy");
293
304
  });
294
305
 
295
- it("expected 段键未生效:applied 缺 vars → ok=false(R18 以实际生效值断言)", async () => {
296
- const r = await reloadAndAssert({ ...rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }), expected: ["mounts", "vars"] });
306
+ it("安装不再强制 +name@ver(R18 修订:applied 空属正常幂等态,挂载真通交由 smoke 段行为化校验)", async () => {
307
+ const r = await reloadAndAssert(rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }));
308
+ expect(r.ok).toBe(true);
309
+ });
310
+
311
+ it("卸载缺 -name@ver → ok=false(卸载须显式拆装,锁未摘除即空转回归拦截点)", async () => {
312
+ const r = assertReloadApplied({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
297
313
  expect(r.ok).toBe(false);
298
- expect(r.message).toContain("vars");
314
+ expect(r.message).toContain("demo-solution@0.1.0");
299
315
  });
300
316
 
301
317
  it("返回形状违约(applied 非数组)→ ok=false retryable=false", async () => {
@@ -337,15 +353,13 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
337
353
  expect(r.message).toContain("不计入失败");
338
354
  });
339
355
 
340
- it("health 401 与否声明 anonymous 无关(统一 deferred,不放宽真失败靠非 401)", async () => {
341
- // 旧语义按 deferredAnonymous 白名单区分是否 deferred;该字段从未被调用方赋值(死代码),
342
- // 且设计明确「health 不强制、无凭证预期 401」,故改为 health 401 一律 deferred。
343
- // 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
356
+ it("health 401 与是否声明 anonymous 无关(统一 deferred,真失败靠非 401 兜底)", async () => {
357
+ // 设计明确「health 不强制、无凭证预期 401」,故 health 401 一律 deferred,
358
+ // 真失败仍由非 401(如 500,见下条)兜底。
344
359
  await seedDeployed();
345
360
  const r = await smoke(smCtx({
346
361
  manifest: solManifest({ health: "/demo-solution/ping" }),
347
362
  fetch: async () => ({ ok: false, status: 401 }),
348
- deferredAnonymous: ["/demo-solution/other"],
349
363
  }));
350
364
  expect(r.ok).toBe(true);
351
365
  expect(r.message).toContain("restart_pending");
@@ -394,6 +408,16 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
394
408
  }));
395
409
  expect(r.ok).toBe(true);
396
410
  });
411
+
412
+ it("无 health:首个非匿名路由 404 → 探活失败(P0 §3.5:首个路由 404/连通失败 = 冒烟失败,避免假成功)", async () => {
413
+ await seedDeployed();
414
+ const r = await smoke(smCtx({
415
+ fetch: async () => ({ ok: false, status: 404 }),
416
+ }));
417
+ expect(r.ok).toBe(false);
418
+ expect(r.message).toContain("探活");
419
+ expect(r.message).toContain("404");
420
+ });
397
421
  });
398
422
 
399
423
  describe("smoke integrity(R10:部署落位后重算 sha384 vs modules.json)", () => {
@@ -156,7 +156,7 @@ function makeReq(overrides: Partial<InstallRequest> = {}): InstallRequest {
156
156
  configPath: join(root, "config.yaml"),
157
157
  fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer }),
158
158
  exec: bundleExec(),
159
- reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }) },
159
+ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }) },
160
160
  sha256Hex: d => createHash("sha256").update(d).digest("hex"),
161
161
  verifyEd25519: nodeVerifyEd25519,
162
162
  trustPath,
@@ -181,8 +181,8 @@ beforeEach(async () => {
181
181
  modulesJsonPath = join(root, "dist", "modules.json");
182
182
  dbPath = join(root, "db.sqlite");
183
183
  await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
184
- // 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
185
- 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");
184
+ // 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
185
+ 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");
186
186
  g.fs = nodeFsFace();
187
187
  g.yaml = { parse: async (t: string) => JSON.parse(t) };
188
188
  g.http = {};
@@ -226,9 +226,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
226
226
  await fsp.access(join(versionDir, "routes.js"));
227
227
  await fsp.access(join(versionDir, "migrations", "0001__init.sql"));
228
228
  await expect(fsp.access(join(versionDir, "web"))).rejects.toThrow();
229
- // reloading 段触发变更已落盘:vars.ojm_install_seq 自增(B-11,PRD E5 口径)
229
+ // reloading 段仅做 anonymous_paths 接线(幂等追加;不再依赖 vars 哨兵触发 reload)
230
230
  const cfg = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
231
- expect(cfg).toContain("ojm_install_seq: 1");
232
231
  // C2-26 anonymous 运行时接线(P2-15③):方案匿名端点补进 auth.anonymous_paths(幂等追加)
233
232
  expect(cfg).toContain("- /demo-solution/ping");
234
233
  // web 产物落位 + modules.json 条目(R10 integrity 为包内容重算值)
@@ -260,10 +259,10 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
260
259
  expect(await getTask(taskDir, "nope")).toBeNull();
261
260
  });
262
261
 
263
- it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起自增(否则 reload 恒 unchanged)", async () => {
262
+ it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起接线(否则 reload 恒 unchanged)", async () => {
264
263
  // ojm preview 派生出来的那份才是服务端在读的 config(spa 挂载 + platform.root 改写)
265
264
  const derived = join(root, ".ojm-preview.yaml");
266
- await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
265
+ await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
267
266
 
268
267
  const t = await runInstall(makeReq());
269
268
  expect(t.status).toBe("done");
@@ -271,10 +270,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
271
270
  const source = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
272
271
  const preview = new TextDecoder().decode(await fsp.readFile(derived));
273
272
  // 源 config 仍是真相(preview 重启后按它重建派生文件)
274
- expect(source).toContain("ojm_install_seq: 1");
275
273
  expect(source).toContain("- /demo-solution/ping");
276
274
  // 派生文件也要变:reloadConfig 按它的文档 sha 判 unchanged
277
- expect(preview).toContain("ojm_install_seq: 1");
278
275
  expect(preview).toContain("- /demo-solution/ping");
279
276
  });
280
277
 
@@ -1313,3 +1310,25 @@ describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase
1313
1310
  expect(t.stage).toBe("smoke");
1314
1311
  });
1315
1312
  });
1313
+
1314
+ describe("Phase 3:无守护 partial 判定(P0 §3.6 / 验收 15)", () => {
1315
+ it("热更返回待重启段非空 → 装完标 partial + restartRequired(不判 done,避免假成功)", async () => {
1316
+ // 无守护形态(P4 supervisor 未实施):匿名接线等 restart_pending 段需宿主重启才完全生效,
1317
+ // 不应报 done 误导。reload 返回 restart_pending 非空即标 partial + restartRequired。
1318
+ const t = await runInstall(makeReq({
1319
+ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: ["auth"], kept_listener: [] }) },
1320
+ }));
1321
+ expect(t.status).toBe("partial");
1322
+ expect(t.restartRequired).toBe(true);
1323
+ expect(t.message).toContain("待重启段");
1324
+ expect(t.message).toContain("重启");
1325
+ // 安装产物已落位(非失败语义②补偿):锁/版本目录仍在,任务非 failed
1326
+ expect(t.stage).not.toBe("failed");
1327
+ });
1328
+
1329
+ it("热更待重启段为空 → 正常 done(restartRequired 不置位)", async () => {
1330
+ const t = await runInstall(makeReq());
1331
+ expect(t.status).toBe("done");
1332
+ expect(t.restartRequired).toBeUndefined();
1333
+ });
1334
+ });