@oj-module/cli 0.2.66 → 0.2.67

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 (187) 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 +30 -1
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +38 -12
  7. package/devkit/publish-walkthrough.md +110 -37
  8. package/devkit/wecom-internal-app.md +7 -2
  9. package/devkit/wecom-multi-corp-links.md +163 -0
  10. package/package.json +2 -2
  11. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  12. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
  13. package/shell-dist/index.html +4 -4
  14. package/shell-dist/versions.json +3 -3
  15. package/src/__tests__/publish-keygen.test.ts +157 -0
  16. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  19. package/src/bundle.ts +8 -3
  20. package/src/cli.ts +17 -5
  21. package/src/dev.ts +15 -10
  22. package/src/install.ts +11 -4
  23. package/src/keygen.ts +54 -0
  24. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  25. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  26. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  27. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  28. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  29. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  30. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  31. package/src/manifest-contract.ts +91 -8
  32. package/src/market/bootstrap.ts +26 -2
  33. package/src/market/command.ts +1 -0
  34. package/src/market/publish.ts +5 -1
  35. package/src/market/walkthrough.ts +24 -8
  36. package/src/site.ts +2 -0
  37. package/src/usage.ts +8 -4
  38. package/src/vendor.ts +5 -1
  39. package/templates/config.yaml +4 -2
  40. package/templates/init.include.yaml +8 -3
  41. package/templates/openapi.json +460 -1
  42. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  43. package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
  44. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  45. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  46. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  47. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  48. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  49. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  50. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  51. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  52. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  53. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  54. package/templates/src/app/api/_platform/market/api.ts +235 -10
  55. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  56. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  57. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  58. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  59. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  60. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  61. package/templates/src/app/api/_platform/schema.yaml +18 -0
  62. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  63. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  64. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  65. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  66. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  67. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  68. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  69. package/templates/src/app/api/home/seed.pg.sql +71 -0
  70. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  71. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  72. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  73. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  74. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  75. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  76. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  77. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  78. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  79. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  80. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  81. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  82. package/templates/src/app/api/publish/api.ts +349 -0
  83. package/templates/src/app/api/publish/manifest.yaml +8 -0
  84. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  85. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  86. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  87. package/templates/src/app/api/publish/schema.yaml +33 -0
  88. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  90. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  91. package/templates/src/app/web/entry.ts +9 -5
  92. package/templates/src/app/web/home/routes.ts +5 -0
  93. package/templates/src/app/web/login/locales/en-US.json +7 -1
  94. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  95. package/templates/src/app/web/login/pages/login.tsx +79 -86
  96. package/templates/src/app/web/login/routes.ts +54 -1
  97. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  98. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  99. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  100. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  101. package/templates/src/app/web/system/locales/en-US.json +1 -8
  102. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  103. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  104. package/templates/src/app/web/system/routes.ts +0 -6
  105. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  106. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  107. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  108. package/templates/src/oj-market/web/api/client.ts +3 -0
  109. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  110. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  111. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  112. package/templates/src/oj-market/web/entry.ts +31 -2
  113. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  114. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  115. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  116. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  117. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  118. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  119. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  120. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  121. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  122. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  123. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  124. package/templates/src/oj-market/web/types.ts +30 -0
  125. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  126. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  127. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  128. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  129. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  130. package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
  131. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  132. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  133. package/templates/tests/publish-credential.spec.ts +354 -0
  134. package/templates/tests/publish-identity.spec.ts +44 -0
  135. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  136. package/templates/tests/wecom-landing.spec.ts +63 -1
  137. package/templates/tsconfig.paths.json +1 -0
  138. package/templates/web.config.ts +6 -4
  139. package/vendor/host-versions.json +3 -3
  140. package/templates/seed.mysql.sql +0 -6
  141. package/templates/seed.pg.sql +0 -6
  142. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  143. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  144. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  145. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  146. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  147. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  148. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  149. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  150. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  151. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  152. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  153. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  154. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  155. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  156. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  157. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  158. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  159. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  160. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  161. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  162. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  163. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  164. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  165. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  166. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  168. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  169. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  170. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  171. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  172. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  173. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  174. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  175. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  176. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  177. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  178. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  179. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  180. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  181. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  182. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  183. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  184. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  185. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  186. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  187. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
package/src/dev.ts CHANGED
@@ -230,12 +230,6 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
230
230
  proxyWsUpgrade(ojTarget)(req, socket, head);
231
231
  });
232
232
 
233
- // 退出回收:server 关闭 → SSE 通道 + oj 子进程一并回收
234
- server.on("close", () => {
235
- hub.close();
236
- void oj?.stop();
237
- });
238
-
239
233
  // 终端 Ctrl+C / `kill`(SIGINT / SIGTERM)终止 dev 进程时,后端 oj 必须随之消亡:
240
234
  // 起一个统一优雅停机,先停 oj 再退出;并打印后端终止信息。
241
235
  // 终端 Ctrl-C 是进程组信号:外层 bin 转发一份 + 组送达一份,内层会收到
@@ -249,13 +243,24 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
249
243
  shuttingDown = true;
250
244
  void shutdownDev(server, oj, hub);
251
245
  };
252
- process.on("SIGINT", shutdown);
253
- process.on("SIGTERM", shutdown);
254
-
255
246
  // 兜底:dev 进程以任何非优雅路径退出(未走 SIGINT/SIGTERM,如未捕获异常)时,
256
247
  // 同步强杀 oj,确保它不孤儿化继续占端口(shutdownDev 已停则 kill 为 no-op)。
257
- process.once("exit", () => {
248
+ const killOjOnExit = () => {
258
249
  oj?.kill?.();
250
+ };
251
+ process.on("SIGINT", shutdown);
252
+ process.on("SIGTERM", shutdown);
253
+ process.once("exit", killOjOnExit);
254
+
255
+ // 退出回收:server 关闭 → SSE 通道 + oj 子进程一并回收,并摘除本实例注册的
256
+ // 进程级信号/exit handler。devServer 可在同一进程被多次调用(编程式 / 测试),
257
+ // 不摘除会累积闭包、触发 MaxListenersExceededWarning(与 preview 的 stop 同款)。
258
+ server.on("close", () => {
259
+ hub.close();
260
+ void oj?.stop();
261
+ process.removeListener("SIGINT", shutdown);
262
+ process.removeListener("SIGTERM", shutdown);
263
+ process.removeListener("exit", killOjOnExit);
259
264
  });
260
265
 
261
266
  // 端口被占用时自动顺延,避免 `EADDRINUSE` 直接让 `pnpm dev` 以非零码退出
package/src/install.ts CHANGED
@@ -254,6 +254,11 @@ export interface InstallOptions {
254
254
  locale?: string
255
255
  /** REG-R9②:从指定 registry 源安装(白名单在服务端;旧站点 API < 0.2.0 不支持 → exit 1) */
256
256
  registry?: string
257
+ /**
258
+ * 强制重装配(oj 0.1.123+ reloadConfig(force)):旁路「unchanged=已是最新装配」幂等短路,
259
+ * 强制宿主重做一次装配。运维显式重装配用;缺省 false。
260
+ */
261
+ force?: boolean
257
262
  /** 测试注入;缺省 globalThis.fetch */
258
263
  fetchFn?: typeof fetch
259
264
  /** 测试注入;缺省真睡 */
@@ -269,6 +274,8 @@ export async function installCommand(target: string, opts: InstallOptions = {}):
269
274
  const fetchFn = opts.fetchFn ?? globalThis.fetch;
270
275
  const { base, user, pass } = resolveSite(opts);
271
276
  const token = await login(fetchFn, base, user, pass);
277
+ // force 透传(oj 0.1.123+ reloadConfig(force) 逃逸舱):仅运维显式重装配时用;常规安装走锁 diff
278
+ const forceQ = opts.force === true ? "?force=1" : "";
272
279
 
273
280
  let response: { task_id: string };
274
281
  if (opts.registry !== undefined) {
@@ -282,13 +289,13 @@ export async function installCommand(target: string, opts: InstallOptions = {}):
282
289
  if (!isSiteApiAtLeast(meta.apiVersion, "0.2.0"))
283
290
  throw new Error(`[ojm] 目标站点平台 API 版本过低(需 ≥ 0.2.0 才支持 --registry 源安装,实为 ${meta.apiVersion ?? "未知"})——请升级目标站点的 oj-module,或改用 CDN/file 源`);
284
291
  const registryOrigin = originOf(src.url) ?? src.url;
285
- response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
292
+ response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", `/api/_platform/market/install${forceQ}`, {
286
293
  name: slug,
287
294
  version: opts.version ?? version,
288
295
  source: "registry",
289
296
  registry: registryOrigin,
290
297
  });
291
- console.log(`[ojm] 已从 registry 源提交安装任务(源 ${registryOrigin},物理名 ${slug})`);
298
+ console.log(`[ojm] 已从 registry 源提交安装任务(源 ${registryOrigin},物理名 ${slug}${opts.force === true ? ",强制重装配" : ""})`);
292
299
  }
293
300
  else {
294
301
  const parsed = parseTarget(target);
@@ -314,7 +321,7 @@ export async function installCommand(target: string, opts: InstallOptions = {}):
314
321
  else {
315
322
  console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
316
323
  }
317
- const res = await fetchFn(`${base}/api/_platform/market/install`, {
324
+ const res = await fetchFn(`${base}/api/_platform/market/install${forceQ}`, {
318
325
  method: "POST",
319
326
  headers: { authorization: `Bearer ${token}`, ...tenantHeaders() },
320
327
  body: form,
@@ -324,7 +331,7 @@ export async function installCommand(target: string, opts: InstallOptions = {}):
324
331
  else {
325
332
  if (opts.name !== undefined && opts.name !== parsed.name)
326
333
  throw new Error(`[ojm] --name「${opts.name}」与目标「${parsed.name}」不一致——请二选一`);
327
- response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
334
+ response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", `/api/_platform/market/install${forceQ}`, {
328
335
  name: parsed.name,
329
336
  version: opts.version ?? parsed.version,
330
337
  source: "cdn",
package/src/keygen.ts CHANGED
@@ -10,6 +10,7 @@ import path from "node:path";
10
10
  import process from "node:process";
11
11
  import { generateKeyPair } from "./lifecycle/keygen";
12
12
  import { fingerprintOfPublicKey } from "./lifecycle/sign";
13
+ import { DEFAULT_SITE_URL, tenantHeaders, unwrapEnvelope } from "./site";
13
14
 
14
15
  export interface KeygenOptions {
15
16
  /** 发布方 key_id(C2-37:<publisher>.<id> 全小写,禁裸通用名) */
@@ -67,3 +68,56 @@ export function keygenCommand(opts: KeygenOptions = {}): KeygenResult {
67
68
  console.log(`[ojm] 下一步:ojm sign <pkg>.tgz --id ${keyId}(或 --key ${privateKeyPath} --id ${keyId})`);
68
69
  return { keyId, publicKeyHex: kp.publicKeyHex, fingerprint, dir, privateKeyPath, publicKeyPath };
69
70
  }
71
+
72
+ /** 兑换结果:服务端下发身份(CLI 零推导——编码规则只在 templates 侧一份) */
73
+ export interface ExchangedIdentity {
74
+ key_id: string
75
+ product: string
76
+ username: string
77
+ display_name: string | null
78
+ }
79
+
80
+ export interface ExchangeOptions {
81
+ /** 站点地址;flag > OJM_SITE_URL > DEFAULT_SITE_URL */
82
+ siteUrl?: string
83
+ /** 测试注入 */
84
+ fetchFn?: typeof fetch
85
+ }
86
+
87
+ /**
88
+ * 用一次性发布凭证换身份(匿名端点,不需要账号密码)。
89
+ *
90
+ * 只做「取回服务端算好的 key_id」——不做任何编码推导(规则漂移比多一次网络请求贵得多),
91
+ * 但**必须**校验拿回的 key_id 形状:它马上会被拼进 `~/.ojm/keys/<key_id>`,
92
+ * 一个被中间人改成 `../../x` 的响应就是任意路径写入。失败一律不落盘(调用方先兑换后生成)。
93
+ */
94
+ export async function exchangePublishCredential(token: string, opts: ExchangeOptions = {}): Promise<ExchangedIdentity> {
95
+ const t = token.trim();
96
+ // 形状先挡:不是 64 位 hex 的输入连请求都不必发(与页面生成规则一致)
97
+ if (!/^[0-9a-f]{64}$/i.test(t))
98
+ throw new Error("[ojm] 发布凭证格式不对(应为 64 位十六进制):请在站点「发布管理」页复制完整凭证");
99
+
100
+ const base = (opts.siteUrl ?? process.env.OJM_SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
101
+ const fetchFn = opts.fetchFn ?? fetch;
102
+ let res: Response;
103
+ try {
104
+ res = await fetchFn(`${base}/api/publish/credential/exchange`, {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json", ...tenantHeaders() },
107
+ body: JSON.stringify({ token: t }),
108
+ });
109
+ }
110
+ catch (e) {
111
+ throw new Error(`[ojm] 无法连接站点 ${base}:${e instanceof Error ? e.message : String(e)}——请确认站点地址(--site-url / OJM_SITE_URL)与网络`);
112
+ }
113
+ const data = await unwrapEnvelope(res) as Partial<ExchangedIdentity> | null;
114
+ const keyId = String(data?.key_id ?? "");
115
+ if (!isValidKeyId(keyId))
116
+ throw new Error(`[ojm] 站点返回的 key_id 非法(C2-37):「${keyId}」——已中止,未写入任何密钥(请核对站点版本)`);
117
+ return {
118
+ key_id: keyId,
119
+ product: String(data?.product ?? ""),
120
+ username: String(data?.username ?? ""),
121
+ display_name: data?.display_name == null ? null : String(data.display_name),
122
+ };
123
+ }
@@ -53,8 +53,8 @@ function makePrebuiltArtifacts(): void {
53
53
  const apiDist = join(project, "dist/api/demo-solution-0.1.0");
54
54
  mkdirSync(join(apiDist, "ping"), { recursive: true });
55
55
  mkdirSync(join(apiDist, "migrations"), { recursive: true });
56
- writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - note\n");
57
- writeFileSync(join(apiDist, "schema.yaml"), "tables:\n note: {}\n");
56
+ writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - demo_solution_note\n");
57
+ writeFileSync(join(apiDist, "schema.yaml"), "prefix: demo_solution_\ntables:\n demo_solution_note: {}\n");
58
58
  writeFileSync(join(apiDist, "routes.js"), "export {};\n");
59
59
  writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
60
60
  // 站点构建产物(dist/web/modules/<name>/<version>/entry.js + css,对应 web.entry/css 声明)
@@ -317,11 +317,29 @@ describe("buildBundle · 校验先于打包(fail-fast)", () => {
317
317
 
318
318
  it("c2-12a:api.tables 与 schema.yaml 表集不一致 → 构建期拒绝", async () => {
319
319
  const m = readManifest();
320
- m.api.tables = ["note", "ghost"];
320
+ m.api.tables = ["demo_solution_note", "ghost"];
321
321
  writeManifest(m);
322
322
  await expect(pack()).rejects.toThrow(/C2-12a/);
323
323
  });
324
324
 
325
+ it("c2-12c:schema.yaml 缺 prefix / 表名未带方案前缀 → 构建期拒绝", async () => {
326
+ const schemaPath = join(project, "api/src/demo-solution/schema.yaml");
327
+ writeFileSync(schemaPath, "tables:\n demo_solution_note: {}\n");
328
+ await expect(pack()).rejects.toThrow(/C2-12c.*prefix/);
329
+
330
+ writeFileSync(schemaPath, "prefix: demo_solution_\ntables:\n note: {}\n");
331
+ await expect(pack()).rejects.toThrow(/C2-12c.*demo_solution_note/);
332
+ });
333
+
334
+ it("c2-12c:prefix 落在第一方保留段 → 构建期拒绝", async () => {
335
+ const m = readManifest();
336
+ m.api.tables = ["oj_note"];
337
+ writeManifest(m);
338
+ const schemaPath = join(project, "api/src/demo-solution/schema.yaml");
339
+ writeFileSync(schemaPath, "prefix: oj_\ntables:\n oj_note: {}\n");
340
+ await expect(pack()).rejects.toThrow(/C2-12c.*保留段/);
341
+ });
342
+
325
343
  it("c2-25:locales 缺引用 key → 构建期 fail-fast", async () => {
326
344
  const m = readManifest();
327
345
  m.roles[0].title_key = "demo-solution:role.absent";
@@ -6,4 +6,4 @@ name: demo-solution
6
6
  desc: manifest.yaml 契约全字段合法夹具
7
7
  version: 0.1.0
8
8
  tables:
9
- - note
9
+ - demo_solution_note
@@ -1,5 +1,5 @@
1
- -- 0001__init:建 note 表(与 schema.yaml 列集一致;oj migrate 按 seq 升序执行)
2
- CREATE TABLE IF NOT EXISTS note (
1
+ -- 0001__init:建 demo_solution_note 表(与 schema.yaml 列集一致;oj migrate 按 seq 升序执行)
2
+ CREATE TABLE IF NOT EXISTS demo_solution_note (
3
3
  id INTEGER PRIMARY KEY AUTOINCREMENT,
4
4
  title TEXT NOT NULL,
5
5
  create_time INTEGER NOT NULL
@@ -1,7 +1,10 @@
1
1
  # 声明式表结构(形态对齐 apps/playground-oj/api/src/demo/schema.yaml)。
2
- # 契约 C2-12:bundle manifest 的 api.tables 必须与本表 keys 一致(oj 冗余校验 fail-fast)。
2
+ # C2-12c:prefix 必须 = 模块名 kebab→snake 再补尾部 `_`(demo-solution → demo_solution_),
3
+ # 且每张表名以它开头——表名全库共享,前缀把归属编码进名字,避免方案之间撞表。
4
+ prefix: demo_solution_
5
+ # 契约 C2-12a:bundle manifest 的 api.tables 必须与本表 keys 一致(oj 冗余校验 fail-fast)。
3
6
  tables:
4
- note:
7
+ demo_solution_note:
5
8
  pk: id
6
9
  columns:
7
10
  id: {type: integer, autoincrement: true}
@@ -26,7 +26,7 @@ peerPlatform: '>=0.1.0' # C2-11(P2 起对宿主 API_VERSION 强制;本仓 AP
26
26
  api:
27
27
  module: demo-solution # C2-12(存在时必须 === name)
28
28
  prefix: /api/demo-solution # C2-13(默认形态显式写出)
29
- tables: [note] # 与 schema.yaml 表集一致(oj 冗余校验同源)
29
+ tables: [demo_solution_note] # 与 schema.yaml 表集一致(oj 冗余校验同源);表名带 C2-12c 前缀
30
30
  deps: # 键 = 模块名(C2-01 语法或 _platform);值 = 范围算子语法
31
31
  _platform: '>=0.1.0'
32
32
  anonymous: # C2-26:首段 === 模块段;无登录语义端点
@@ -14,7 +14,7 @@ import { parse } from "yaml";
14
14
  // 跨端引用(双端一致钉):被测实现同仓分居两运行时(计划 §1「两运行时取舍」),
15
15
  // 测试直接互调,比「两套件各读同一夹具」更强——漂移即本文件红。
16
16
  import { preflight } from "../../../templates/src/app/api/_platform/_lifecycle/steps/preflight";
17
- import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, RESERVED_NAMES, reservedNameSuggestions, validateContract } from "../../manifest-contract";
17
+ import { checkLocalesComplete, checkSchemaTablePrefix, checkTablesAgainstSchema, parseManifest, RESERVED_NAMES, reservedNameSuggestions, validateContract } from "../../manifest-contract";
18
18
 
19
19
  const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
20
20
  const readText = (p: string) => readFileSync(join(FIXTURES, p), "utf8");
@@ -214,19 +214,91 @@ describe("rEG-CS7 保留名扩项(红队 F5)", () => {
214
214
  describe("checkTablesAgainstSchema(C2-12a:构建 ✅ 对照 schema.yaml)", () => {
215
215
  const SCHEMA = readText("fixture-valid/api/src/demo-solution/schema.yaml");
216
216
 
217
- it("fixture-valid tables [note] 与 schema.yaml 表集一致 → 零违规", () => {
217
+ it("fixture-valid tables 与 schema.yaml 表集一致 → 零违规", () => {
218
218
  expect(checkTablesAgainstSchema(structuredClone(VALID), SCHEMA)).toEqual([]);
219
219
  });
220
220
 
221
221
  it("tables 与 schema.yaml 表集不一致 → C2-12a 且指明差额", () => {
222
222
  const m = structuredClone(VALID);
223
- (m.api as any).tables = ["note", "ghost"];
223
+ (m.api as any).tables = ["demo_solution_note", "ghost"];
224
224
  const v = checkTablesAgainstSchema(m, SCHEMA);
225
225
  expect(v[0]).toContain("C2-12a");
226
226
  expect(v[0]).toContain("ghost");
227
227
  });
228
228
  });
229
229
 
230
+ describe("checkSchemaTablePrefix(C2-12c:schema.yaml 表前缀)", () => {
231
+ const SCHEMA = readText("fixture-valid/api/src/demo-solution/schema.yaml");
232
+
233
+ it("fixture-valid 声明 prefix 且表名带前缀 → 零违规", () => {
234
+ expect(checkSchemaTablePrefix(SCHEMA)).toEqual([]);
235
+ });
236
+
237
+ it("允许自选短前缀(长模块名可用 crm_ 之类,不强制由模块名派生)", () => {
238
+ expect(checkSchemaTablePrefix("prefix: dm_\ntables:\n dm_note: {}\n")).toEqual([]);
239
+ });
240
+
241
+ it("缺 prefix → C2-12c", () => {
242
+ const v = checkSchemaTablePrefix("tables:\n demo_solution_note: {}\n");
243
+ expect(v[0]).toContain("C2-12c");
244
+ expect(v[0]).toContain("prefix");
245
+ });
246
+
247
+ it("prefix 不以 _ 结尾 → C2-12c 语法违规", () => {
248
+ const v = checkSchemaTablePrefix("prefix: demo_solution\ntables:\n demo_solution_note: {}\n");
249
+ expect(v[0]).toContain("C2-12c");
250
+ expect(v[0]).toContain("_ 结尾");
251
+ });
252
+
253
+ it("prefix 超过 32 字符 → C2-12c(吃掉表名可用空间)", () => {
254
+ const long = `${"a".repeat(33)}_`;
255
+ const v = checkSchemaTablePrefix(`prefix: ${long}\ntables:\n ${long}note: {}\n`);
256
+ expect(v[0]).toContain("C2-12c");
257
+ expect(v[0]).toContain("超过上限 32");
258
+ });
259
+
260
+ it("表名未带前缀 → C2-12c 并给出改名建议", () => {
261
+ const v = checkSchemaTablePrefix("prefix: demo_solution_\ntables:\n note: {}\n");
262
+ expect(v[0]).toContain("C2-12c");
263
+ expect(v[0]).toContain("demo_solution_note");
264
+ });
265
+
266
+ it("表名总长边界:63 合法、64 拒绝(PostgreSQL 上限 63 字节)", () => {
267
+ const p = "ab_";
268
+ const name63 = `${p}${"c".repeat(60)}`; // 3 + 60 = 63
269
+ const name64 = `${p}${"c".repeat(61)}`; // 3 + 61 = 64
270
+ expect(name63.length).toBe(63);
271
+ expect(checkSchemaTablePrefix(`prefix: ${p}\ntables:\n ${name63}: {}\n`)).toEqual([]);
272
+ const v = checkSchemaTablePrefix(`prefix: ${p}\ntables:\n ${name64}: {}\n`);
273
+ expect(v[0]).toContain("C2-12c");
274
+ expect(v[0]).toContain("63 字符");
275
+ });
276
+
277
+ it("引用式方案(api.tables 缺省)也做前缀检查", () => {
278
+ const m = structuredClone(VALID);
279
+ delete (m.api as any).tables;
280
+ const v = checkTablesAgainstSchema(m, "tables:\n note: {}\n");
281
+ expect(v[0]).toContain("C2-12c");
282
+ });
283
+
284
+ it("prefix 落在第一方保留段(oj_ / ojm_ 开头)→ C2-12c,整段禁用含派生形式", () => {
285
+ for (const p of ["oj_", "ojm_", "oj_crm_", "ojm_tools_"]) {
286
+ const v = checkSchemaTablePrefix(`prefix: ${p}\ntables:\n ${p}note: {}\n`);
287
+ expect(v.some(x => x.includes("C2-12c") && x.includes("保留段"))).toBe(true);
288
+ }
289
+ });
290
+
291
+ it("自有前缀不落在保留段 → 零违规(保留段检查的对照)", () => {
292
+ expect(checkSchemaTablePrefix("prefix: crm_\ntables:\n crm_note: {}\n")).toEqual([]);
293
+ });
294
+
295
+ it("_oj_ 因下划线开头只报语法违规,不重复报保留段", () => {
296
+ const v = checkSchemaTablePrefix("prefix: _oj_\ntables:\n _oj_note: {}\n");
297
+ expect(v[0]).toContain("必须匹配");
298
+ expect(v.every(x => !x.includes("保留段"))).toBe(true);
299
+ });
300
+ });
301
+
230
302
  describe("checkLocalesComplete(C2-25:构建期缺 key fail-fast)", () => {
231
303
  const ZH = readText("fixture-valid/web/src/demo-solution/locales/zh-CN.json");
232
304
  const EN = readText("fixture-valid/web/src/demo-solution/locales/en-US.json");
@@ -37,11 +37,11 @@ describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJ
37
37
  const apiDist = join(project, "dist/api/demo-solution-0.1.0");
38
38
  const { mkdirSync, writeFileSync } = await import("node:fs");
39
39
  mkdirSync(join(apiDist, "ping"), { recursive: true });
40
- // 迁移 SQL 随夹具 src 真源(0001__init.sql 建 note 表);B-11 硬验收:appliedSeq > 0 可断言
40
+ // 迁移 SQL 随夹具 src 真源(0001__init.sql 建 demo_solution_note 表);B-11 硬验收:appliedSeq > 0 可断言
41
41
  cpSync(join(FIXTURE_VALID, "api/src/demo-solution/migrations"), join(apiDist, "migrations"), { recursive: true });
42
42
  // schema.yaml 同随夹具真源(列集与迁移 SQL 一致):oj migrate 在 apply 后跑 schema
43
43
  // reconcile(migrate_cmd.rs migrate_one),声明缺列而实库多列 = Err——合成空列集会炸
44
- writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - note\n");
44
+ writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - demo_solution_note\n");
45
45
  cpSync(join(FIXTURE_VALID, "api/src/demo-solution/schema.yaml"), join(apiDist, "schema.yaml"));
46
46
  writeFileSync(join(apiDist, "routes.js"), "export {};\n");
47
47
  writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
@@ -297,8 +297,8 @@ export function validateContract(m: unknown): string[] {
297
297
  }
298
298
  if (api.tables !== undefined) {
299
299
  const t = api.tables;
300
- if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(x))) {
301
- v0.push(v("C2-12a", "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,63}$ 的字符串(与 api/src/<name>/schema.yaml 表集逐一相等另行对照)"));
300
+ if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,62}$/.test(x))) {
301
+ v0.push(v("C2-12a", "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,62}$ 的字符串(≤63 字符:PostgreSQL 标识符上限 63 字节、MySQL 64 字符,取最严值;与 api/src/<name>/schema.yaml 表集逐一相等另行对照)"));
302
302
  }
303
303
  }
304
304
  if (api.deps !== undefined) {
@@ -682,30 +682,113 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
682
682
  return out;
683
683
  }
684
684
 
685
+ /** C2-12c prefix 语法:小写字母开头,仅小写字母/数字/下划线,且以 `_` 结尾 */
686
+ const TABLE_PREFIX_RE = /^[a-z][a-z0-9_]*_$/;
687
+
688
+ /**
689
+ * 第一方保留的表前缀段:oj 引擎 / ojm CLI 的命名空间。
690
+ *
691
+ * 框架表(_platform / auth / home / notification 下的 users / sys_* / wecom_* 等)保持裸名,
692
+ * 是保留的框架命名空间;方案表名一律带作者自选前缀,与框架表名天然不重叠。把 `oj_` / `ojm_`
693
+ * 整段留给第一方,防止方案先占用、日后框架想用时反被占(S002 归属图以表名为全局主键)。
694
+ * 按「段」保留(startsWith),不只禁精确值——整个命名空间都归第一方。
695
+ * 注:下划线开头的 `_oj_`(oj 内建账本 `_oj_migrations`)本就不满足 TABLE_PREFIX_RE,无需列举。
696
+ */
697
+ const RESERVED_TABLE_PREFIX_SEGMENTS = ["oj_", "ojm_"];
698
+
699
+ /**
700
+ * 跨库可移植的表名上限:PostgreSQL 标识符上限 63 **字节**(NAMEDATALEN-1),
701
+ * MySQL 是 64 字符、SQLite 无实际限制——取最严的 63。表名字符集限定 ASCII,
702
+ * 字符数 = 字节数,因此 63 字符在三种库上都安全。
703
+ */
704
+ const MAX_TABLE_NAME_LEN = 63;
705
+
706
+ /** 前缀上限:给表名留 ≥31 字符(63 - 32),避免前缀把表名的可用空间吃光 */
707
+ const MAX_PREFIX_LEN = 32;
708
+
709
+ /**
710
+ * C2-12c `schema.yaml` 的 `prefix` 与逐表前缀校验。
711
+ *
712
+ * 表名在**全库共享**(oj 归属图以表名为主键),前缀把模块归属编码进名字,
713
+ * 让两个独立发布的方案不会因为都叫 `note` 而在安装期 S002 拒启。
714
+ * 前缀由作者选择(短、可读、尽量全局唯一),不强制由模块名派生——长模块名
715
+ * 可以用 `crm_` 之类短前缀,避免表名被模块名拖到数据库上限之外。
716
+ *
717
+ * 入参为 schema.yaml 文本(fs 在调用方)。YAML 解析失败返回 []——那是 C2-12a
718
+ * 的判罚面,同一事故不重复报两遍。
719
+ */
720
+ export function checkSchemaTablePrefix(schemaYamlText: string): string[] {
721
+ let doc: unknown;
722
+ try {
723
+ doc = parse(schemaYamlText);
724
+ }
725
+ catch {
726
+ return [];
727
+ }
728
+ if (!isObj(doc) || !isObj(doc.tables))
729
+ return []; // 缺 tables / 非映射由 C2-12a 报
730
+
731
+ const keys = Object.keys(doc.tables);
732
+ const out: string[] = [];
733
+ const prefix = doc.prefix;
734
+
735
+ if (prefix === undefined) {
736
+ out.push(v("C2-12c", "schema.yaml 缺 prefix:必须声明表前缀(如 prefix: crm_;小写字母开头、以 _ 结尾、≤32 字符,建议与方案名相关且全局唯一);表名在全库共享,前缀把归属编码进名字,避免方案之间撞表"));
737
+ }
738
+ else if (typeof prefix !== "string" || !TABLE_PREFIX_RE.test(prefix)) {
739
+ out.push(v("C2-12c", `schema.yaml 的 prefix「${String(prefix)}」必须匹配 ^[a-z][a-z0-9_]*_$(小写字母开头、仅小写字母/数字/下划线、以 _ 结尾)`));
740
+ }
741
+ if (typeof prefix === "string" && prefix.length > MAX_PREFIX_LEN) {
742
+ out.push(v("C2-12c", `schema.yaml 的 prefix「${prefix}」长 ${prefix.length} 字符,超过上限 ${MAX_PREFIX_LEN}——表名总长须 ≤${MAX_TABLE_NAME_LEN},前缀过长会吃掉表名可用空间;请改用更短的方案前缀(如 crm_)`));
743
+ }
744
+ if (typeof prefix === "string" && TABLE_PREFIX_RE.test(prefix)) {
745
+ const seg = RESERVED_TABLE_PREFIX_SEGMENTS.find(s => prefix.startsWith(s));
746
+ if (seg !== undefined) {
747
+ out.push(v("C2-12c", `schema.yaml 的 prefix「${prefix}」落在第一方保留段「${seg}*」——oj 引擎 / ojm CLI 的表命名空间由框架独占,方案不得占用,请改用自有前缀(如 crm_、acme_)`));
748
+ }
749
+ }
750
+
751
+ if (typeof prefix === "string" && prefix !== "") {
752
+ const offenders = keys.filter(k => !k.startsWith(prefix));
753
+ if (offenders.length > 0) {
754
+ out.push(v("C2-12c", `表名必须以本方案前缀「${prefix}」开头:${offenders.map(k => `「${k}」→「${prefix}${k}」`).join("、")}`));
755
+ }
756
+ }
757
+ // 表名语法/总长(引用式方案没有 api.tables 语法列兜底,这里一并查)
758
+ const malformed = keys.filter(k => !/^[a-z][a-z0-9_]{0,62}$/.test(k));
759
+ if (malformed.length > 0) {
760
+ out.push(v("C2-12c", `schema.yaml 表名必须匹配 ^[a-z][a-z0-9_]{0,62}$(小写字母开头,仅小写字母/数字/下划线,≤${MAX_TABLE_NAME_LEN} 字符——PostgreSQL 上限 63 字节、MySQL 64 字符,取最严值):${malformed.join("、")}`));
761
+ }
762
+ return out;
763
+ }
764
+
685
765
  /**
686
- * C2-12a api.tables 与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源,不一致 fail-fast)。
766
+ * C2-12a api.tables 与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源,不一致 fail-fast);
767
+ * 同时执行 C2-12c 表前缀校验——引用式方案(api.tables 缺省)只要 schema.yaml 存在也必须带前缀。
687
768
  * 入参为 schema.yaml 文本(fs 在调用方)。
688
769
  */
689
770
  export function checkTablesAgainstSchema(m: unknown, schemaYamlText: string): string[] {
771
+ const prefixViolations = checkSchemaTablePrefix(schemaYamlText);
772
+
690
773
  if (!isObj(m) || !isObj(m.api) || m.api.tables === undefined)
691
- return [];
774
+ return prefixViolations;
692
775
  const declared = m.api.tables;
693
776
  if (!Array.isArray(declared))
694
- return []; // 语法违规归 validateContract(C2-12a 语法列),此处只对照表集
777
+ return prefixViolations; // 语法违规归 validateContract(C2-12a 语法列),此处只对照表集
695
778
  let schemaTables: unknown;
696
779
  try {
697
780
  const doc = parse(schemaYamlText);
698
781
  schemaTables = isObj(doc) ? doc.tables : undefined;
699
782
  }
700
783
  catch {
701
- return [v("C2-12a", "schema.yaml 不是合法 YAML,无法对照 api.tables 表集")];
784
+ return [...prefixViolations, v("C2-12a", "schema.yaml 不是合法 YAML,无法对照 api.tables 表集")];
702
785
  }
703
786
  if (!isObj(schemaTables)) {
704
- return [v("C2-12a", "schema.yaml 缺 tables 映射,无法对照 api.tables 表集(oj 冗余校验同源)")];
787
+ return [...prefixViolations, v("C2-12a", "schema.yaml 缺 tables 映射,无法对照 api.tables 表集(oj 冗余校验同源)")];
705
788
  }
706
789
  const schemaKeys = new Set(Object.keys(schemaTables));
707
790
  const declaredKeys = new Set(declared.filter(x => typeof x === "string"));
708
- const out: string[] = [];
791
+ const out: string[] = [...prefixViolations];
709
792
  const missingInSchema = [...declaredKeys].filter(k => !schemaKeys.has(k));
710
793
  const undeclaredInManifest = [...schemaKeys].filter(k => !declaredKeys.has(k));
711
794
  if (missingInSchema.length > 0) {
@@ -69,6 +69,14 @@ export interface IsolatedSite {
69
69
  cleanup: () => Promise<void>
70
70
  /** 销毁清单(--keep 时给人看这里到底有什么) */
71
71
  teardownReport: () => string[]
72
+ /**
73
+ * 重启隔离站点 oj serve(停当前进程,按同一派生 config 重新起站)。
74
+ * 用途:无守护形态安装会落 partial(vars/auth 等冻结段热改属 restart_pending,需进程
75
+ * 重启才激活)。walkthrough 在每次安装后置重启,模拟真实「部署 → 重启」时序,让模块真正
76
+ * 上线(路由/匿名端点注册),使后续深检/升级/重装断言落到活跃态而非空洞的 partial。
77
+ * token 仍有效(jwt_secret 落在 config 文件,重启不变);DB 在同根,会话延续。
78
+ */
79
+ restart: () => Promise<void>
72
80
  }
73
81
 
74
82
  export interface BootstrapOptions {
@@ -178,8 +186,13 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
178
186
  // 隔离站点直接预置被测方案的探活路径——与 scripts/verify-solution-smoke/bootstrap-host.mjs 同手法,
179
187
  // 让 trial 验的是安装机制本身,而不是重启时序。
180
188
  const healthPath = `/${parseFixtureSpec(opts.fixture).name}/ping`;
181
- if (!cfg.includes(`- ${healthPath}`))
182
- cfg = cfg.replace(/^(\s*)- \/system\/login-methods$/m, `$1- /system/login-methods\n$1- ${healthPath}`);
189
+ // 锚点行可能带行内注释与缩进(模板 config.yaml 的 /system/login-methods 即如此),
190
+ // 故用 lookahead 容忍尾注释;命中第一个 anonymous_paths 列表项(auth 守卫读取的那个,
191
+ // 与安装器 appendAnonymousPaths 同目标)即可——旧写法用 `$` 硬锚尾,注释使其失配导致预置
192
+ // 静默失效,安装器随后自己补匿名路径 → auth 进 restart_pending → 无守护形态判 partial。
193
+ const loginAnchor = /^(\s*)- \/system\/login-methods(?=\s*(?:#.*)?$)/m;
194
+ if (loginAnchor.test(cfg) && !cfg.includes(`- ${healthPath}`))
195
+ cfg = cfg.replace(loginAnchor, `$1- /system/login-methods\n$1- ${healthPath}`);
183
196
  // fs 段:模板自 0.2.13 起已自带(安装器落盘面);旧模板没有时才补,避免重复键
184
197
  if (!/^fs:/m.test(cfg))
185
198
  cfg = `${cfg.trimEnd()}\nfs:\n root: .\n readonly: false\n`;
@@ -237,6 +250,16 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
237
250
  process.on("SIGINT", onSignal);
238
251
  process.on("SIGTERM", onSignal);
239
252
 
253
+ // 重启:停当前进程后按同一派生 config 重新起站(激活冻结段);cleanup 持有的是本作用域的
254
+ // `oj` 变量引用,故重启后 cleanup 停的是新进程(闭包捕获变量,非值)。
255
+ const restart = async () => {
256
+ log("[ojm] 隔离站点重启 oj serve(激活冻结段 auth/vars)…");
257
+ await oj.stop();
258
+ oj = starter(configPath, "/api", resolveLayout(root).apiDist);
259
+ await oj.ready;
260
+ log(`[ojm] 隔离站点重启就绪:http://127.0.0.1:${port}(根 ${root})`);
261
+ };
262
+
240
263
  return {
241
264
  root,
242
265
  port,
@@ -249,6 +272,7 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
249
272
  admin: { user: SEED_ADMIN_USER, pass: SEED_ADMIN_PASS },
250
273
  cleanup,
251
274
  teardownReport: () => teardownReport(root, port),
275
+ restart,
252
276
  };
253
277
  }
254
278
 
@@ -109,6 +109,7 @@ export async function marketCommand(sub: string, argv: string[]): Promise<void>
109
109
  key: flags.key,
110
110
  check: flags.check,
111
111
  registryId: flags.registryId,
112
+ rebuildIndex: flags.rebuildIndex === true,
112
113
  });
113
114
  if (flags.rebuildIndex === true)
114
115
  await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
@@ -38,6 +38,8 @@ export interface MarketPublishOptions {
38
38
  check?: boolean
39
39
  /** 签发方 registry 身份(REG-CS3 v2 防跨仓库重放;透传 signCommand --registry-id) */
40
40
  registryId?: string
41
+ /** 命令层是否紧接着重建索引(仅用于措辞:true 时不打印「索引未重建」) */
42
+ rebuildIndex?: boolean
41
43
  }
42
44
 
43
45
  export interface MarketPublishResult {
@@ -88,7 +90,9 @@ export async function publishPackage(opts: MarketPublishOptions = {}): Promise<M
88
90
  const size = fs.statSync(tgzPath).size;
89
91
  console.log(`[ojm] 包已落 CDN → ${path.relative(projectRoot, tgzPath)}(${size} 字节)`);
90
92
  console.log(`[ojm] 发布方 ${signed.keyId};指纹 ${signed.fingerprint}`);
91
- console.log("[ojm] 索引未重建:多人场景请由维护者跑 ojm market build-index");
93
+ // 带了 --rebuild-index 时由命令层紧接着重建索引,这里不能再报「未重建」(否则前后两句自相矛盾)
94
+ if (opts.rebuildIndex !== true)
95
+ console.log("[ojm] 索引未重建:多人场景请由维护者跑 ojm market build-index");
92
96
 
93
97
  return {
94
98
  name,