@oj-module/cli 0.2.2 → 0.2.6

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 (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +189 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +62 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
@@ -0,0 +1,229 @@
1
+ import { auditOp } from "../_shared/service/audit";
2
+ // 宿主信任库(P2-2,契约 v1.3 §2.11 / C2-38/39,G3 双轨第一轨 / G5)。
3
+ // 存储:.solutions/trusted-keys.yaml(key_id → {public_key, added_at})——YAML 受限子集
4
+ // (两层 map + 标量,同 state.ts 口径:只读回本模块写出的内容,不引 yaml 依赖,严格解析)。
5
+ // 原子写(D13)+ .bak 前一版;主文件损坏自动回落 .bak 并留 note(下次写入自愈),
6
+ // 两者皆坏 = 可读报错 fail-closed(含路径与修法指引,不半截数据)。
7
+ // 首次绑定不可改(C2-38):同 key_id 异公钥 = trust_bound_mismatch(409);轮换 = 新增 key_id。
8
+ // 吊销(C2-39/G5)= remove:只阻断新装/升级(P2-3/P2-6 消费),不动已装方案。
9
+ // 空沙箱红线:模块级零全局触碰——fs 经 atomic.ts 的 fsFace()(globalThis.fs 平台注入),
10
+ // audit 经注入面(缺省 auditOp,测试注入记录器);禁 console(release isolate 抛 BadResource,Finding A)。
11
+ import { atomicWriteText, fsFace } from "./atomic";
12
+
13
+ export interface TrustEntry {
14
+ key_id: string
15
+ /** 32B raw 公钥 hex(契约 C2-35 public_key 口径) */
16
+ public_key: string
17
+ added_at: number
18
+ }
19
+
20
+ /** 审计出口形状(OpEntry 的相关子集;测试注入记录器,缺省 auditOp fire-and-forget) */
21
+ export type TrustAudit = (entry: { action: string, module: string, target: string, detail: unknown, level?: number }) => void;
22
+
23
+ /** 指纹面(sha256Hex(publicKey) 全 64 hex,契约 §2.11;缺省走运行时 crypto 增补面,测试注入) */
24
+ export type FingerprintFn = (publicKeyHex: string) => string | null;
25
+
26
+ export interface TrustStore {
27
+ list: () => Promise<TrustEntry[]>
28
+ /** 公钥 hex;不在库 = null */
29
+ get: (keyId: string) => Promise<string | null>
30
+ /** 新增信任;同 key 同公钥 = "exists_same"(幂等);同 key 异公钥 = 拒(首次绑定不可改) */
31
+ add: (keyId: string, publicKeyHex: string, o?: { operator?: string | null }) => Promise<"added" | "exists_same">
32
+ /** 移除即吊销(G5);返回是否有过该 key(审计留痕) */
33
+ remove: (keyId: string, o?: { operator?: string | null }) => Promise<boolean>
34
+ /** 最近一次读盘的损坏/回落 note(null = 正常;诊断观测出口,禁 console——Finding A) */
35
+ corruptNote?: () => string | null
36
+ }
37
+
38
+ /** C2-37 key_id 命名空间:<publisher>.<id> 全小写、至少一段点后缀;禁裸通用名(default/test/单段) */
39
+ const KEY_ID = /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)+$/;
40
+ const PUBLIC_KEY_HEX = /^[0-9a-f]{64}$/;
41
+
42
+ export function isValidKeyId(keyId: string): boolean {
43
+ return KEY_ID.test(keyId);
44
+ }
45
+
46
+ function fingerprintOf(publicKeyHex: string): string | null {
47
+ try {
48
+ const c = (globalThis as { crypto?: { sha256Hex?: (d: Uint8Array) => string } }).crypto;
49
+ const raw = new Uint8Array(32);
50
+ for (let i = 0; i < 32; i++)
51
+ raw[i] = Number.parseInt(publicKeyHex.slice(i * 2, i * 2 + 2), 16);
52
+ return c?.sha256Hex?.(raw) ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function bakPathOf(path: string): string {
60
+ return `${path}.bak`;
61
+ }
62
+
63
+ /** 带 status 的可读失败(respond 收口:err.status → json.fail,D14/B5/409 同一通道) */
64
+ function failStatus(status: number, msg: string): never {
65
+ throw Object.assign(new Error(msg), { status });
66
+ }
67
+
68
+ /**
69
+ * 信任库工厂(同 state.ts:构造即读盘 hydrate;写路径每次从盘上最新内容读-改-写,多写者不丢键)。
70
+ * fs 走 atomic.ts fsFace()(globalThis.fs 平台注入/测试 stub,state.ts/lock.ts 同款 DIP)。
71
+ */
72
+ export function createTrustStore(path: string, opts?: { audit?: TrustAudit, fingerprintOf?: FingerprintFn }): TrustStore {
73
+ const audit = opts?.audit ?? auditOp;
74
+ const fingerprint = opts?.fingerprintOf ?? fingerprintOf;
75
+ let note: string | null = null;
76
+
77
+ async function readDisk(): Promise<{ text: string | null, entries: Map<string, TrustEntry>, recovered: boolean }> {
78
+ const main = await readTextOrNull(path);
79
+ if (main !== null) {
80
+ try {
81
+ return { text: main, entries: parseTrust(main), recovered: false };
82
+ }
83
+ catch (e) {
84
+ // 主文件损坏:尝试 .bak 回落(自动恢复 + note,下次写入自愈)
85
+ const bak = await readTextOrNull(bakPathOf(path));
86
+ if (bak !== null) {
87
+ try {
88
+ return { text: bak, entries: parseTrust(bak), recovered: true };
89
+ }
90
+ catch {
91
+ // bak 也坏:落到底部统一报错
92
+ }
93
+ }
94
+ failStatus(500, `信任库损坏:${path} 无法解析(${String(e)})且 ${bakPathOf(path)} 不可用——请按 key_id/公钥手工修复 YAML,或删除该文件后经 ojm trust add 重建;损坏期间拒绝一切信任变更(fail-closed)`);
95
+ }
96
+ }
97
+ return { text: null, entries: new Map(), recovered: false };
98
+ }
99
+
100
+ async function readTextOrNull(p: string): Promise<string | null> {
101
+ try {
102
+ return new TextDecoder().decode(await fsFace().readFile(p));
103
+ }
104
+ catch {
105
+ return null; // 不存在/不可读按缺失(读缺失不炸;isMissingPath 双形态由调用方不关心——空库语义)
106
+ }
107
+ }
108
+
109
+ async function commit(disk: { text: string | null, entries: Map<string, TrustEntry>, recovered: boolean }): Promise<void> {
110
+ const next = serializeTrust(disk.entries);
111
+ // .bak 只在主文件健康且内容将变时更新(recovered 时保住可用的 .bak 不覆盖)
112
+ if (!disk.recovered && disk.text !== null && disk.text !== next)
113
+ await atomicWriteText(bakPathOf(path), disk.text);
114
+ await atomicWriteText(path, next);
115
+ note = null; // 写成功即自愈:note 清零
116
+ }
117
+
118
+ return {
119
+ async list() {
120
+ const disk = await readDisk();
121
+ note = disk.recovered ? `${path} 损坏,已自动回落 ${bakPathOf(path)}(下次信任变更写入即自愈)` : null;
122
+ return [...disk.entries.values()].sort((a, b) => (a.key_id < b.key_id ? -1 : a.key_id > b.key_id ? 1 : 0));
123
+ },
124
+ async get(keyId) {
125
+ const disk = await readDisk();
126
+ return disk.entries.get(keyId)?.public_key ?? null;
127
+ },
128
+ corruptNote: () => note,
129
+ async add(keyId, publicKeyHex, o) {
130
+ if (!isValidKeyId(keyId))
131
+ failStatus(400, `key_id 格式非法(C2-37 命名空间 <publisher>.<id> 全小写、禁裸通用名):${keyId}`);
132
+ if (!PUBLIC_KEY_HEX.test(publicKeyHex))
133
+ failStatus(400, `public_key 必须是 64 hex(Ed25519 32B raw):${publicKeyHex.slice(0, 16)}…`);
134
+ const disk = await readDisk();
135
+ const existing = disk.entries.get(keyId);
136
+ if (existing !== undefined) {
137
+ if (existing.public_key === publicKeyHex)
138
+ return "exists_same"; // 幂等:不重写不审计
139
+ failStatus(409, `信任库首次绑定不可改(C2-38):key_id「${keyId}」已绑定其他公钥(库内指纹 ${fingerprint(existing.public_key) ?? "?"} ≠ 新钥指纹 ${fingerprint(publicKeyHex) ?? "?"})——轮换请新增 key_id,禁覆写`);
140
+ }
141
+ const next = new Map(disk.entries);
142
+ next.set(keyId, { key_id: keyId, public_key: publicKeyHex, added_at: Date.now() });
143
+ await commit({ ...disk, entries: next });
144
+ // operator 不可得 → 记 "unknown" 且告警级(level 4),不静默 null(P2-5)
145
+ const op = o?.operator ?? null;
146
+ audit({
147
+ action: "solution.trust.add",
148
+ module: "market",
149
+ target: keyId,
150
+ detail: { key_id: keyId, fingerprint: fingerprint(publicKeyHex), operator: op ?? "unknown" },
151
+ level: op ? 6 : 4,
152
+ });
153
+ return "added";
154
+ },
155
+ async remove(keyId, o) {
156
+ const disk = await readDisk();
157
+ if (!disk.entries.has(keyId))
158
+ return false; // 无此 key:无吊销可执行,不审计
159
+ const next = new Map(disk.entries);
160
+ next.delete(keyId);
161
+ await commit({ ...disk, entries: next });
162
+ // operator 不可得 → 记 "unknown" 且告警级(level 4),不静默 null(P2-5)
163
+ const op = o?.operator ?? null;
164
+ audit({
165
+ action: "solution.trust.revoke",
166
+ module: "market",
167
+ target: keyId,
168
+ detail: { key_id: keyId, operator: op ?? "unknown" },
169
+ level: op ? 6 : 4,
170
+ });
171
+ return true;
172
+ },
173
+ };
174
+ }
175
+
176
+ // ── 序列化/解析(严格受限子集,同 state.ts 口径)──────────────────────────────
177
+
178
+ function serializeTrust(entries: Map<string, TrustEntry>): string {
179
+ const ids = [...entries.keys()].sort();
180
+ if (ids.length === 0) {
181
+ return "trusted_keys: {}\n";
182
+ }
183
+ const lines = ["trusted_keys:"];
184
+ for (const id of ids) {
185
+ const e = entries.get(id)!;
186
+ lines.push(
187
+ ` ${id}:`,
188
+ ` public_key: "${e.public_key}"`,
189
+ ` added_at: ${e.added_at}`,
190
+ );
191
+ }
192
+ return `${lines.join("\n")}\n`;
193
+ }
194
+
195
+ /** 严格解析:首行 `trusted_keys:`/`trusted_keys: {}`,随后每组三行(键行/public_key/added_at),任何偏差 = 损坏 */
196
+ function parseTrust(text: string): Map<string, TrustEntry> {
197
+ const lines = text.split("\n");
198
+ if (lines[lines.length - 1] === "") {
199
+ lines.pop();
200
+ }
201
+ if (lines.length === 0) {
202
+ throw new Error("空文件");
203
+ }
204
+ const entries = new Map<string, TrustEntry>();
205
+ if (lines[0] === "trusted_keys: {}") {
206
+ if (lines.length !== 1) {
207
+ throw new Error("「trusted_keys: {}」之后存在多余内容");
208
+ }
209
+ return entries;
210
+ }
211
+ if (lines[0] !== "trusted_keys:") {
212
+ throw new Error(`首行应为「trusted_keys:」,实为「${lines[0]}」`);
213
+ }
214
+ let i = 1;
215
+ while (i < lines.length) {
216
+ const keyId = /^ {2}([^:\s]+):$/.exec(lines[i])?.[1];
217
+ if (keyId === undefined) {
218
+ throw new Error(`第 ${i + 1} 行应为 key_id 行,实为「${lines[i]}」`);
219
+ }
220
+ const publicKey = /^ {4}public_key: "([0-9a-f]{64})"$/.exec(lines[i + 1] ?? "")?.[1];
221
+ const addedAt = /^ {4}added_at: (-?\d+)$/.exec(lines[i + 2] ?? "")?.[1];
222
+ if (publicKey === undefined || addedAt === undefined) {
223
+ throw new Error(`key「${keyId}」条目格式不完整或字段损坏`);
224
+ }
225
+ entries.set(keyId, { key_id: keyId, public_key: publicKey, added_at: Number(addedAt) });
226
+ i += 3;
227
+ }
228
+ return entries;
229
+ }
@@ -0,0 +1,94 @@
1
+ // 生命周期编排面类型收敛(P1-10):InstallContext 自 steps/preflight.ts 迁入(含可选 hostVersions,
2
+ // C2-09 比对真源注入位);StageResult 收敛为全 stage 通用结构形状(各 step 模块保留自己的字面量
3
+ // stage 窄类型,结构相容即可赋值);十阶段 InstallStage 名 = 契约附录 A 逐字(lock_commit 为
4
+ // D11①扩展组);TaskRecord = 注册设计 §10.2 形状 + stages 明细日志 + appliedSeq(对账 floor 依据)。
5
+
6
+ import type { StateStore } from "./state";
7
+
8
+ /** 安装上下文(各 step 模块 ctx 的公共基座;依赖注入面经各 step 的 ctx 扩展,见 steps/*) */
9
+ export interface InstallContext {
10
+ /** 已解析的 manifest.yaml(安装器读盘解析产物) */
11
+ manifest: unknown
12
+ /** 安装目标身份(与 manifest.name/version 同源;manifest 字段缺失/非字符串时兜底) */
13
+ name: string
14
+ version: string
15
+ /** 站点 sidecar 状态(.solutions/state.yaml);占用检测真源:solutions 键 = 已装方案名 */
16
+ state: StateStore
17
+ /** 宿主 versions.json(C2-09 peerRuntime 比对真源;键 = 包名,runtime 取 @oj-module/runtime) */
18
+ hostVersions?: Record<string, string>
19
+ /** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/_platform/market/meta 的 apiVersion,即 market/api.ts 的 API_VERSION 常量,与 _platform/manifest.yaml 版本手工同步) */
20
+ hostApiVersion?: string
21
+ /** 目标站点数据库句柄(本段不消费;P1-8 register 段起使用) */
22
+ db?: unknown
23
+ /**
24
+ * 同名重装/升级放行位(P1-10,AC-P1-3):安装器检测到 sidecar 已有同名方案时置位——
25
+ * 同名命中是本方案自己的升级路径(升级/降级 = runInstall({version}),任务简报),
26
+ * 跨方案碰撞仍由 apiSeg/route_prefix 首段占用兜住;缺省 false 保持 P1-5「同名拒装」钉面。
27
+ */
28
+ reinstall?: boolean
29
+ }
30
+
31
+ /** 阶段结果(全 stage 通用结构形状;stage 取值为各 step 自己的字面量,编排层以 StageDef.name 为准) */
32
+ export interface StageResult {
33
+ stage: string
34
+ ok: boolean
35
+ message: string
36
+ retryable?: boolean
37
+ /** 取消安装(U5,P3-8):本 stage 被用户中止,运行器据其走「清场 + 锁/目录回退装前 + 审计末位」语义 */
38
+ cancelled?: boolean
39
+ /** 签名失败六码(P2-3,契约附录 A.2;缺省 = 非签名类失败) */
40
+ code?: SigCode
41
+ /** 未知 key_id 待 TOFU 确认:任务停 needs_trust 终态,staging 不清场供确认后重试 */
42
+ needsTrust?: boolean
43
+ /** 待确认的发布方 key(TaskRecord.trust 同源;UI/CLI 展示指纹) */
44
+ trust?: { key_id: string, public_key: string, fingerprint: string }
45
+ }
46
+
47
+ /**
48
+ * 签名失败六码(契约 v1.3 附录 A.2,P2-3):retryable 恒 false、零解压零落盘。
49
+ * 文案 key = `lifecycle.stage.verifying.<code>`(双语值 P2-5 落 locales)。
50
+ */
51
+ export type SigCode = "sig_missing" | "sig_alg" | "sig_key" | "tampered" | "unknown_key" | "sig_format";
52
+
53
+ /**
54
+ * 安装阶段名(契约附录 A 十阶段逐字 + D11①扩展组 lock_commit + audit/done 终态)。
55
+ * 附录 A 文案 key:`lifecycle.stage.<stage>.reason/.hint/.retryable`(audit 失败仅记日志不设 key)。
56
+ */
57
+ export type InstallStage = "preflight" | "downloading" | "verifying" | "extracting" | "migrating" | "lock_commit" | "registering" | "deploying_web" | "reloading" | "smoke" | "audit" | "done";
58
+
59
+ /**
60
+ * 任务状态(§10.2 三态 + partial:锁后失败部分生效——D11 失败语义② + needs_trust:
61
+ * 未知发布方 key_id 待人工 TOFU 确认——P2-3 第五终态,预锁段停止、staging 不清场,
62
+ * 确认入信任库后重试同一安装即可继续;一切消费面须把 needs_trust 当非成功态渲染)。
63
+ */
64
+ export type TaskStatus = "running" | "done" | "failed" | "partial" | "needs_trust";
65
+
66
+ /** 安装任务记录(§10.2 + stages 明细日志;落 .install-tasks/<id>.json,800ms 轮询消费面) */
67
+ export interface TaskRecord {
68
+ /**
69
+ * 记录 schema 版本(P2-10):写方盖章(tasks.ts TASK_SCHEMA_VERSION)、读方回填(缺省=1,旧记录不炸)。
70
+ * 升版纪律——加字段=minor(兼容:读方忽略未知字段);改语义/删字段=major(不认识的高版本,
71
+ * 读方须降级提示:人话提示 + 不误渲染成功)。与 CLI 侧 site.ts TASK_SCHEMA_VERSION_KNOWN 手工同步。
72
+ */
73
+ schemaVersion?: number
74
+ id: string
75
+ name: string
76
+ version: string
77
+ status: TaskStatus
78
+ stage: InstallStage | null
79
+ /** 0-100 整体进度(按 STAGES 表完成数折算) */
80
+ progress: number
81
+ message: string
82
+ /** 各 stage 明细日志(UI 详情日志区数据源) */
83
+ stages: { stage: InstallStage, ok: boolean, message: string }[]
84
+ /** 失败/partial 终态:reason + retryable + 指引文案 key(契约附录 A) */
85
+ error?: { reason: string, retryable: boolean, hintKey?: string }
86
+ /** needs_trust 终态:待确认的发布方 key(POST /market/trust {task_id} 消费;指纹供人确认) */
87
+ trust?: { key_id: string, public_key: string, fingerprint: string }
88
+ startedAt: number
89
+ finishedAt?: number
90
+ /** 本次迁移 applied max seq(migrating 段捕获;reconcile 账本对账 floor 依据) */
91
+ appliedSeq?: number
92
+ /** 取消安装标记(U5,P3-8):用户中止触发的终态(status 仍取 failed/partial——D11 共用失败终态,此标记供 UI 区分「取消」与「失败」) */
93
+ cancelled?: boolean
94
+ }
@@ -0,0 +1,283 @@
1
+ // 卸载编排器(P1-15a,SRP/OCP):与 installer.ts 对称——安装 = runInstall,卸载 = runUninstall。
2
+ // 端点(market/api.ts)只做门禁 + 取参 + 调本编排器,不重复编排(与 install 同纪律)。
3
+ // 删除纪律(D12 反向,与 setEnabled 四处同写对称):①锁摘除 ②sidecar 删条目 ③sys_solution_route 删
4
+ // ④modules.json 删条目;另:权限唯一减法出口 uninstallSolution(双维度级联,solution-perms.ts);
5
+ // 版本目录移除;终态审计 + 全用户失效(权限已变,D10 撤销即强退)+ market_history 留痕。
6
+ // 空锁拒启(S1-1.2):摘除后锁为空 → 拒绝卸载最后一个模块(站点至少保留一个已锁模块)。
7
+ // 任一步失败返回 {ok:false, message}(与 setEnabled 同纪律);部分残差由 admin 重试或重装自愈。
8
+ // SQL 全部参数化(与 audit.ts / setEnabled 同纪律,值不拼字符串)。
9
+
10
+ import type { OpEntry } from "../_shared/service/audit";
11
+ import type { StateStore } from "./state";
12
+ import { invalidateAllUsers } from "../_shared/service/access";
13
+ import { auditOp } from "../_shared/service/audit";
14
+ import { uninstallSolution } from "../_shared/service/solution-perms";
15
+ import { atomicWriteText, fsFace } from "./atomic";
16
+ import { recordHistory } from "./history";
17
+ import { readLock, writeLock } from "./lock";
18
+
19
+ // db 为 oj isolate 注入全局(同模板 service 层惯例,installer.ts 同款;测试由夹具装 globalThis.db)
20
+ declare const db: {
21
+ exec: (sql: string, params: unknown[]) => Promise<unknown>
22
+ query: (sql: string, params?: unknown[]) => Promise<unknown>
23
+ /** 当前方言(sqlite/mysql/pg);缺省 sqlite。Q4 业务表目录查询方言无关 */
24
+ dialect?: () => string
25
+ /** 单一 DB 事务包裹(P3-3):回调内语句共享一个 BEGIN/COMMIT/ROLLBACK 边界,异常即整段回滚 */
26
+ tx: <T>(fn: (t: { exec: (sql: string, params?: unknown[]) => Promise<unknown>, query: (sql: string, params?: unknown[]) => Promise<unknown> }) => Promise<T>) => Promise<T>
27
+ };
28
+
29
+ function isObj(v: unknown): v is Record<string, unknown> {
30
+ return typeof v === "object" && v !== null && !Array.isArray(v);
31
+ }
32
+
33
+ // P3-4(Q4 业务表保留提示):卸载只删代码/锁/路由/sidecar/权限,绝不 DROP 业务数据;
34
+ // 受影响业务表经双重来源得到——① manifest.tables 声明式权威源 ② 模块 slug 规范化前缀兜底扫描——
35
+ // 仅回传「实际存在」的表名,供运维手工清理。检测全程失败静默兜底,不阻断卸载主流程。
36
+
37
+ /** 从 manifest 文本抽取顶层 `tables:` 标量列表(manifest 形态稳定,避免引入 yaml 全局依赖) */
38
+ function manifestTables(yamlText: string): string[] {
39
+ const out: string[] = [];
40
+ let inTables = false;
41
+ for (const raw of yamlText.split("\n")) {
42
+ const line = raw.replace(/\r$/, "");
43
+ if (!inTables) {
44
+ if (/^tables\s*:/.test(line)) {
45
+ inTables = true;
46
+ continue;
47
+ }
48
+ continue;
49
+ }
50
+ if (line.trim() === "")
51
+ continue;
52
+ const m = /^\s+-\s+(\S.*)$/.exec(line);
53
+ if (m) {
54
+ out.push(m[1].replace(/^['"]|['"]$/g, ""));
55
+ continue;
56
+ }
57
+ if (/^\S/.test(line))
58
+ break; // 顶层新键,tables 块结束
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** 当前方言(oj 注入 db.dialect,缺省 sqlite) */
64
+ function currentDialect(): string {
65
+ const d = (db as { dialect?: () => string }).dialect;
66
+ return typeof d === "function" ? d() : "sqlite";
67
+ }
68
+
69
+ /** 表目录查询片段(方言无关:sqlite=sqlite_master,mysql/pg=information_schema) */
70
+ export function catalogSpec(dialect: string): { existing: string, like: (pat: string) => [string, unknown[]] } {
71
+ if (dialect === "sqlite") {
72
+ return {
73
+ existing: "SELECT name FROM sqlite_master WHERE type='table'",
74
+ like: pat => ["SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '!'", [pat]],
75
+ };
76
+ }
77
+ const schemaExpr = dialect === "mysql" ? "DATABASE()" : "'public'";
78
+ return {
79
+ existing: `SELECT table_name AS name FROM information_schema.tables WHERE table_schema = ${schemaExpr}`,
80
+ like: pat => [`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = ${schemaExpr} AND table_name LIKE ? ESCAPE '!'`, [pat]],
81
+ };
82
+ }
83
+
84
+ /** 当前库实际存在的表名集合 */
85
+ async function listExistingTables(dialect: string): Promise<Set<string>> {
86
+ const rows = (await db.query(catalogSpec(dialect).existing)) as Array<{ name: string }>;
87
+ return new Set(rows.map(r => r.name));
88
+ }
89
+
90
+ /** 模块 slug 规范化 + LIKE 转义(沿用 RBAC 权限命名空间约定:!%_ 转义,连字符→下划线作表前缀) */
91
+ export function tablePrefixPattern(name: string): string {
92
+ const slug = name.replace(/-/g, "_").replace(/[!%_]/g, c => `!${c}`);
93
+ // 表名用下划线分隔(非权限码的 `.`):slug_ + 通配符 %(末尾 % 不转义,保持通配语义)
94
+ return `${slug}_%`;
95
+ }
96
+
97
+ /** 收集需保留的业务表(Q4):声明 + 前缀兜底;仅返回实际存在的表名 */
98
+ export async function collectRetainedTables(name: string, manifestPath: string): Promise<string[]> {
99
+ const dialect = currentDialect();
100
+ const existing = await listExistingTables(dialect);
101
+ const candidates = new Set<string>();
102
+ // ① 声明式权威源:manifest.tables(通知等模块已在 manifest 声明业务表归属)
103
+ try {
104
+ const text = new TextDecoder().decode(await fsFace().readFile(manifestPath));
105
+ for (const t of manifestTables(text))
106
+ candidates.add(t);
107
+ }
108
+ catch {
109
+ // manifest 缺失(api-only 方案)→ 仅前缀兜底
110
+ }
111
+ // ② 前缀兜底:扫描当前库匹配模块前缀的表,覆盖未声明场景
112
+ try {
113
+ const [sql, params] = catalogSpec(dialect).like(tablePrefixPattern(name));
114
+ const rows = (await db.query(sql, params)) as Array<{ name: string }>;
115
+ for (const r of rows)
116
+ candidates.add(r.name);
117
+ }
118
+ catch {
119
+ // 兜底扫描失败不阻断卸载
120
+ }
121
+ return [...candidates].filter(t => existing.has(t));
122
+ }
123
+
124
+ /** 卸载影响面预检(GET uninstall-impact 消费面):返回前端二次确认所需的影响计数 */
125
+ export interface UninstallImpact {
126
+ name: string
127
+ affectedRoles: number
128
+ affectedUsers: number
129
+ }
130
+
131
+ export interface UninstallInput {
132
+ name: string
133
+ /** 可选显式版本;缺省回落锁/sidecar 版本 */
134
+ version?: string
135
+ sharedRoot: string
136
+ lockPath: string
137
+ state: StateStore
138
+ modulesJsonPath: string
139
+ /** 操作人(用户名,历史留痕;缺省 null) */
140
+ operator?: string | null
141
+ now?: number
142
+ /** 审计出口(缺省 auditOp;测试注入记录器) */
143
+ audit?: (entry: OpEntry) => void
144
+ }
145
+
146
+ export interface UninstallResult {
147
+ ok: boolean
148
+ message: string
149
+ /** P3-11 失败细分:not-installed = 方案已不存在(锁与 sidecar 均无记录)——端点据此回 404(资源缺失语义),其余拒绝仍 200 {ok:false} */
150
+ reason?: "not-installed"
151
+ /** P3-4(Q4)业务表保留清单:卸载不删业务数据,仅回传实际存在的业务表名供运维手工清理 */
152
+ retained?: string[]
153
+ }
154
+
155
+ /** 计算卸载影响面(角色数 + 持有这些角色的用户数) */
156
+ export async function computeUninstallImpact(input: UninstallInput): Promise<UninstallImpact> {
157
+ const { name } = input;
158
+ const roles = await db.query("select name from sys_role where module = ?", [name]) as Array<{ name: string }>;
159
+ const roleNames = roles.map(r => r.name);
160
+ let affectedUsers = 0;
161
+ if (roleNames.length > 0) {
162
+ const rows = await db.query(
163
+ `select distinct user_id from sys_user_role where role_name in (${roleNames.map(() => "?").join(",")})`,
164
+ roleNames,
165
+ ) as Array<{ user_id: number }>;
166
+ affectedUsers = rows.length;
167
+ }
168
+ return { name, affectedRoles: roleNames.length, affectedUsers };
169
+ }
170
+
171
+ /**
172
+ * 卸载编排(四处同删 + 权限级联 + 版本目录移除 + 审计 + 全用户失效 + 历史留痕)。
173
+ * 任一步失败返回 {ok:false, message}(操作已执行、被域逻辑拒绝,人话在 message,前端照 ok 位渲染,
174
+ * 与 setEnabled 同纪律);锁/sidecar/route/modules 可能留部分残差(admin 手动重试或重装自愈)。
175
+ */
176
+ export async function runUninstall(input: UninstallInput): Promise<UninstallResult> {
177
+ const { name } = input;
178
+ const f = fsFace();
179
+ const lock = await readLock(input.lockPath);
180
+ const side = input.state.read().solutions[name];
181
+
182
+ // 安装在哪:锁与 sidecar 均无记录 = 未安装(P3-11:细分 reason,端点据此回 404 资源缺失语义)
183
+ if (lock[name] === undefined && side === undefined) {
184
+ return { ok: false, reason: "not-installed", message: `卸载失败:「${name}」未安装(锁与 sidecar 均无记录)` };
185
+ }
186
+ const version = input.version ?? lock[name] ?? side?.version;
187
+ if (version === undefined) {
188
+ return { ok: false, message: `卸载失败:「${name}」版本不可得(锁与 sidecar 均无版本)` };
189
+ }
190
+
191
+ // 空锁拒启(S1-1.2):摘除后锁为空 → 拒绝卸载最后一个模块
192
+ const remaining = { ...lock };
193
+ delete remaining[name];
194
+ if (Object.keys(remaining).length === 0) {
195
+ return { ok: false, message: "卸载失败:摘除后锁为空(空锁拒启,S1-1.2)——站点至少保留一个已锁模块,不能卸载最后一个" };
196
+ }
197
+
198
+ const audit = (ok: boolean, detail: string): void =>
199
+ (input.audit ?? auditOp)({ action: "solution.uninstall", module: name, target: name, detail: { ok, detail }, level: ok ? 6 : 4 });
200
+
201
+ // ① DB 级联单一事务(P3-3):route 删除 + 权限双维度级联(uninstallSolution)纳入同一 BEGIN/COMMIT/ROLLBACK
202
+ // 边界。任一步失败 → 整段回滚(无角色/权限/路由半残),锁不摘(fail-closed:模块仍挂载、可重试或重装自愈)。
203
+ try {
204
+ await db.tx(async (t) => {
205
+ await t.exec("delete from sys_solution_route where module = ?", [name]);
206
+ await uninstallSolution(name, t);
207
+ });
208
+ }
209
+ catch (e) {
210
+ audit(false, `权限级联清理失败:${String(e)}`);
211
+ return { ok: false, message: `卸载失败:权限级联清理失败(${String(e)})——事务已回滚,锁/路由/角色/权限未改动(fail-closed)` };
212
+ }
213
+
214
+ // ② sidecar 删条目(patch 值 null = 删除该方案键)
215
+ try {
216
+ await input.state.write({ solutions: { [name]: null } });
217
+ }
218
+ catch (e) {
219
+ audit(false, `sidecar 写失败:${String(e)}`);
220
+ return { ok: false, message: `卸载失败:sidecar 写失败(${String(e)})——DB 级联已提交,锁未摘(fail-closed)` };
221
+ }
222
+
223
+ // ③ modules.json 删条目(条目不存在 = api-only 方案,天然一致)
224
+ try {
225
+ const list = JSON.parse(new TextDecoder().decode(await f.readFile(input.modulesJsonPath))) as unknown[];
226
+ if (!Array.isArray(list)) {
227
+ throw new TypeError("modules.json 顶层不是数组");
228
+ }
229
+ const next = list.filter(e => !(isObj(e) && e.name === name));
230
+ await atomicWriteText(input.modulesJsonPath, `${JSON.stringify(next, null, 2)}\n`);
231
+ }
232
+ catch (e) {
233
+ audit(false, `modules.json 写失败:${String(e)}`);
234
+ return { ok: false, message: `卸载失败:modules.json 写失败(${String(e)})——DB 级联已提交、sidecar 已删,锁未摘(fail-closed)` };
235
+ }
236
+
237
+ // ① 锁摘除(最后执行:模块停止装配;在此之前任一失败锁均保留 = fail-closed)
238
+ try {
239
+ await writeLock(input.lockPath, remaining);
240
+ }
241
+ catch (e) {
242
+ audit(false, `锁写失败:${String(e)}`);
243
+ return { ok: false, message: `卸载失败:锁写失败(${String(e)})——DB 级联/sidecar/modules 已更新,锁残留` };
244
+ }
245
+
246
+ // 版本目录移除(缺失不报错,幂等)
247
+ // —— 但 Q4 业务表清单须在版本目录移除前从 manifest 取得(manifest 在版本目录内)——
248
+ let retained: string[] = [];
249
+ try {
250
+ retained = await collectRetainedTables(name, `${input.sharedRoot}/${name}-${version}/manifest.yaml`);
251
+ }
252
+ catch {
253
+ retained = [];
254
+ }
255
+ try {
256
+ await f.remove(`${input.sharedRoot}/${name}-${version}`, { recursive: true });
257
+ }
258
+ catch {
259
+ // 目录缺失 = 已清理,忽略
260
+ }
261
+
262
+ // 权限已变 → 强退所有会话(D10:撤销即强退)
263
+ invalidateAllUsers();
264
+
265
+ // 终态审计
266
+ audit(true, `卸载完成:${name}@${version}`);
267
+
268
+ // 历史留痕(守卫式:表不存在/写失败静默,history.ts 同语义)
269
+ await recordHistory({
270
+ solution: name,
271
+ version,
272
+ action: "uninstall",
273
+ status: "success",
274
+ operator: input.operator ?? null,
275
+ message: `卸载完成:${name}@${version}`,
276
+ });
277
+
278
+ return {
279
+ ok: true,
280
+ message: `卸载完成:锁摘除 + sidecar + sys_solution_route + modules.json 四处同删 + 权限级联清理 + 版本目录移除(${name}@${version});业务表 ${retained.length} 张已保留(未删除,需运维手工清理)`,
281
+ retained,
282
+ };
283
+ }